Skip to content

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.

  1. Where the RFC 9728 protected-resource metadata is served. When token_verifier + AuthSettings are set, the SDK serves the metadata and the 401 WWW-Authenticate challenge 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 the 401 challenge correctly advertises the host-root URL /.well-known/oauth-protected-resource/mcp (RFC 9728 places .well-known at the origin root with the resource path appended). The advertised URL would therefore 404.

  2. 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=true but forgets STUDIO_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 emailsub 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.