Skip to content

feat(server): runtime-neutral OAuth discovery serving for web-standard hosts#2422

Merged
felixweinberger merged 5 commits into
mainfrom
fweinberger/web-standard-oauth-metadata
Jul 6, 2026
Merged

feat(server): runtime-neutral OAuth discovery serving for web-standard hosts#2422
felixweinberger merged 5 commits into
mainfrom
fweinberger/web-standard-oauth-metadata

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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-resource ahead of the resource path). This is the value a WWW-Authenticate challenge's resource_metadata parameter points at, so clients and servers alike need it — yet it shipped only from @modelcontextprotocol/express despite 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 the Response/.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; returns undefined for everything else so it composes as a fall-through (same shape as hostHeaderValidationResponse):

export default { fetch: request => oauthMetadataResponse(request, metadata) ?? handler.fetch(request) };

The Express Router was 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)

  • Path matching happens before validation, so unmatched traffic always falls through: a misconfigured issuer surfaces on the discovery routes (or at startup), never as a whole-server outage.
  • A single trailing slash is tolerated, matching path-mounted router behavior.
  • HEAD is served wherever GET is, per RFC 9110.
  • Reflected CORS preflights carry Vary: Access-Control-Request-Headers so shared caches cannot replay one preflight's allow-list against another.
  • The insecure-issuer escape hatch is an explicit dangerouslyAllowInsecureIssuerUrl option instead of a module-scope environment variable read (which breaks on Workers); the Express adapter still maps its existing MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL variable onto it.

How Has This Been Tested?

  • The Express metadata router's existing tests pass untouched, pinning adapter compatibility.
  • 17 new unit tests for the neutral core: both documents, path-awareness, trailing slash, HEAD, 405 with Allow, preflight reflection with Vary, issuer validation (HTTPS, localhost exemption, fragment/query rejection), and the fall-through contract including the misconfigured-issuer case.
  • Driven over real sockets: a minimal web-standard host and an Express app serving identical documents, probed with curl (GET/HEAD/POST/OPTIONS, trailing slash, bad-issuer fall-through).

Breaking Changes

None. The Express router's public surface and behavior are unchanged; AuthMetadataOptions remains an interface there.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

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 are createMcpExpressApp (inherently framework glue) and the host/origin middleware wrappers (already thin adapters over neutral cores).

@felixweinberger felixweinberger requested a review from a team as a code owner July 2, 2026 17:39
@changeset-bot

changeset-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9b7d004

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

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

@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2422

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2422

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2422

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2422

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2422

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2422

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2422

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2422

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2422

commit: 9b7d004

Comment thread packages/server/src/index.ts
Comment thread packages/server/src/server/middleware/oauthMetadata.ts
Base automatically changed from fweinberger/web-standard-bearer-auth to main July 2, 2026 18:35
…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
@felixweinberger felixweinberger force-pushed the fweinberger/web-standard-oauth-metadata branch from 137b1b6 to f1a0e13 Compare July 2, 2026 18:36
felixweinberger and others added 2 commits July 2, 2026 18:41
…; 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.
@felixweinberger felixweinberger requested a review from pcarleton July 6, 2026 15:19

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants