Skip to content

Commit 543db9e

Browse files
feat(server): runtime-neutral OAuth discovery serving for web-standard 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.
1 parent 7635115 commit 543db9e

9 files changed

Lines changed: 450 additions & 73 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@modelcontextprotocol/server': minor
3+
'@modelcontextprotocol/express': patch
4+
---
5+
6+
Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`:
7+
`oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and
8+
RFC 8414 Authorization Server metadata documents from web-standard
9+
`fetch(request)` hosts, built on the exported
10+
`buildOAuthProtectedResourceMetadata`, with
11+
`getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata
12+
router adapts the same core and is unchanged in behavior; the insecure-issuer
13+
escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the
14+
neutral core instead of a module-scope environment read. The web-standard
15+
matcher validates lazily so unmatched traffic always falls through, tolerates
16+
a trailing slash, supports HEAD, and marks reflected CORS preflights with
17+
`Vary`.

docs/migration/upgrade-to-v2.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,9 @@ A few transports need a decision the codemod can't make:
397397
`mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`)
398398
→ `@modelcontextprotocol/express`; the runtime-neutral core (`requireBearerAuth`
399399
for web-standard `fetch` hosts, `verifyBearerToken`, `bearerAuthChallengeResponse`,
400-
`OAuthTokenVerifier`) is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`,
400+
`OAuthTokenVerifier`, and the discovery serving `oauthMetadataResponse` /
401+
`buildOAuthProtectedResourceMetadata` / `getOAuthProtectedResourceMetadataUrl`)
402+
is also exported from `@modelcontextprotocol/server`. Authorization Server helpers (`mcpAuthRouter`,
401403
`OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`,
402404
`authenticateClient`, `metadataHandler`, `createOAuthMetadata`,
403405
`authorizationHandler` / `tokenHandler` / `revocationHandler` /

docs/serving/authorization.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }
8989

9090
The router mounts two well-known routes: `/.well-known/oauth-protected-resource/mcp` — the path-aware RFC 9728 location, the same string `getOAuthProtectedResourceMetadataUrl(mcpServerUrl)` put into the challenge — and `/.well-known/oauth-authorization-server`, a mirror of `oauthMetadata` for clients that probe your origin directly. An unauthenticated client follows `401``resource_metadata``authorization_servers` to find your AS, obtains a token, and retries.
9191

92+
On a web-standard host, `oauthMetadataResponse` from `@modelcontextprotocol/server` serves the same two documents from a `fetch(request)` handler — it returns the matched document `Response` (with permissive CORS and `405` handling) or `undefined` to fall through to your own routing:
93+
94+
```ts source="../../examples/guides/serving/authorization.examples.ts#oauthMetadataResponse_webStandard"
95+
import { oauthMetadataResponse } from '@modelcontextprotocol/server';
96+
97+
async function webStandardFetch(request: Request): Promise<Response> {
98+
return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request);
99+
}
100+
```
101+
92102
## Read the caller in your handlers
93103

94104
`requireBearerAuth` attaches the verified `AuthInfo` to `req.auth`, `toNodeHandler` forwards it, and tool handlers inside `buildServer` read it as `ctx.http.authInfo` — the exact object your verifier returned.

examples/guides/serving/authorization.examples.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,13 @@ function buildServer(): McpServer {
8383

8484
return server;
8585
}
86+
87+
//#region oauthMetadataResponse_webStandard
88+
import { oauthMetadataResponse } from '@modelcontextprotocol/server';
89+
90+
async function webStandardFetch(request: Request): Promise<Response> {
91+
return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request);
92+
}
93+
//#endregion oauthMetadataResponse_webStandard
94+
95+
declare function serveMcp(request: Request): Promise<Response>;
Lines changed: 26 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import type { OAuthMetadata, OAuthProtectedResourceMetadata } from '@modelcontextprotocol/server';
2-
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
1+
import type {
2+
AuthMetadataOptions as NeutralAuthMetadataOptions,
3+
OAuthMetadata,
4+
OAuthProtectedResourceMetadata
5+
} from '@modelcontextprotocol/server';
6+
import { buildOAuthProtectedResourceMetadata, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
37
import cors from 'cors';
48
import type { RequestHandler, Router } from 'express';
59
import express from 'express';
@@ -12,19 +16,6 @@ if (allowInsecureIssuerUrl) {
1216
console.warn('MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL is enabled - HTTP issuer URLs are allowed. Do not use in production.');
1317
}
1418

15-
function checkIssuerUrl(issuer: URL): void {
16-
// RFC 8414 technically does not permit a localhost HTTPS exemption, but it is necessary for local testing.
17-
if (issuer.protocol !== 'https:' && issuer.hostname !== 'localhost' && issuer.hostname !== '127.0.0.1' && !allowInsecureIssuerUrl) {
18-
throw new Error('Issuer URL must be HTTPS');
19-
}
20-
if (issuer.hash) {
21-
throw new Error(`Issuer URL must not have a fragment: ${issuer}`);
22-
}
23-
if (issuer.search) {
24-
throw new Error(`Issuer URL must not have a query string: ${issuer}`);
25-
}
26-
}
27-
2819
/**
2920
* Express middleware that rejects HTTP methods not in the supplied allow-list
3021
* with a 405 Method Not Allowed and an OAuth-style error body. Used by
@@ -60,39 +51,14 @@ export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResource
6051
}
6152

6253
/**
63-
* Options for {@link mcpAuthMetadataRouter}.
54+
* Options for {@link mcpAuthMetadataRouter}. Same shape as the runtime-neutral
55+
* `AuthMetadataOptions` in `@modelcontextprotocol/server`, except the
56+
* insecure-issuer escape hatch is controlled here by the
57+
* `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL` environment variable instead of
58+
* an option.
6459
*/
65-
export interface AuthMetadataOptions {
66-
/**
67-
* Authorization Server metadata (RFC 8414) for the AS this MCP server
68-
* relies on. Served at `/.well-known/oauth-authorization-server` so
69-
* legacy clients that probe the resource origin still discover the AS.
70-
*/
71-
oauthMetadata: OAuthMetadata;
72-
73-
/**
74-
* The public URL of this MCP server, used as the `resource` value in the
75-
* Protected Resource Metadata document. Any path component is reflected
76-
* in the well-known route per RFC 9728.
77-
*/
78-
resourceServerUrl: URL;
79-
80-
/**
81-
* Optional documentation URL advertised as `resource_documentation`.
82-
*/
83-
serviceDocumentationUrl?: URL;
84-
85-
/**
86-
* Optional list of scopes this MCP server understands, advertised as
87-
* `scopes_supported`.
88-
*/
89-
scopesSupported?: string[];
90-
91-
/**
92-
* Optional human-readable name advertised as `resource_name`.
93-
*/
94-
resourceName?: string;
95-
}
60+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
61+
export interface AuthMetadataOptions extends Omit<NeutralAuthMetadataOptions, 'dangerouslyAllowInsecureIssuerUrl'> {}
9662

9763
/**
9864
* Builds an Express router that serves the two OAuth discovery documents an
@@ -101,7 +67,7 @@ export interface AuthMetadataOptions {
10167
* - `/.well-known/oauth-protected-resource[/<path>]` — RFC 9728 Protected
10268
* Resource Metadata, derived from the supplied options.
10369
* - `/.well-known/oauth-authorization-server` — RFC 8414 Authorization
104-
* Server Metadata, passed through verbatim from {@link AuthMetadataOptions.oauthMetadata}.
70+
* Server Metadata, passed through verbatim from the supplied `oauthMetadata`.
10571
*
10672
* Mount this router at the application root:
10773
*
@@ -114,18 +80,19 @@ export interface AuthMetadataOptions {
11480
* so unauthenticated clients can discover the AS from the 401 challenge.
11581
*/
11682
export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router {
117-
checkIssuerUrl(new URL(options.oauthMetadata.issuer));
83+
// Explicit fields, not a spread: a wider object cannot smuggle in (or be
84+
// clobbered on) the insecure-issuer flag — here it comes only from the env.
85+
const protectedResourceMetadata = buildOAuthProtectedResourceMetadata({
86+
oauthMetadata: options.oauthMetadata,
87+
resourceServerUrl: options.resourceServerUrl,
88+
serviceDocumentationUrl: options.serviceDocumentationUrl,
89+
scopesSupported: options.scopesSupported,
90+
resourceName: options.resourceName,
91+
dangerouslyAllowInsecureIssuerUrl: allowInsecureIssuerUrl
92+
});
11893

11994
const router = express.Router();
12095

121-
const protectedResourceMetadata: OAuthProtectedResourceMetadata = {
122-
resource: options.resourceServerUrl.href,
123-
authorization_servers: [options.oauthMetadata.issuer],
124-
scopes_supported: options.scopesSupported,
125-
resource_name: options.resourceName,
126-
resource_documentation: options.serviceDocumentationUrl?.href
127-
};
128-
12996
// Serve PRM at the path-aware URL per RFC 9728 §3.1.
13097
const rsPath = new URL(options.resourceServerUrl.href).pathname;
13198
router.use(`/.well-known/oauth-protected-resource${rsPath === '/' ? '' : rsPath}`, metadataHandler(protectedResourceMetadata));
@@ -136,18 +103,5 @@ export function mcpAuthMetadataRouter(options: AuthMetadataOptions): Router {
136103
return router;
137104
}
138105

139-
/**
140-
* Builds the RFC 9728 Protected Resource Metadata URL for a given MCP server
141-
* URL by inserting `/.well-known/oauth-protected-resource` ahead of the path.
142-
*
143-
* @example
144-
* ```ts
145-
* getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp'))
146-
* // → 'https://api.example.com/.well-known/oauth-protected-resource/mcp'
147-
* ```
148-
*/
149-
export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string {
150-
const u = new URL(serverUrl.href);
151-
const rsPath = u.pathname && u.pathname !== '/' ? u.pathname : '';
152-
return new URL(`/.well-known/oauth-protected-resource${rsPath}`, u).href;
153-
}
106+
// Re-exported from the runtime-neutral home in @modelcontextprotocol/server.
107+
export { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/server';

packages/server/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ export type { BearerAuthOptions, OAuthTokenVerifier } from './server/middleware/
3939
export { bearerAuthChallengeResponse, requireBearerAuth, verifyBearerToken } from './server/middleware/bearerAuth';
4040
export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation';
4141
export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation';
42+
// OAuth discovery documents (RFC 9728 / RFC 8414) for web-standard hosts; the
43+
// Express metadata router in @modelcontextprotocol/express adapts the same core.
44+
export type { AuthMetadataOptions } from './server/middleware/oauthMetadata';
45+
export {
46+
buildOAuthProtectedResourceMetadata,
47+
getOAuthProtectedResourceMetadataUrl,
48+
oauthMetadataResponse
49+
} from './server/middleware/oauthMetadata';
4250
export type { OriginValidationResult } from './server/middleware/originValidation';
4351
export { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from './server/middleware/originValidation';
4452
export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerRequestResponseMode } from './server/perRequestTransport';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Type-checked examples for `oauthMetadata.ts`.
3+
*
4+
* These examples are synced into JSDoc comments via the sync-snippets script.
5+
* Each function's region markers define the code snippet that appears in the docs.
6+
*
7+
* @module
8+
*/
9+
10+
import type { AuthMetadataOptions } from './oauthMetadata';
11+
import { oauthMetadataResponse } from './oauthMetadata';
12+
13+
/**
14+
* Example: serving the discovery documents from a fetch handler.
15+
*/
16+
function oauthMetadataResponse_fetchHandler(options: AuthMetadataOptions, serveMcp: (request: Request) => Promise<Response>) {
17+
//#region oauthMetadataResponse_fetchHandler
18+
async function fetchHandler(request: Request): Promise<Response> {
19+
return oauthMetadataResponse(request, options) ?? serveMcp(request);
20+
}
21+
//#endregion oauthMetadataResponse_fetchHandler
22+
return fetchHandler;
23+
}

0 commit comments

Comments
 (0)