ADR-011: MCP OAuth Resource Server — RFC 9728 Host-Root Bridge + Fail-Closed Dev Auth¶
Status: Accepted
Date: 2026-06-03
Deciders: Heiko Sundermann, AI dev-lead
Context: Studio MCP Trace-Inspection Server (docs/superpowers/specs/2026-06-02-studio-mcp-trace-server-design.md), PR-3
Context and Problem Statement¶
PR-3 secures the /mcp server as an OAuth 2.1 Resource Server (RS-only) using
the mcp==1.27.2 SDK (token_verifier + AuthSettings). Two non-obvious
decisions emerged that a future maintainer will need the rationale for.
-
Where the RFC 9728 protected-resource metadata is served. When
token_verifier+AuthSettingsare set, the SDK serves the metadata and the401WWW-Authenticatechallenge automatically — but it registers the metadata route inside its streamable-HTTP sub-app. Once that sub-app is mounted at/mcp, the SDK's metadata route lands at/mcp/.well-known/oauth-protected-resource/mcp, while the401challenge correctly advertises the host-root URL/.well-known/oauth-protected-resource/mcp(RFC 9728 places.well-knownat the origin root with the resource path appended). The advertised URL would therefore 404. -
What happens when the server is enabled without an IdP. The dev token verifier accepts ANY non-empty bearer token (it exists so the tool layer is usable in local dev before OIDC is wired). If an operator sets
STUDIO_MCP_ENABLED=truebut forgetsSTUDIO_MCP_OIDC_ISSUER, the most sensitive surface (all trace data) would be effectively unauthenticated.
Decision¶
1. Serve the RFC 9728 document at the host-root advertised path ourselves
(app/mcp/auth/metadata.py + main._mount_mcp). We keep the SDK's auto-served
401 challenge (it advertises the correct root URL) and add a FastAPI route on the
main app at /.well-known/oauth-protected-resource<resource-path> returning the
§7.3 document. The SDK's own copy under /mcp/... is left in place (harmless);
ours is the one the challenge points at.
2. Fail closed on dev auth. When STUDIO_MCP_ENABLED is true and no
STUDIO_MCP_OIDC_ISSUER is configured, the server refuses to build (raises
at mount) unless STUDIO_MCP_ALLOW_DEV_AUTH=true is explicitly set. When dev
auth is explicitly allowed, it logs a loud warning. Security is the default; the
accept-all path requires an intentional opt-in. (Spec R-6: "clear startup
warning if neither configured" — we go further and fail closed.)
3. Allow-list trusts email only when email_verified is true. The
domain/email allow-list (PR-4) uses the token's email claim as identity, but
only when the token also carries email_verified: true; otherwise the caller has
no usable identity and is denied (deny:no_verified_email). Without this, anyone
who can register an unverified @allowed-domain address at the IdP would pass
the domain check. Operational consequence: the IdP MUST be configured to emit
email_verified (and email) — an IdP that omits it will deny all users
regardless of allow-list config (mirrors the email→sub caveat, spec R-6).
The dev verifier stamps email_verified=true (trusted local stand-in). Note:
spec §7.6's consumed-claims list predates this and should be read as including
email_verified.
Audit semantics (v1). The audit line (spec §7.5) is emitted at dispatch,
recording the authorization decision (allow / deny:<reason>), not the
downstream tool outcome — so denied and erroring calls are still logged, exactly
once per call. status_code (spec §7.2) is therefore not recorded in v1; it is
deferred to the persisted mcp_audit table (§10.3). Deny reasons emitted by the
allow-list layer are deny:not_allow_listed and deny:no_verified_email; the
spec's deny:bad_token is a verifier-layer 401 (the SDK's challenge), not an
audit decision value.
Supporting choices (not separately ADR-worthy, recorded for context):
- Tools register with structured_output=True and dict[str, Any] returns so
the route dict reaches the client as MCP structuredContent verbatim (§7.1);
a bare dict annotation yields null structured content over the wire.
- The token verifier is split into oidc_verifier.py (validation) +
jwks_cache.py (discovery/cache/resilience) rather than the single
token_verifier.py named in spec §3.1 — JWKS caching is a separable concern
with its own R-7 resilience contract and tests. The §3.1 module table should be
read with this split in mind.
- server.py builds the FastMCP instance via a settings-driven lazy factory
(get_mcp() caches; reset_mcp() for tests) so the verifier seam flips from
config — a standard pattern, noted only because it replaced module-level
construction.
- studio_mcp_effective_audience defaults the validated aud to
STUDIO_MCP_RESOURCE_URL when STUDIO_MCP_OIDC_AUDIENCE is unset, per
RFC 8707, so the validated audience and the advertised resource agree by
default instead of silently diverging.
Consequences¶
Positive: - The advertised metadata URL resolves; agents complete the OAuth discovery loop. - A misconfigured deployment crashes loudly instead of silently exposing traces. - Audience/resource coherence by default removes a 401-everything footgun.
Negative / risks:
- Two metadata copies exist (SDK's under /mcp, ours at root). A future SDK
version that serves the root path itself would make ours redundant — revisit
on SDK upgrade.
- The fail-closed gate will surprise an operator who enables the server without
an issuer; mitigated by the actionable error message and the N1 setup guide.
Alternatives Considered¶
- Mount the SDK app at root instead of
/mcp: rejected — the transport endpoint must live at/mcp, and mounting at root collides with the API. - Rely solely on the SDK's metadata route: rejected — it is unreachable at
the advertised URL once mounted under
/mcp. - Only warn (don't fail) on missing issuer: rejected — a warning in logs is too easy to miss for a surface this sensitive; security must be the default.