Skip to content

Commit 6813ca8

Browse files
claudeSamMorrowDrums
authored andcommitted
refactor(server): derive scope-challenge resource_metadata from AuthInfo
Rework the scope-challenge configuration surface so the RFC 9728 metadata URL is configured exactly once and every WWW-Authenticate header a server emits is built by one formatter: - Scope-challenge 403s now build their WWW-Authenticate header via the bearer-auth formatter (buildWwwAuthenticateHeader, now exported from bearerAuth.ts), giving identical parameter order and quoting to the bearer-auth 401/403 answers. The formatter now quotes the scope and resource_metadata parameter values too. The JSON-RPC error body is unchanged. - AuthInfo gains an optional resourceMetadataUrl field; requireBearerAuth / verifyBearerToken stamp their configured resourceMetadataUrl onto the AuthInfo they return, so the URL flows inward with the verified token. The scope preflight reads it from there, falls back to the well-known location for the token's RFC 8707 resource identifier, and omits the parameter otherwise (matching the bearer-auth optional precedent). - Remove ScopeChallengeConfig and the scopeChallenge option from createMcpHandler and the Streamable HTTP transports: the preflight is active whenever a registered primitive carries a scopeChallenge callback, fixing the silent no-op when the handler-level config was omitted. - Update the authorization guide, examples, conformance server, tests, and the changeset for the single-config shape; add coverage for the stamped-URL flow, the RFC 8707 fallback, and parameter omission.
1 parent c51e262 commit 6813ca8

13 files changed

Lines changed: 253 additions & 111 deletions

File tree

.changeset/scope-challenge-server.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,13 @@ exact scope set for an `insufficient_scope` response. `requireScopes` provides a
1010
small helper for static all-of checks.
1111

1212
`createMcpHandler` and Streamable HTTP transports return HTTP 403 with an
13-
`insufficient_scope` challenge before handler execution or SSE setup.
13+
`insufficient_scope` challenge before handler execution or SSE setup. The
14+
preflight is active whenever a registered primitive carries a `scopeChallenge`
15+
callback — there is no handler- or transport-level configuration. The
16+
challenge's `WWW-Authenticate` header is built by the same formatter as the
17+
bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived
18+
from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now
19+
stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return
20+
(new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the
21+
well-known location for the token's RFC 8707 `resource` identifier; the
22+
parameter is omitted when neither is available.

docs/serving/authorization.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,7 @@ const auth = requireBearerAuth({
3434
});
3535

3636
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
37-
const node = toNodeHandler(
38-
createMcpHandler(buildServer, {
39-
scopeChallenge: {
40-
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
41-
}
42-
})
43-
);
37+
const node = toNodeHandler(createMcpHandler(buildServer));
4438
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
4539
```
4640

@@ -125,7 +119,9 @@ The per-request factory itself receives the same value as `ctx.authInfo`, so it
125119

126120
## Enforce per-operation scopes
127121

128-
`requiredScopes` gates the whole endpoint. For scope step-up on an individual tool call, resource read, or prompt retrieval, set `scopeChallenge` on its registration and configure `scopeChallenge.resourceMetadataUrl` on `createMcpHandler` (or a directly constructed Streamable HTTP transport). The callback receives the full parsed request and verified `authInfo`. Return `undefined` to continue, or return the exact, complete scope set to send `403 insufficient_scope` before invocation or SSE. Throwing or rejecting fails closed.
122+
`requiredScopes` gates the whole endpoint. For scope step-up on an individual tool call, resource read, or prompt retrieval, set `scopeChallenge` on its registration — no handler or transport configuration is needed. The callback receives the full parsed request and verified `authInfo`. Return `undefined` to continue, or return the exact, complete scope set to send `403 insufficient_scope` before invocation or SSE. Throwing or rejecting fails closed.
123+
124+
The challenge's `WWW-Authenticate` header is built by the same formatter as `requireBearerAuth`'s own `401`/`403` answers, and its `resource_metadata` parameter comes from the verified `AuthInfo`: the gate stamps its configured `resourceMetadataUrl` onto the `AuthInfo` it returns, so the metadata URL is configured exactly once — on `requireBearerAuth`. Without a stamped value the parameter falls back to the well-known location for the token's RFC 8707 `resource` identifier, or is omitted.
129125

130126
Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request:
131127

@@ -172,5 +168,5 @@ The callback runs before the primitive's input schema is validated or transforme
172168
- `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens.
173169
- Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge.
174170
- `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata.
175-
- Verified auth flows `req.auth``ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation.
171+
- Verified auth flows `req.auth``ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation, advertising the metadata URL the gate stamped onto `AuthInfo`.
176172
- The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`.

examples/guides/serving/authorization.examples.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,7 @@ const auth = requireBearerAuth({
3535
});
3636

3737
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
38-
const node = toNodeHandler(
39-
createMcpHandler(buildServer, {
40-
scopeChallenge: {
41-
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
42-
}
43-
})
44-
);
38+
const node = toNodeHandler(createMcpHandler(buildServer));
4539
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
4640
//#endregion requireBearerAuth_basic
4741

packages/core-internal/src/types/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,19 @@ export interface AuthInfo {
751751
*/
752752
resource?: URL;
753753

754+
/**
755+
* URL of the RFC 9728 Protected Resource Metadata document for the
756+
* resource server that accepted this token.
757+
*
758+
* The bearer-auth helpers stamp their configured `resourceMetadataUrl`
759+
* here when verification succeeds, so challenge responses built after
760+
* authentication (for example per-operation `insufficient_scope` scope
761+
* challenges) can advertise the same document as the authentication
762+
* gate's own challenges without separate configuration. Verifiers may
763+
* also populate it directly; a verifier-set value wins.
764+
*/
765+
resourceMetadataUrl?: string;
766+
754767
/**
755768
* Additional data associated with the token.
756769
* This field should be used for any additional data that needs to be attached to the auth info.

packages/server/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export { InMemoryServerEventBus } from './server/serverEventBus';
6565
// StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio
6666
// has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath
6767
// gives consumers a consistent shape across packages.
68-
export type { ScopeChallenge, ScopeChallengeConfig, ScopeChallengeHandler } from './server/scopeChallenge';
68+
export type { ScopeChallenge, ScopeChallengeHandler } from './server/scopeChallenge';
6969
export { requireScopes } from './server/scopeChallenge';
7070
export type {
7171
EventId,

packages/server/src/server/createMcpHandler.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter';
6464
import { McpServer } from './mcp';
6565
import type { PerRequestResponseMode } from './perRequestTransport';
6666
import { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
67-
import type { ScopeChallengeConfig } from './scopeChallenge';
68-
import { createScopeChallengeResponse, findScopeChallenge } from './scopeChallenge';
67+
import { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge';
6968
import type { Server } from './server';
7069
import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server';
7170
import type { ServerEventBus, ServerNotifier } from './serverEventBus';
@@ -214,8 +213,6 @@ export interface CreateMcpHandlerOptions {
214213
* @default 4194304 (4 MiB)
215214
*/
216215
maxRequestBodySize?: number;
217-
/** Enables per-operation OAuth scope challenges. */
218-
scopeChallenge?: ScopeChallengeConfig;
219216
}
220217

221218
/**
@@ -327,8 +324,7 @@ function createLegacyStatelessFallback(
327324
factory: McpServerFactory,
328325
onerror?: (error: Error) => void,
329326
keepAliveMs?: number,
330-
maxRequestBodySize?: number,
331-
scopeChallenge?: ScopeChallengeConfig
327+
maxRequestBodySize?: number
332328
): LegacyHttpHandler {
333329
return async (request, options) => {
334330
if (request.method.toUpperCase() !== 'POST') {
@@ -343,8 +339,7 @@ function createLegacyStatelessFallback(
343339
const transport = new WebStandardStreamableHTTPServerTransport({
344340
sessionIdGenerator: undefined,
345341
...(keepAliveMs !== undefined && { keepAliveMs }),
346-
...(maxRequestBodySize !== undefined && { maxRequestBodySize }),
347-
...(scopeChallenge !== undefined && { scopeChallenge })
342+
...(maxRequestBodySize !== undefined && { maxRequestBodySize })
348343
});
349344
await product.connect(transport);
350345

@@ -721,9 +716,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
721716
// The default posture is the stateless fallback; 'reject' is the only way
722717
// to turn legacy serving off (modern-only strict).
723718
const legacyHandler: LegacyHttpHandler | undefined =
724-
legacy === 'reject'
725-
? undefined
726-
: createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize, options.scopeChallenge);
719+
legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize);
727720

728721
async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
729722
const claimedRevision = route.classification.revision;
@@ -839,13 +832,18 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
839832
}
840833
}
841834

842-
// Run scope preflight after Mcp-Param headers have been checked against the body.
843-
if (route.messageKind === 'request' && product instanceof McpServer && options.scopeChallenge !== undefined) {
835+
// Run scope preflight after Mcp-Param headers have been checked against
836+
// the body. Active whenever the factory's instance registers a
837+
// per-primitive scopeChallenge callback — no handler-level
838+
// configuration exists: the challenge's resource_metadata parameter is
839+
// derived from the verified AuthInfo (stamped by the bearer-auth gate,
840+
// or the token's RFC 8707 resource identifier) and omitted otherwise.
841+
if (route.messageKind === 'request' && product instanceof McpServer) {
844842
try {
845843
const result = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context));
846844
if (result !== undefined) {
847845
void product.close().catch(reportError);
848-
return createScopeChallengeResponse(options.scopeChallenge, result.challenge, result.requestId);
846+
return createScopeChallengeResponse(result.challenge, result.requestId, scopeChallengeResourceMetadataUrl(authInfo));
849847
}
850848
} catch (error) {
851849
void product.close().catch(reportError);

packages/server/src/server/middleware/bearerAuth.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export interface BearerAuthOptions {
5050
*
5151
* Typically built with `getOAuthProtectedResourceMetadataUrl`, exported
5252
* from this package.
53+
*
54+
* When verification succeeds the value is also stamped onto the returned
55+
* {@link AuthInfo} (`authInfo.resourceMetadataUrl`, unless the verifier
56+
* already set one), so challenges built after authentication — such as
57+
* per-operation `insufficient_scope` scope challenges — advertise the
58+
* same document without being configured separately.
5359
*/
5460
resourceMetadataUrl?: string;
5561
}
@@ -62,18 +68,27 @@ function headerQuotedValue(value: string): string {
6268
return value.replaceAll(/[\\"]/g, String.raw`\$&`).replaceAll(/[^\u0020-\u007E]/g, ' ');
6369
}
6470

65-
function buildWwwAuthenticateHeader(
71+
/**
72+
* Build a `WWW-Authenticate: Bearer …` challenge header value (RFC 6750).
73+
*
74+
* The single formatter behind every challenge this package emits — the
75+
* bearer-auth 401/403 answers and the per-operation scope-challenge 403 — so
76+
* all challenges from one server agree on parameter order and quoting. Every
77+
* parameter value is emitted as an HTTP quoted-string with `\` and `"`
78+
* escaped and non-printable characters replaced.
79+
*/
80+
export function buildWwwAuthenticateHeader(
6681
errorCode: string,
6782
description: string,
68-
requiredScopes: string[],
83+
requiredScopes: readonly string[],
6984
resourceMetadataUrl: string | undefined
7085
): string {
7186
let header = `Bearer error="${headerQuotedValue(errorCode)}", error_description="${headerQuotedValue(description)}"`;
7287
if (requiredScopes.length > 0) {
73-
header += `, scope="${requiredScopes.join(' ')}"`;
88+
header += `, scope="${headerQuotedValue(requiredScopes.join(' '))}"`;
7489
}
7590
if (resourceMetadataUrl) {
76-
header += `, resource_metadata="${resourceMetadataUrl}"`;
91+
header += `, resource_metadata="${headerQuotedValue(resourceMetadataUrl)}"`;
7792
}
7893
return header;
7994
}
@@ -120,6 +135,14 @@ export async function verifyBearerToken(authorizationHeader: string | null | und
120135
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired');
121136
}
122137

138+
// Hand the gate's discovery configuration inward with the verified token,
139+
// so challenges built after authentication (per-operation scope
140+
// challenges) advertise the same metadata document. A verifier-set value
141+
// wins over the gate's configuration.
142+
if (options.resourceMetadataUrl !== undefined && authInfo.resourceMetadataUrl === undefined) {
143+
return { ...authInfo, resourceMetadataUrl: options.resourceMetadataUrl };
144+
}
145+
123146
return authInfo;
124147
}
125148

packages/server/src/server/scopeChallenge.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { AuthInfo, JSONRPCRequest, RequestId } from '@modelcontextprotocol/core-internal';
22

3+
import { buildWwwAuthenticateHeader } from './middleware/bearerAuth';
4+
import { getOAuthProtectedResourceMetadataUrl } from './middleware/oauthMetadata';
5+
36
/** OAuth scopes to request before handling an MCP request. */
47
export interface ScopeChallenge {
58
/** The exact, complete scope set to include in the challenge. Each scope must satisfy the OAuth `scope-token` grammar. */
@@ -14,12 +17,6 @@ export type ScopeChallengeHandler = (context: {
1417
authInfo?: AuthInfo;
1518
}) => ScopeChallenge | undefined | Promise<ScopeChallenge | undefined>;
1619

17-
/** Configuration for HTTP `insufficient_scope` challenges. */
18-
export interface ScopeChallengeConfig {
19-
/** URL of the RFC 9728 protected resource metadata. */
20-
resourceMetadataUrl: string;
21-
}
22-
2320
/** @internal */
2421
export function supportsScopeChallengeResolver(
2522
transport: unknown
@@ -90,22 +87,41 @@ export async function findScopeChallenge(
9087
return undefined;
9188
}
9289

93-
function quoteAuthParam(value: string): string {
94-
return value.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`);
90+
/**
91+
* The RFC 9728 Protected Resource Metadata URL to advertise on a scope
92+
* challenge, derived from the verified {@link AuthInfo}: the URL the
93+
* authentication gate stamped (`authInfo.resourceMetadataUrl`, set by the
94+
* bearer-auth helpers from their `resourceMetadataUrl` option), falling back
95+
* to the well-known location for the token's RFC 8707 `resource` identifier,
96+
* or `undefined` when neither is available (the `resource_metadata` parameter
97+
* is then omitted, matching the bearer-auth challenges).
98+
*
99+
* @internal
100+
*/
101+
export function scopeChallengeResourceMetadataUrl(authInfo: AuthInfo | undefined): string | undefined {
102+
if (authInfo?.resourceMetadataUrl !== undefined) {
103+
return authInfo.resourceMetadataUrl;
104+
}
105+
if (authInfo?.resource !== undefined) {
106+
return getOAuthProtectedResourceMetadataUrl(authInfo.resource);
107+
}
108+
return undefined;
95109
}
96110

97111
/** @internal */
98112
export function createScopeChallengeResponse(
99-
config: ScopeChallengeConfig,
100113
challenge: ScopeChallenge,
101-
responseId: RequestId | null
114+
responseId: RequestId | null,
115+
resourceMetadataUrl: string | undefined
102116
): Response {
103-
const wwwAuthenticate =
104-
'Bearer' +
105-
' error="insufficient_scope"' +
106-
`, scope="${quoteAuthParam(challenge.scopes.join(' '))}"` +
107-
`, resource_metadata="${quoteAuthParam(config.resourceMetadataUrl)}"` +
108-
(challenge.errorDescription === undefined ? '' : `, error_description="${quoteAuthParam(challenge.errorDescription)}"`);
117+
// One formatter for every challenge this package emits: identical
118+
// parameter order and quoting to the bearer-auth 401/403 answers.
119+
const wwwAuthenticate = buildWwwAuthenticateHeader(
120+
'insufficient_scope',
121+
challenge.errorDescription ?? 'Insufficient scope',
122+
challenge.scopes,
123+
resourceMetadataUrl
124+
);
109125

110126
return Response.json(
111127
{

packages/server/src/server/streamableHttp.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import {
2020
} from '@modelcontextprotocol/core-internal';
2121

2222
import { MAX_BATCH_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody';
23-
import type { ScopeChallengeConfig, ScopeChallengeHandler } from './scopeChallenge';
24-
import { createScopeChallengeResponse, findScopeChallenge } from './scopeChallenge';
23+
import type { ScopeChallengeHandler } from './scopeChallenge';
24+
import { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge';
2525
import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive';
2626

2727
export type StreamId = string;
@@ -179,9 +179,6 @@ export interface WebStandardStreamableHTTPServerTransportOptions {
179179
* @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS}
180180
*/
181181
supportedProtocolVersions?: string[];
182-
183-
/** Enables OAuth scope challenges. `McpServer.connect()` supplies the resolver. */
184-
scopeChallenge?: ScopeChallengeConfig;
185182
}
186183

187184
/**
@@ -272,7 +269,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
272269
private _supportedProtocolVersions: string[];
273270
private _keepAliveMs: number;
274271
private _maxRequestBodySize: number;
275-
private _scopeChallenge?: ScopeChallengeConfig;
276272
private _scopeChallengeResolver?: ScopeChallengeHandler;
277273

278274
sessionId?: string;
@@ -293,7 +289,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
293289
this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
294290
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS;
295291
this._maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize);
296-
this._scopeChallenge = options.scopeChallenge;
297292
}
298293

299294
private startKeepAlive(
@@ -366,14 +361,22 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
366361
}
367362

368363
private async _checkScopeChallenge(messages: JSONRPCMessage[], authInfo?: AuthInfo): Promise<Response | undefined> {
369-
if (!this._scopeChallenge || !this._scopeChallengeResolver) {
364+
// Active whenever a connected McpServer supplied a resolver (it
365+
// resolves per-primitive scopeChallenge callbacks); the challenge's
366+
// resource_metadata parameter is derived from the verified AuthInfo
367+
// and omitted when unavailable.
368+
if (!this._scopeChallengeResolver) {
370369
return undefined;
371370
}
372371
const requests: JSONRPCRequest[] = messages.filter(message => isJSONRPCRequest(message));
373372
const result = await findScopeChallenge(requests, authInfo, this._scopeChallengeResolver);
374373
return result === undefined
375374
? undefined
376-
: createScopeChallengeResponse(this._scopeChallenge, result.challenge, messages.length === 1 ? result.requestId : null);
375+
: createScopeChallengeResponse(
376+
result.challenge,
377+
messages.length === 1 ? result.requestId : null,
378+
scopeChallengeResourceMetadataUrl(authInfo)
379+
);
377380
}
378381

379382
/**

0 commit comments

Comments
 (0)