MCP facade helper
runtime/mcpfacade (Go, RegisterMCPFacade), ts/src/mcpfacade.ts (TypeScript, registerMcpFacade), and
py/src/apptheory/mcpfacade.py (Python, register_mcp_facade) are the runtime composition half of
m17.mcp-route-algebra/v2. One call installs the complete per-pattern MCP OAuth facade for the namespace,
partner_namespace, agent, and partner_agent endpoint kinds without copying route strings into the application.
All three registrars mount the same routes from the shared route algebra, resolve the same issuer/URL modes and
capabilities, pair the same transport postures, and emit byte-identical metadata documents from the same config.
For every mcproutes.SupportedEndpointTemplates() entry, the helper installs:
POST,GET, andDELETEat the MCP pattern, all served by the application’sMCPHandler;- public
GETRFC 9728 protected-resource metadata at the derived path; - public
GETRFC 8414 authorization-server metadata at both the canonical prefix path and suffix-compatible path; - the derived authorize
GETand tokenPOSTroutes only when the application supplies both handler factories, or — when the front-channel field set is declared — the framework-owned front-channel handlers (see Framework-owned front channel).
The helper wraps MCP and metadata handlers with the contract parsers. It derives endpoint kind from the normalized
request path, reconstructs discovery identity from client_namespace, partner_id, and agent_id path parameters,
and fails closed when the identity or route shape is invalid. The returned RouteInventory references
mcproutes.ContractVersion and exposes every derived pattern for inspection.
When RootAuthorizationServer is non-nil, the same call also installs exactly one static GET at
mcproutes.AuthorizationServerPathForResourcePath("/"). Root discovery is deliberately not a fifth endpoint kind:
root canonical and suffix discovery collapse to the same path, and the document describes the upstream authorization
server rather than a routed MCP identity.
P0 is a minimal tier, not a protected one
TierP0 is the frozen legacy pipeline: it does not evaluate route-level transport postures, so mounting the
facade under P0 gets no posture enforcement from the tier. An application that explicitly selects P0 must supply
RequireBearerToken or own its transport authentication itself — the facade has no iam/jwt route guard to lean on
there. P0 is the documented minimal tier in all three runtimes (see HTTP Runtime tiers) and exists
for tightly scoped functions that do their own auth, observability, and shedding. It is never a shortcut past the
facade’s route guard: the default tier (P2) and SecureApp are the paths that evaluate the transport posture and its
defense-in-depth pairing.
Registration
app := apptheory.New()
inventory, err := mcpfacade.RegisterMCPFacade(app, mcpfacade.FacadeConfig{
IssuerURL: "https://accounts.example.com",
JWKSURI: "https://accounts.example.com/.well-known/jwks.json",
URLMode: mcpfacade.URLModePublicBaseURL,
PublicBaseURL: "https://mcp.example.com",
Scopes: map[mcproutes.EndpointKind][]string{
mcproutes.EndpointKindNamespace: namespaceScopes,
mcproutes.EndpointKindPartnerNamespace: partnerNamespaceScopes,
mcproutes.EndpointKindAgent: agentScopes,
mcproutes.EndpointKindPartnerAgent: partnerAgentScopes,
},
MCPHandler: mcpHandler,
AuthorizeHandler: func(kind mcproutes.EndpointKind) apptheory.Handler {
return appOwnedAuthorizeHandler(kind)
},
TokenHandler: func(kind mcproutes.EndpointKind) apptheory.Handler {
return appOwnedTokenHandler(kind)
},
RootAuthorizationServer: &mcpfacade.RootDiscoveryConfig{
IssuerURL: "https://accounts.example.com",
AuthorizationEndpointURL: "https://accounts.example.com/authorize",
TokenEndpointURL: "https://accounts.example.com/token",
RegistrationEndpointURL: "https://accounts.example.com/register",
JWKSURI: "https://accounts.example.com/.well-known/jwks.json",
Scopes: rootScopes,
},
})
if err != nil {
return err
}
_ = inventory
Scopes is required for all four kinds; AppTheory never invents application scope policy. AuthorizeHandler and
TokenHandler are an all-or-none pair. When both are absent and no front-channel field set is declared, the helper
does not register authorize or token paths. When either factory is missing or returns nil, registration fails before
any route is added. This avoids exposing a silent public authorization path.
RootAuthorizationServer is opt-in. A non-nil value must provide every upstream URL and at least one root scope;
otherwise configuration fails before registration. A nil value leaves root discovery unregistered. The root
document uses DefaultCapabilities and its configured static endpoints; it never derives endpoints from request
headers or from a routed endpoint kind.
AppTheory derives and validates the full internal route inventory before touching the target app. App does not
expose route introspection, so a collision with a route that was registered earlier is returned by the strict router
registration guard when encountered instead of escaping as a panic. Routes installed earlier in the same facade call
may remain registered after that error. Applications should register the facade before overlapping application routes
and must discard an app whose registration returned an error.
Absolute URL modes
Metadata documents need absolute resource, authorization, and token URLs. URLMode is mandatory so deployment intent
cannot be guessed.
| Mode | Source | Appropriate deployments |
|---|---|---|
URLModePublicBaseURL |
Operator-supplied PublicBaseURL, fixed at registration |
CloudFront, CDN, front-door, mapped-stage, or split-origin deployments where the Lambda-facing host is not the public authority |
URLModeRequestHost |
Scheme and host reconstructed for every request, then enforced against required AllowedHostnames |
Direct custom domains and tests whose edge-header trust boundary is explicitly controlled |
Public-base mode accepts HTTPS and loopback HTTP URLs but rejects every non-empty path component: the well-known routes are root-relative, so a pathful base would advertise URLs this facade does not serve. Install-time public-base mode is the recommended mode for CloudFront, CDN, and other front-door deployments, including the golden consumer.
Request-host mode rejects a simultaneous PublicBaseURL and fails configuration when AllowedHostnames is empty.
Configuration entries normalize case, trailing dots, and default ports scheme-agnostically. Request authorities
normalize case and trailing dots too, but strip only the request scheme’s own default port. Every reconstructed
authority must exact-match a normalized allowlist entry. Missing, unsafe, or non-allowlisted request hosts return HTTP
OriginURLdeliberately givesx-apptheory-original-host,x-facetheory-original-host,x-forwarded-host, andForwarded: host=precedence overHost. Those forwarding headers are attacker-controlled unless the edge removes viewer-supplied copies and writes trusted values. The edge must strip spoofed forwarding/original-host headers. The allowlist is the enforcement layer if that boundary is misconfigured: a spoofed higher-precedence header cannot fall back to an otherwise allowlistedHost.
Metadata defaults and overrides
DefaultCapabilities returns the fixed golden-path advertisement inherited from theory-mcp-server:
| RFC 8414 field | Default |
|---|---|
response_types_supported |
["code"] |
grant_types_supported |
["authorization_code", "refresh_token"] |
token_endpoint_auth_methods_supported |
["none"] |
code_challenge_methods_supported |
["S256"] |
Non-nil Capabilities fields replace their corresponding default. Empty lists and blank values fail registration.
The registration endpoint defaults to IssuerURL + /register and can be replaced with
RegistrationEndpointURL.
Protected-resource documents advertise the absolute concrete MCP resource, authorization_servers=[IssuerURL], the
kind’s configured scopes, and JWKSURI. Routed authorization-server documents advertise the configured issuer,
registration endpoint and JWKS URI plus public-origin authorize/token URLs derived from the route algebra.
Every protected-resource, routed canonical/suffix, and opt-in root metadata response sets Cache-Control: no-store.
It also sets Vary over every header consulted by request-origin reconstruction: Host, X-Forwarded-Host,
X-AppTheory-Original-Host, X-FaceTheory-Original-Host, Forwarded, CloudFront-Forwarded-Proto, and
X-Forwarded-Proto. The shared routing fixture pins routed document bytes per endpoint kind and URL mode, including
golden rows that exercise HTML-escaping of &, <, and >.
Authorization plug-point contract
For the application-owned handler factories, AppTheory derives and mounts paths; it does not implement authorize
or token behavior. (The framework-owned front channel, below, is the alternative when the application wants the
framework to own those mechanics.) HandlerFactory receives the endpoint kind at installation so the application can
attach its tenant-admission bridge, proxy, token validator, refresh coordination, telemetry, and other policy. With an
application-owned surface the helper never issues grants, proxies an authorization server, or chooses scopes.
The helper mounts the MCP transport routes with an authenticated posture by default: a request without a verified
principal is rejected with HTTP 401 and the MCP handler never runs. FacadeConfig.TransportPosture selects that posture
explicitly (TransportPostureIAM, TransportPostureJWT, TransportPostureRuntimeBearer, or
TransportPostureNonePublic), and registration fails closed when the posture and its required runtime assertion
disagree. Applications that authenticate MCP themselves either configure FacadeConfig.RequireBearerToken, which
performs RFC 9728 bearer validation with the correct WWW-Authenticate challenge and is valid only with
TransportPostureRuntimeBearer, or select TransportPostureNonePublic and keep their own middleware around the
supplied handler.
Framework-owned front channel
The registrars can own the client-facing OAuth mechanics instead of only mounting app-owned routes. Declaring the
front-channel field set — Provider, Stores, UpstreamClientID, UpstreamIssuer, and optionally
LifecycleScopes and ProviderHints — makes the framework mount and drive the front channel. The set is all-or-none
and anchored by Provider: setting any other front-channel field without a Provider is a registration error, and an
incomplete set fails closed. Provider is mutually exclusive with the application-owned AuthorizeHandler/TokenHandler
pair and CallbackHandler, because it claims the same routes. The field-by-field table and the fail-closed rules are in
docs/features/mcp-auth-facade-spi.md (see its “Front-channel configuration” section).
When the set is declared, each registrar mounts:
- the per-endpoint-kind
GETauthorize terminator at the derived authorize pattern; - the per-endpoint-kind
POSTtoken endpoint at the derived token pattern; - the one stage-global callback at the configured
UpstreamCallbackPath(default/_oauth/mcp/callback).
The client journey steps that now work:
- The client is sent to
GET …/authorizewith itsclient_id, its exact registeredredirect_uri,response_type=code, an S256code_challenge,state, andresource. The facade validates these and redirects the browser upstream with its own client, callback, PKCE challenge, fresh state, and clamped scope set. - The upstream returns the browser to the stage-global callback; the facade pins the upstream RFC 9207
iss, consumes the single-use transaction, exchanges the upstream code server-side, validates the upstream token, and redirects the client back to its registered redirect URI with a facade code, the client’s ownstate, andiss. - The client redeems the code with
POST …/token(grant_type=authorization_code) and receives the upstream access token byte-for-byte unmodified plus a framework-minted opaque refresh handle.
The application must pre-register clients. Dynamic client registration is not mounted, so no client is created by a
request: the application seeds each client into the ClientStore, keyed by the store hash helper (oauthstore.HashKey
in Go, hashKey in TypeScript, hash_key in Python) so the facade derives the same key when it looks the client up. A
client record carries its registered redirect URIs and token_endpoint_auth_method=none (public clients only).
Not yet available (the follow-on milestone owns them): DCR — the /register route is not mounted, so clients must
be pre-registered; the refresh_token grant — the code-exchange response carries the framework-minted opaque
refresh handle, but the token endpoint answers 400 unsupported_grant_type for it until the brokered refresh lands, so
a returned handle cannot be redeemed yet; and the CDK facade wiring — the construct’s attach prop does not yet
wire the provider and stores. The stage-global callback path override is wired separately, through the construct’s
upstreamCallbackPath prop. Transport-route bearer enforcement is unchanged.
theory-mcp-server migration fitness
| Current golden-path behavior | Helper replacement |
|---|---|
NewApp loops over four endpoint templates and mounts MCP POST/GET/DELETE |
RegisterMCPFacade consumes SupportedEndpointTemplates and mounts MCPHandler for all three methods |
NewApp mounts one path-scoped protected-resource handler per kind |
The helper derives the request MCP identity with ResourcePathFromProtectedResourcePath plus ParseMCPPath and serves RFC 9728 metadata |
registerOAuthDiscoveryRoutes mounts each routed handler at canonical and suffix forms |
One generated handler per kind is mounted at both SupportedOAuthDiscoveryTemplates paths |
registerOAuthDiscoveryRoutes mounts unscoped root discovery |
Optional RootAuthorizationServer mounts one static document at the algebra-derived root path |
Routed discovery rebuilds EndpointPath from path params |
The helper rebuilds, validates, re-parses, and exact-matches the concrete discovery path before producing metadata |
absoluteResourceURL(cfg.PublicBaseURL, path) builds document URLs |
URLModePublicBaseURL is the direct replacement; URLModeRequestHost is the deliberate direct-host/test alternative |
supportedScopesForEndpointKind supplies app policy |
Required FacadeConfig.Scopes owns all four per-kind sets |
| Fixed RFC 8414 capability slices | DefaultCapabilities, with validated per-field overrides |
oauth_facade_routes.go creates kind-specific authorize/token proxy handlers |
AuthorizeHandler and TokenHandler factories attach application-owned handlers to algebra-derived paths |
TypeScript and Python registrars
registerMcpFacade(app, config) and register_mcp_facade(app, config) take the same configuration shape as the Go
FacadeConfig (issuer, JWKS URI, optional registration-endpoint override, issuerMode, URL mode + public base URL or
allowed hostnames, per-kind scopes, capability overrides, the MCP handler, the all-or-none authorize/token handler
factories, the optional register factory, the optional callback handler, the optional root discovery config, the
transport posture, and the runtime bearer assertion). The derived route tables come from the shared route algebra
(mcproutes), so there is no per-language route list to drift.
They mount the MCP transport routes; the RFC 9728 protected-resource metadata route; both canonical and
suffix-compatible RFC 8414 authorization-server metadata routes; the derived authorize/token route shapes (when both
handler factories are supplied, or the framework-owned front-channel handlers when the front-channel field set is
declared); the derived registration route shape (only when a register factory is supplied); the opt-in static root
discovery route; and the single stage-global callback route. Nothing beyond what Go mounts becomes reachable.
Registration fails closed before any route is added: a malformed config or a posture/handler pairing violation raises,
and the none-public posture registers no OAuth surface and rejects any OAuth-surface configuration.
Authorize, token, register, and callback behavior stays entirely app-owned under the handler factories: the registrars
own the routes; the application owns the bodies. (Declaring the front-channel field set is the opt-in alternative where
the framework owns the authorize/callback/token bodies.) The metadata documents (protected resource, routed discovery,
root discovery) carry the same field order and the same Cache-Control: no-store and Vary headers as Go. The shared
contract fixtures pin the routed document bytes per endpoint kind and URL mode for all three runtimes, so those
metadata documents are byte-identical across Go, TypeScript, and Python, including HTML-escaping of &, <, and >
matching Go’s encoding/json; golden rows that exercise those characters back the claim. facade-route-model.v1.json,
facade-transport-posture.v1.json, and the route-inventory fixture are consumed by the Go, TypeScript, and Python
suites alike. The framework-owned front channel (authorize/callback/token mechanics, code-store single-use and
replay→revoke, and wiring the provider and store SPIs into the facade config) is now implemented in all three runtimes
and mounts when the front-channel field set is declared — see Framework-owned front channel.
Still scheduled for a follow-up milestone: dynamic client registration, the brokered refresh grant, and the CDK facade
wiring.
Non-goals
- no authorize/token behavior, grant issuance, upstream proxying, admission, or scope-selection logic except the framework-owned front channel described above: with the front-channel field set declared, the framework owns the authorize/callback/token mechanics; otherwise the registrars mount the routes and applications own the bodies;
- no DCR handler (
/registeris app-owned until the framework DCR handler lands) and no brokeredrefresh_tokengrant — the code-exchange response carries the opaque refresh handle, but the grant answersunsupported_grant_typeuntil the follow-on milestone; - no fifth/root endpoint kind or routed root authorization facade;
- no CDK wiring or jsii/CDK-Go regeneration in this change;
- no alternative route registration or private route strings outside
runtime/mcproutes.