feat(server): runtime-neutral OAuth discovery serving for web-standard hosts#2422
Conversation
🦋 Changeset detectedLatest commit: 9b7d004 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…d hosts The RFC 9728 Protected Resource Metadata and RFC 8414 Authorization Server metadata documents could only be served through the Express metadata router, so fetch-handler hosts (Cloudflare Workers, Deno, Bun, Hono) hand-rolled the well-known routes, CORS, and method handling. The core moves to @modelcontextprotocol/server as oauthMetadataResponse (a Response-or-undefined matcher in the style of hostHeaderValidationResponse), built on the exported buildOAuthProtectedResourceMetadata, with getOAuthProtectedResourceMetadataUrl now defined here. The Express router adapts the same core with unchanged behavior, proven by its untouched test suite. The neutral core improves on the Express quirks where green-field allows: matching happens before validation so unmatched traffic always falls through (a misconfigured issuer surfaces on the discovery routes, or at startup via an eager buildOAuthProtectedResourceMetadata call, never as a whole-server outage), a single trailing slash is tolerated, HEAD is served per RFC 9110, reflected CORS preflights carry Vary, and the insecure-issuer escape hatch is an explicit dangerouslyAllowInsecureIssuerUrl option instead of a module-scope environment read.
…ty Omit-extends interface; option and env var both enable the insecure-issuer escape hatch
137b1b6 to
f1a0e13
Compare
…; point the resourceMetadataUrl JSDoc at this package A resourceServerUrl with a trailing slash derived an expected well-known path that kept the slash while incoming request paths had theirs stripped, so the PRM route matched neither spelling. Both sides now share one normalization.
There was a problem hiding this comment.
The two issues I flagged earlier (stale JSDoc pointer and the trailing-slash path mismatch) are fixed and pinned by tests, and I found no new problems in the latest revision — but since this adds new public API surface (oauthMetadataResponse, buildOAuthProtectedResourceMetadata, getOAuthProtectedResourceMetadataUrl) to @modelcontextprotocol/server and touches the OAuth resource-server discovery path, it should get a maintainer's sign-off rather than a shadow approval.
Extended reasoning...
Overview
This PR extracts the Express metadata router's OAuth discovery serving into a runtime-neutral core in @modelcontextprotocol/server: a new oauthMetadata.ts module with oauthMetadataResponse, buildOAuthProtectedResourceMetadata, and getOAuthProtectedResourceMetadataUrl, exported from the package root barrel. The Express mcpAuthMetadataRouter becomes a thin adapter over the same core, mapping its MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL env var onto the new explicit dangerouslyAllowInsecureIssuerUrl option. Docs, a guide example, a changeset, and 20+ new unit tests accompany the change.
Security risks
The code serves RFC 9728 / RFC 8414 discovery documents with permissive CORS, which is intentional and matches the existing Express behavior. The issuer-URL validation (HTTPS required outside localhost, no fragment/query) is preserved verbatim, and the insecure-issuer escape hatch is now an explicit option rather than an ambient env read — arguably an improvement, though the Express adapter still honors the env var for compatibility. Path matching happens before validation so a misconfigured issuer surfaces on the discovery routes rather than as a whole-server outage. I did not find injection or bypass concerns; the reflected Access-Control-Allow-Headers on preflight carries Vary, and preflight responses are inherently non-sensitive. Still, this is auth-adjacent serving code, which per the review guidelines warrants human eyes.
Level of scrutiny
High-medium. The implementation itself is small and well tested (both my earlier findings — the stale bearerAuth.ts JSDoc and the trailing-slash resourceServerUrl path mismatch — were fixed in 4258f34 with regression tests). The remaining question is a design/API-surface one: three new exports plus a shared AuthMetadataOptions type on @modelcontextprotocol/server (minor bump), which under this repo's minimalism principle is a maintainer decision, not something to auto-approve. It follows the established pattern of hostHeaderValidationResponse/requireBearerAuth extraction from prior PRs, so it is consistent, but the burden-of-proof-on-addition call belongs to a human.
Other factors
The PR is stacked on #2420 and the changeset-bot output shows major bumps for several middleware packages coming from the base branch, so the merge ordering matters. Existing Express router tests pass untouched, pinning adapter compatibility, and the new neutral-core tests cover the fall-through contract, HEAD, 405, preflight Vary, and issuer validation. The bug-hunting system found no issues in the current revision.
Completes the runtime-neutral extraction of the Express package's Resource Server auth surface, begun in #2420 (token verification). This PR moves the other half: OAuth discovery serving. Everything here is RS-side — the documents a protected resource publishes about itself; no Authorization Server behavior is added anywhere.
Motivation and Context
Serving OAuth discovery metadata from the SDK previously required mounting the Express
mcpAuthMetadataRouter. Fetch-handler hosts (Cloudflare Workers, Deno, Bun, Hono) had no SDK support and hand-rolled the well-known routes, CORS, and method handling — even though only ~12 of the router's 153 lines were actually Express-specific.The three pieces
1.
buildOAuthProtectedResourceMetadata(options)— extracted core (was inline in the Express router)Builds the RFC 9728 Protected Resource Metadata document — the RS's own statement of "this is my resource, this is the Authorization Server that protects it, these are my scopes" — including issuer validation (HTTPS enforcement with a localhost exemption). Previously this assembly logic lived inside the Express router's request handler, so producing the document at all meant mounting Express. Now it is a pure function; the Express router delegates to it (single source of truth — the Express-side diff is net −113 lines), and calling it once at startup fails fast on a misconfigured issuer.
2.
getOAuthProtectedResourceMetadataUrl(serverUrl)— extracted core (was an Express-package export)Pure URL math: where a given resource's metadata document lives (inserts
/.well-known/oauth-protected-resourceahead of the resource path). This is the value aWWW-Authenticatechallenge'sresource_metadataparameter points at, so clients and servers alike need it — yet it shipped only from@modelcontextprotocol/expressdespite having no Express dependency. Now defined in@modelcontextprotocol/server; the Express package re-exports it unchanged.3.
oauthMetadataResponse(request, options)— the one genuinely new surface (no prior equivalent existed off Express)The web-standard HTTP adapter over the shared core: takes a WHATWG
Request, and if it targets a discovery path, returns theResponse—/.well-known/oauth-protected-resource[/<path>](RFC 9728), and/.well-known/oauth-authorization-server(RFC 8414) as a verbatim echo of the Authorization Server's document supplied via options, kept for 2025-03-26-era clients that look up AS metadata on the MCP server's own host. Handles GET/HEAD/OPTIONS with CORS; returnsundefinedfor everything else so it composes as a fall-through (same shape ashostHeaderValidationResponse):The Express
Routerwas the HTTP adapter for this capability; this is the second adapter over the now-shared core — which is what makes the seam real rather than hypothetical.Green-field improvements over Express quirks (neutral core only; the Express adapter's behavior is unchanged)
Vary: Access-Control-Request-Headersso shared caches cannot replay one preflight's allow-list against another.dangerouslyAllowInsecureIssuerUrloption instead of a module-scope environment variable read (which breaks on Workers); the Express adapter still maps its existingMCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URLvariable onto it.How Has This Been Tested?
Allow, preflight reflection withVary, issuer validation (HTTPS, localhost exemption, fragment/query rejection), and the fall-through contract including the misconfigured-issuer case.Breaking Changes
None. The Express router's public surface and behavior are unchanged;
AuthMetadataOptionsremains an interface there.Types of changes
Checklist
Additional context
Originally stacked on #2420 (now merged; branch updated to main). Changeset: minor for
@modelcontextprotocol/server, patch for@modelcontextprotocol/express. With both PRs, the only Express-only auth exports left arecreateMcpExpressApp(inherently framework glue) and the host/origin middleware wrappers (already thin adapters over neutral cores).