Remote MCP authorization
See the MCP Apps integration standard for portable embedded UI, optional OpenAI extensions, ownership and staged release gates.
This is the deployment contract for Anytown, Ergot, and other applications that
put a SMRT MCP HTTP surface on the public internet. SMRT currently supplies the
application-scoped /api/mcp stateless Streamable HTTP endpoint and a local
stdio bridge. The REST-shaped /api/mcp/tools and /api/mcp/call adapters are
deprecated compatibility routes for one release. SMRT does not supply an
OAuth authorization server. Use an application gateway or the server-only JWT
adapter below to validate tokens before supplying an authenticated principal.
The issuer remains operator-owned; resolve current application membership and
tenant authority on every request.
Authorization-server contract
Use one stable HTTPS issuer identifier per authorization server. The application gateway or server-only JWT adapter
must validate token signature, iss, audience/resource, expiry, and scopes
before protected MCP operations run. Never accept a token minted for another
issuer or resource.
The authorization server metadata must:
- expose the exact issuer as
issuer; - set
authorization_response_iss_parameter_supported: trueand include that exact value asissin successful and error authorization responses; - advertise
client_id_metadata_document_supported: truewhen supported; - expose
registration_endpointonly when RFC 7591 Dynamic Client Registration is intentionally retained as a compatibility fallback.
Clients compare response iss and discovered issuer with exact string
comparison. A missing iss from a server that advertised support, or any
mismatch (including a trailing-slash difference), aborts the grant before the
authorization code is exchanged.
Client registration
Prefer a pre-registered client when one exists. Otherwise use a Client ID
Metadata Document; use DCR only when the authorization server does not advertise
metadata-document support. @happyvertical/smrt-app-cli exports
resolveMcpClientRegistration() to apply that order and
createMcpClientIdMetadataDocument() to build the document.
const registration = resolveMcpClientRegistration(serverMetadata, {
applicationType: 'native',
clientId: 'https://client.anytown.example/oauth/mcp.json',
clientName: 'Anytown MCP Client',
redirectUris: ['http://127.0.0.1:3210/callback'],
});
Host the returned metadata document at the exact HTTPS clientId URL. It must
remain byte-for-byte identical in its client_id field. The helper's DCR
fallback includes application_type, grant_types, response_types, and
token_endpoint_auth_method; post that request only to the discovered
registration_endpoint.
ChatGPT and Claude connectors
Give the host the application's public HTTPS Streamable HTTP endpoint. Serve protected-resource metadata for that exact endpoint and point it at the operator's issuer. Configure registration in the host's connector settings. The registration helper above applies to clients that call it. The external host selects and presents its own client identity.
- ChatGPT. Select Client ID Metadata Documents (CIMD) when the issuer
supports them. Verify the HTTPS document's
client_idand exact redirect URI at the issuer. Use DCR as an explicit compatibility mode if CIMD is unavailable for the connection, and retain the registered client for the lifetime of that connection. The issuer must accept the token endpoint authentication method negotiated with the host. OpenAI client registration guidance - Claude. When adding a custom web connector, choose Claude's published identity if the issuer supports it. Choose automatic registration for a supported DCR fallback, or supply a previously registered OAuth client when the operator manages that client. Verify the selected identity and exact callback URI at the issuer. Claude custom connector setup
For either host, verify authorization-code PKCE with S256, bind the code to
the client and exact redirect URI, and bind the token to the intended resource.
Verify that the issuer returns its exact RFC 9207 iss value and that the host
refuses a mismatched issuer before exchanging the code. Test the registration
mode the connector actually uses and exercise DCR separately if offered.
MCP authorization specification
· RFC 9207
Application wiring
For the application MCP route:
- Protect
/api/mcpwith the gateway or server-only JWT adapter. During the one-release compatibility window, apply the same policy to/api/mcp/toolsand/api/mcp/call. - Validate the bearer token at the selected boundary and resolve fresh
application membership, tenant, and permissions on every request. Gateway
integrations populate
event.locals.user; the JWT adapter passes its trusted mapped principal to the route as shown below.mountMcpRouteuses that principal for discovery and calls; header presence is not authentication. - Keep
publicToolPatternsempty unless anonymous read access is deliberate. - Preserve tenant isolation and the app allow-list for every tool invocation.
- Do not expose the generated stdio server remotely; stdio obtains credentials from its environment and has no per-request OAuth principal.
The app CLI and its stdio MCP bridge use SMRT's first-party terminal device flow,
not the MCP authorization-code flow. The terminal start response emits an
issuer, and stored bearer tokens are keyed by that exact issuer and sent only to
the server saved with the login. @happyvertical/smrt-agents ships no MCP client
or OAuth credential store, so RFC 9207 and client registration are not
applicable to that package today.
In the local profile there is no authorization server. Instead, the owner
mints a scoped, expiring, revocable bearer token on the machine with
smrt app token --scopes …. The runtime stores only its HMAC. The bridge
sends it as <PREFIX>_TOKEN. mountMcpAppRoute({ runtime }) (or the
hand-wired createHostedMcpResourceAuth({ profile, runtime })) accepts it
through the same auth/bindPrincipal path, capped to the owner's live
permissions; the route resolves the profile per request and fails closed when
the runtime cannot supply the profile's adapter. Hosted profiles use the runtime's membership-backed
resolveMcpPrincipal unless the application supplies resolvePrincipal.
Write tools
Keep the remote tool catalog read-only until the application supplies and tests an authorization policy for each exposed write operation. Before a mutation, require the operation's scope and check the actor's current membership, permission, active tenant, and access to the target record using the server's trusted context. Apply the same checks when a task resumes or retries. Derive the tenant and actor from that context and look up any required approval in the owning workflow. Host confirmations and tool annotations describe intent.
For example, an applications:prepare grant can allow an authorized member to
create a pending application in their tenant. Repeating the request must
preserve the owning workflow's idempotency and audit trail. Employer submission
requires the separate human approval in the product's review flow. See the
MCP Apps security and deployment invariants.
Deployment verification
Before promoting Anytown or Ergot:
- fetch authorization-server metadata and assert the issuer and both advertised capabilities match deployment policy;
- have the authorization server fetch the HTTPS Client ID Metadata Document and reject a request whose redirect URI is not listed;
- exercise the DCR compatibility path, when enabled, and capture the received
request showing
application_type; - confirm a callback with the expected
isssucceeds, while missing and mismatchedissvalues fail before the token endpoint is called; - log in the app CLI, switch its server URL, and confirm the prior bearer token is not sent;
- call a protected MCP tool with a valid token, a token from another issuer, a token for another audience, and no token; only the first request may dispatch.
Keep issuer metadata, client documents, and gateway policy in deployment source control. Never place client secrets or bearer tokens in the repository.
For live host qualification, repeat the following on ChatGPT and Claude with a test tenant. Record the date, host and version if visible, server revision, selected registration mode, and sanitized outcomes in the MCP Apps integration epic.
- Connect, sign in, and call a permitted read tool. Record the issuer-observed
client identity, callback,
S256challenge method, and resource. Verify the issuer rejects an invalid verifier and unregistered redirect. Record any failure probes performed with a separate test client as fixture evidence. - Exercise issuer mismatch, wrong resource, missing scope, and revoked membership. Verify the failed grant or tool denial occurs before dispatch.
- If write tools are enabled, create one pending record with an allowed actor. Retry it and verify the same outcome. Attempt the same operation as a read-only actor and against another tenant's record. Verify both requests leave state unchanged and the pending record requires product approval to advance.
Server-only protected-resource adapter
@happyvertical/smrt-app-mcp/auth supplies createMcpResourceAuth. It verifies
JWT access tokens using the configured issuer's JWKS with an explicit RS256 or
ES256 allow-list, exact issuer and resource audience, required subject and expiry,
required scopes, and nbf when present. nbf is optional. Its default token type is at+jwt (RFC 9068). Select
tokenType: 'JWT' only when the configured provider uses that type for access
tokens; ensure that provider cannot issue an ID token with this resource's
audience. Opaque access tokens and introspection are not implemented by this
adapter; use an existing validating application gateway for those providers.
import { createMcpResourceAuth } from '@happyvertical/smrt-app-mcp/auth';
import { mountMcpRoute } from '@happyvertical/smrt-app-mcp/sveltekit';
const auth = createMcpResourceAuth({
profile: 'self-hosted',
resource: 'https://app.example/api/mcp',
issuer: 'https://identity.example/',
jwksUri: 'https://identity.example/keys',
algorithms: ['RS256'],
scopes: ['mcp:read'],
async resolvePrincipal({ issuer, subject }) {
// Application-owned lookup: exact provider identity, enabled account,
// current active tenant and membership. This is a required callback.
const member = await accounts.resolveActiveMembership(issuer, subject);
return member ? { id: member.userId, tenantId: member.tenantId,
roles: member.roles } : null;
},
});
const route = mountMcpRoute(mcpServer);
export async function POST(event) {
const result = await auth.authenticate(event.request);
if (!result.ok) return result.response;
return route({ ...event, locals: { ...event.locals, user: result.principal } });
}
// In a separate GET route at auth.metadataUrl:
// https://app.example/.well-known/oauth-protected-resource/api/mcp
export const GET = () => auth.metadataResponse();
The route example's accounts and mcpServer are application dependencies, not
new framework services. Mount metadata at the exact metadataUrl, including
the resource path inserted after /.well-known/oauth-protected-resource per
RFC 9728. Missing credentials return a 401 discovery challenge; invalid tokens
return 401 invalid_token; insufficient scopes return 403 insufficient_scope.
Responses never include the bearer credential, claims, or upstream errors.
Protect every protocol request with the same check, including discovery,
resource reads, task continuation and reconnects. Keep the app's operation
policy and object ownership checks: OAuth scopes alone do not establish access
to a particular record.
The resolver receives only verified identity claims. It must use application
state to select a tenant and validate membership on every call. Token tenant
claims, request tool arguments and iframe state must never choose the active
tenant automatically. Missing actor IDs or required tenant IDs fail closed.
requireTenant: false is for a deliberately single-tenant application; the
adapter never copies an allowCrossTenant override. No users-package adapter
or dependency is introduced: browser OIDC and terminal sessions remain separate
contracts, and deployments supply their existing account/membership service.
Revocation and reconnect
The adapter caches signing keys using jose's remote-JWKS cache, but never caches authentication or the mapped principal. Account disablement, application session revocation and removed membership take effect on the next request if the resolver checks that state. A freshly issued token does not bypass a resolver denial. JWT signature validation alone does not detect authorization server token revocation before expiry. Configure short-lived access tokens or an application/provider revocation check in the resolver when immediate revocation is required. A JWKS cache is not a revocation list; cached keys may remain usable during an issuer outage. A required key fetch that fails denies the request. Provider availability, timeouts and any revocation lookup inside the callback are the application's responsibility.
Supported deployment profiles
| Profile | Required configuration | Operator responsibility |
|---|---|---|
local | Resource, issuer and JWKS URLs all use literal loopback hosts (localhost, 127.0.0.1, [::1]); HTTP or HTTPS | Bind the HTTP listener to loopback, restrict local storage, configure local credentials, prevent reverse-proxy exposure |
self-hosted | HTTPS resource, issuer and JWKS; explicit asymmetric algorithms and scopes | Existing OAuth issuer/gateway, TLS, account mapping, policy and ownership enforcement |
cloud | Same HTTPS contract plus required tenant mapping by default | Verified customer membership, isolation on every operation, provider availability and revocation policy |
The profile validates configured URLs; it cannot bind a listener or prove cloud
isolation. Configure network binding independently. This adapter does not create
an authorization server, register a host client, issue or refresh tokens, or
add a browser login session. Its Node entrypoint has no Svelte or OpenAI runtime
imports. The authorization server remains responsible for authorization-code
PKCE (S256), exact resource binding at authorization and token endpoints,
client registration, exact redirect allow-lists, RFC 9207 issuer identification,
and refresh/revocation policy. Advertise only capabilities actually implemented.
Tool results and hosted UI content leave the installation for the connected client/host; OAuth does not prevent that disclosure. Keep credentials and provider claims out of those results and publish only data the actor may see.
CLI expected-issuer pinning
Set CliConfigContext.expectedCredentialIssuer from trusted deployment
configuration or validated discovery when the selected server's issuer is known.
The CLI then requires both the saved server URL and exact issuer to match before
sending credentials. Changing issuer trailing slashes, server paths or ports
does not silently reuse a previous credential. Legacy credentials without an
issuer are refused in this mode. Environment credentials additionally require
${PREFIX}_TOKEN_ISSUER to match; ${PREFIX}_SERVER_URL must still match the
target server. Omitting the expected issuer preserves the existing first-party
device-login contract; it does not discover or validate a new issuer implicitly.
Evidence and external contract
The auth.test.ts and auth-pkce.test.ts suites exercise real HTTP JWKS and a
synthetic authorization server, including S256 proof rejection, one-use codes,
resource/redirect binding, callback mix-up checks in the fixture client,
predefined/DCR registration, token failures, principal mapping and reconnect.
The CLI's mcp-oauth.test.ts independently covers CIMD document selection and
DCR. The synthetic issuer is a test fixture, not a production identity provider
or a claim that a live OpenAI host has been certified. Hosted profiles validate
HTTPS configuration locally; deployment TLS and real provider policy require an
operator's integration run.
Contract sources: RFC 9728, RFC 6750, and OpenAI authentication guidance (inspected 2026-09-30). The provider must advertise S256 and token endpoint auth methods. Current OpenAI hosts support CIMD, DCR and predefined clients; configure and test the mode actually used by the connection.