From 77a01ba3cade9925cce0d5460fc30611231eb9f7 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 21 Aug 2026 02:28:43 +0200 Subject: [PATCH 1/7] feat(server): add request-time OAuth scope challenges Let tools return exact OAuth scope challenges from request-aware callbacks before invocation or SSE setup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/scope-challenge-server.md | 12 + docs/behavior-surface-pins.md | 1 + docs/serving/authorization.md | 53 +++-- .../guides/serving/authorization.examples.ts | 41 +++- .../middleware/node/src/streamableHttp.ts | 6 + packages/server/src/index.ts | 2 + .../server/src/server/createMcpHandler.ts | 29 ++- packages/server/src/server/mcp.ts | 30 ++- packages/server/src/server/scopeChallenge.ts | 121 ++++++++++ packages/server/src/server/streamableHttp.ts | 38 ++- .../server/test/server/scopeChallenge.test.ts | 224 ++++++++++++++++++ .../test/server/scopeChallengeModern.test.ts | 175 ++++++++++++++ 12 files changed, 699 insertions(+), 33 deletions(-) create mode 100644 .changeset/scope-challenge-server.md create mode 100644 packages/server/src/server/scopeChallenge.ts create mode 100644 packages/server/test/server/scopeChallenge.test.ts create mode 100644 packages/server/test/server/scopeChallengeModern.test.ts diff --git a/.changeset/scope-challenge-server.md b/.changeset/scope-challenge-server.md new file mode 100644 index 0000000000..6152d150e7 --- /dev/null +++ b/.changeset/scope-challenge-server.md @@ -0,0 +1,12 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/node': minor +--- + +Add request-time OAuth scope challenges for tools. A tool's `scopeChallenge` +callback receives the parsed request and verified authentication info, then +either continues or returns the exact scope set for an `insufficient_scope` +response. `requireScopes` provides a small helper for static all-of checks. + +`createMcpHandler` and Streamable HTTP transports return HTTP 403 with an +`insufficient_scope` challenge before tool execution or SSE setup. diff --git a/docs/behavior-surface-pins.md b/docs/behavior-surface-pins.md index 70257c9015..1c690d7a69 100644 --- a/docs/behavior-surface-pins.md +++ b/docs/behavior-surface-pins.md @@ -30,6 +30,7 @@ CI pass — that reopens the silent-drift hole the pin exists to close. | Published package set, export maps, dual ESM/CJS topology | `packages/core-internal/test/packageTopologyPins.test.ts` | | stdio environment-inheritance safelist | `packages/client/test/client/stdioEnvPins.test.ts` | | 2025-11-25 wire method-registry membership, schema identity | `packages/core-internal/test/types/registryPins.test.ts` | +| OAuth scope challenge timing and serialization | `packages/server/test/server/scopeChallenge.test.ts` | ## Writing a new pin diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 7a0389c77c..3225119a34 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -21,7 +21,8 @@ import { } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, requireScopes } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; const mcpServerUrl = new URL('https://api.example.com/mcp'); const verifier: OAuthTokenVerifier = { verifyAccessToken }; @@ -33,7 +34,13 @@ const auth = requireBearerAuth({ }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); -const node = toNodeHandler(createMcpHandler(buildServer)); +const node = toNodeHandler( + createMcpHandler(buildServer, { + scopeChallenge: { + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + } + }) +); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); ``` @@ -118,22 +125,34 @@ The per-request factory itself receives the same value as `ctx.authInfo`, so it ## Enforce per-tool scopes -`requiredScopes` gates the whole endpoint. For a scope only some tools need, check inside the handler — the handler is the only place that knows which tool is executing. - -```ts source="../../examples/guides/serving/authorization.examples.ts#perToolScopes_handler" -server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => { - if (!ctx.http?.authInfo?.scopes.includes('notes:write')) { - return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true }; - } - return { content: [{ type: 'text', text: 'All notes deleted' }] }; -}); +`requiredScopes` gates the whole endpoint. For scope step-up on an individual tool, set its `scopeChallenge` callback 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. + +Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request: + +```ts source="../../examples/guides/serving/authorization.examples.ts#perToolScopes_challenge" +server.registerTool('purge-notes', { scopeChallenge: requireScopes('notes:write') }, async () => ({ + content: [{ type: 'text', text: 'All notes deleted' }] +})); + +server.registerTool( + 'read-repository', + { + inputSchema: z.object({ visibility: z.enum(['public', 'private']) }), + scopeChallenge: ({ request, authInfo }) => { + const visibility = (request.params as { arguments?: { visibility?: unknown } }).arguments?.visibility; + if (visibility !== 'public' && visibility !== 'private') return; + + const scopes = visibility === 'private' ? (['repo:read'] as const) : (['public_repo'] as const); + return scopes.every(scope => authInfo?.scopes.includes(scope)) + ? undefined + : { scopes, errorDescription: `${visibility} repository access is required` }; + } + }, + async ({ visibility }) => ({ content: [{ type: 'text', text: `Read ${visibility} repository` }] }) +); ``` -A caller holding only `mcp` gets an ordinary tool result with `isError: true`, so the model reads the refusal and moves on instead of losing the connection. - -::: info -Responding `403 insufficient_scope` at the HTTP layer instead triggers the client transport's automatic scope step-up (SEP-2350) — see [Authenticate a user with OAuth](../clients/oauth.md). -::: +Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged tools remain visible in `tools/list`. ## Recap @@ -141,5 +160,5 @@ Responding `403 insufficient_scope` at the HTTP layer instead triggers the clien - `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens. - 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. - `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata. -- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-tool scopes are a check inside the handler that returns `isError: true`. +- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-tool callbacks can trigger HTTP `403` scope step-up before invocation. - The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`. diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts index bc6887d9e0..97c62e63d2 100644 --- a/examples/guides/serving/authorization.examples.ts +++ b/examples/guides/serving/authorization.examples.ts @@ -22,7 +22,8 @@ import { } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer, requireScopes } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; const mcpServerUrl = new URL('https://api.example.com/mcp'); const verifier: OAuthTokenVerifier = { verifyAccessToken }; @@ -34,7 +35,13 @@ const auth = requireBearerAuth({ }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); -const node = toNodeHandler(createMcpHandler(buildServer)); +const node = toNodeHandler( + createMcpHandler(buildServer, { + scopeChallenge: { + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + } + }) +); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); //#endregion requireBearerAuth_basic @@ -72,14 +79,28 @@ function buildServer(): McpServer { }); //#endregion authInfo_handler - //#region perToolScopes_handler - server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => { - if (!ctx.http?.authInfo?.scopes.includes('notes:write')) { - return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true }; - } - return { content: [{ type: 'text', text: 'All notes deleted' }] }; - }); - //#endregion perToolScopes_handler + //#region perToolScopes_challenge + server.registerTool('purge-notes', { scopeChallenge: requireScopes('notes:write') }, async () => ({ + content: [{ type: 'text', text: 'All notes deleted' }] + })); + + server.registerTool( + 'read-repository', + { + inputSchema: z.object({ visibility: z.enum(['public', 'private']) }), + scopeChallenge: ({ request, authInfo }) => { + const visibility = (request.params as { arguments?: { visibility?: unknown } }).arguments?.visibility; + if (visibility !== 'public' && visibility !== 'private') return; + + const scopes = visibility === 'private' ? (['repo:read'] as const) : (['public_repo'] as const); + return scopes.every(scope => authInfo?.scopes.includes(scope)) + ? undefined + : { scopes, errorDescription: `${visibility} repository access is required` }; + } + }, + async ({ visibility }) => ({ content: [{ type: 'text', text: `Read ${visibility} repository` }] }) + ); + //#endregion perToolScopes_challenge return server; } diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index a6f1c43a6b..779e582ac9 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -15,6 +15,7 @@ import type { JSONRPCMessage, MessageExtraInfo, RequestId, + ScopeChallengeHandler, Transport, WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; @@ -169,6 +170,11 @@ export class NodeStreamableHTTPServerTransport implements Transport { this._webStandardTransport.setSupportedProtocolVersions(versions); } + /** Sets the scope challenge resolver used by the wrapped Web Standard transport. */ + setScopeChallengeResolver(resolver: ScopeChallengeHandler): void { + this._webStandardTransport.setScopeChallengeResolver(resolver); + } + /** * Handles an incoming HTTP request, whether `GET` or `POST`. * diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 4bd9a04f3f..be95cc92fd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -65,6 +65,8 @@ export { InMemoryServerEventBus } from './server/serverEventBus'; // StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio // has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath // gives consumers a consistent shape across packages. +export type { ScopeChallenge, ScopeChallengeConfig, ScopeChallengeHandler } from './server/scopeChallenge'; +export { requireScopes } from './server/scopeChallenge'; export type { EventId, EventStore, diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 9adbe54fb8..6b82396d5d 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -64,6 +64,8 @@ import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter'; import { McpServer } from './mcp'; import type { PerRequestResponseMode } from './perRequestTransport'; import { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody'; +import type { ScopeChallengeConfig } from './scopeChallenge'; +import { createScopeChallengeResponse, findScopeChallenge } from './scopeChallenge'; import type { Server } from './server'; import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server'; import type { ServerEventBus, ServerNotifier } from './serverEventBus'; @@ -212,6 +214,8 @@ export interface CreateMcpHandlerOptions { * @default 4194304 (4 MiB) */ maxRequestBodySize?: number; + /** Enables per-operation OAuth scope challenges. */ + scopeChallenge?: ScopeChallengeConfig; } /** @@ -323,7 +327,8 @@ function createLegacyStatelessFallback( factory: McpServerFactory, onerror?: (error: Error) => void, keepAliveMs?: number, - maxRequestBodySize?: number + maxRequestBodySize?: number, + scopeChallenge?: ScopeChallengeConfig ): LegacyHttpHandler { return async (request, options) => { if (request.method.toUpperCase() !== 'POST') { @@ -338,7 +343,8 @@ function createLegacyStatelessFallback( const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, ...(keepAliveMs !== undefined && { keepAliveMs }), - ...(maxRequestBodySize !== undefined && { maxRequestBodySize }) + ...(maxRequestBodySize !== undefined && { maxRequestBodySize }), + ...(scopeChallenge !== undefined && { scopeChallenge }) }); await product.connect(transport); @@ -715,7 +721,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // The default posture is the stateless fallback; 'reject' is the only way // to turn legacy serving off (modern-only strict). const legacyHandler: LegacyHttpHandler | undefined = - legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize); + legacy === 'reject' + ? undefined + : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize, options.scopeChallenge); async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise { const claimedRevision = route.classification.revision; @@ -829,6 +837,21 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } } } + + // Run scope preflight after Mcp-Param headers have been checked against the body. + if (options.scopeChallenge !== undefined) { + try { + const result = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); + if (result !== undefined) { + void product.close().catch(reportError); + return createScopeChallengeResponse(options.scopeChallenge, result.challenge, result.requestId); + } + } catch (error) { + void product.close().catch(reportError); + reportError(toError(error)); + return internalServerErrorResponse(route.message.id); + } + } } // Era-write at instance binding, then modern-only handler installation — diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d2e40181e4..d5508f3df3 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -47,6 +47,8 @@ import { import type * as z from 'zod/v4'; import { getCompleter, isCompletable } from './completable'; +import type { ScopeChallengeHandler } from './scopeChallenge'; +import { supportsScopeChallengeResolver } from './scopeChallenge'; import type { ServerOptions } from './server'; import { Server } from './server'; @@ -146,6 +148,9 @@ export class McpServer { * ``` */ async connect(transport: Transport): Promise { + if (supportsScopeChallengeResolver(transport)) { + transport.setScopeChallengeResolver(context => this.resolveScopeChallenge(context)); + } return await this.server.connect(transport); } @@ -156,6 +161,16 @@ export class McpServer { await this.server.close(); } + /** @internal */ + resolveScopeChallenge: ScopeChallengeHandler = context => { + if (context.request.method !== 'tools/call') return; + const toolName = (context.request.params as { name?: unknown } | undefined)?.name; + if (typeof toolName !== 'string') return; + const tool = this._registeredTools[toolName]; + if (tool === undefined || !tool.enabled) return; + return tool.scopeChallenge?.(context); + }; + private _toolHandlersInitialized = false; private setToolRequestHandlers() { @@ -811,6 +826,7 @@ export class McpServer { annotations: ToolAnnotations | undefined, icons: Icon[] | undefined, execution: ToolExecution | undefined, + scopeChallenge: ScopeChallengeHandler | undefined, _meta: Record | undefined, handler: AnyToolHandler ): RegisteredTool { @@ -856,6 +872,7 @@ export class McpServer { annotations, icons, execution, + scopeChallenge, _meta, handler: handler, executor: createToolExecutor(inputSchema, handler), @@ -911,6 +928,9 @@ export class McpServer { } if (updates.annotations !== undefined) registeredTool.annotations = updates.annotations; if (updates.icons !== undefined) registeredTool.icons = updates.icons; + if (updates.scopeChallenge !== undefined) { + registeredTool.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge; + } if (updates._meta !== undefined) registeredTool._meta = updates._meta; if (updates.enabled !== undefined) registeredTool.enabled = updates.enabled; this.sendToolListChanged(); @@ -959,6 +979,8 @@ export class McpServer { outputSchema?: OutputArgs; annotations?: ToolAnnotations; icons?: Icon[]; + /** Determines whether this tool call needs an OAuth scope challenge. */ + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: ToolCallback @@ -973,6 +995,7 @@ export class McpServer { outputSchema?: OutputArgs; annotations?: ToolAnnotations; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: LegacyToolCallback @@ -986,6 +1009,7 @@ export class McpServer { outputSchema?: StandardSchemaWithJSON | ZodRawShape; annotations?: ToolAnnotations; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: ToolCallback | LegacyToolCallback @@ -994,8 +1018,7 @@ export class McpServer { throw new Error(`Tool ${name} is already registered`); } - const { title, description, inputSchema, outputSchema, annotations, icons, _meta } = config; - + const { title, description, inputSchema, outputSchema, annotations, icons, scopeChallenge, _meta } = config; return this._createRegisteredTool( name, title, @@ -1005,6 +1028,7 @@ export class McpServer { annotations, icons, undefined, + scopeChallenge, _meta, cb as ToolCallback ); @@ -1278,6 +1302,7 @@ export type RegisteredTool = { annotations?: ToolAnnotations; icons?: Icon[]; execution?: ToolExecution; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; handler: AnyToolHandler; /** @hidden */ @@ -1293,6 +1318,7 @@ export type RegisteredTool = { outputSchema?: StandardSchemaWithJSON; annotations?: ToolAnnotations; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler | null; _meta?: Record; callback?: ToolCallback; enabled?: boolean; diff --git a/packages/server/src/server/scopeChallenge.ts b/packages/server/src/server/scopeChallenge.ts new file mode 100644 index 0000000000..27fad701ce --- /dev/null +++ b/packages/server/src/server/scopeChallenge.ts @@ -0,0 +1,121 @@ +import type { AuthInfo, JSONRPCRequest, RequestId } from '@modelcontextprotocol/core-internal'; + +/** OAuth scopes to request before handling an MCP request. */ +export interface ScopeChallenge { + /** The exact, complete scope set to include in the challenge. */ + scopes: readonly [string, ...string[]]; + /** Optional human-readable detail for the OAuth challenge. */ + errorDescription?: string; +} + +/** Determines whether an MCP request needs an OAuth scope challenge. */ +export type ScopeChallengeHandler = (context: { + request: JSONRPCRequest; + authInfo?: AuthInfo; +}) => ScopeChallenge | undefined | Promise; + +/** Configuration for HTTP `insufficient_scope` challenges. */ +export interface ScopeChallengeConfig { + /** URL of the RFC 9728 protected resource metadata. */ + resourceMetadataUrl: string; +} + +/** @internal */ +export function supportsScopeChallengeResolver( + transport: unknown +): transport is { setScopeChallengeResolver(resolver: ScopeChallengeHandler): void } { + return ( + typeof transport === 'object' && + transport !== null && + 'setScopeChallengeResolver' in transport && + typeof (transport as { setScopeChallengeResolver: unknown }).setScopeChallengeResolver === 'function' + ); +} + +function assertScope(scope: unknown, location: string): asserts scope is string { + if (typeof scope !== 'string' || scope.length === 0 || /\s/.test(scope)) { + throw new TypeError(`${location} must be a non-empty OAuth scope without whitespace`); + } +} + +function validateScopeChallenge(challenge: ScopeChallenge): ScopeChallenge { + if (challenge === null || typeof challenge !== 'object' || !Array.isArray(challenge.scopes) || challenge.scopes.length === 0) { + throw new TypeError('scope challenge must contain at least one scope'); + } + for (const [index, scope] of challenge.scopes.entries()) { + assertScope(scope, `scope challenge scopes[${index}]`); + } + if (challenge.errorDescription !== undefined && typeof challenge.errorDescription !== 'string') { + throw new TypeError('scope challenge errorDescription must be a string'); + } + return challenge; +} + +/** + * Creates a handler that requires every supplied scope exactly. + * + * Requests without authentication are left to the server's authentication gate. + */ +export function requireScopes(...scopes: readonly [string, ...string[]]): ScopeChallengeHandler { + if (scopes.length === 0) { + throw new TypeError('requireScopes must contain at least one scope'); + } + for (const [index, scope] of scopes.entries()) { + assertScope(scope, `requireScopes scope[${index}]`); + } + const requiredScopes = [...scopes] as [string, ...string[]]; + return ({ authInfo }) => { + if (authInfo === undefined) return; + const activeScopes = new Set(authInfo.scopes); + if (requiredScopes.every(scope => activeScopes.has(scope))) return; + return { scopes: requiredScopes }; + }; +} + +/** @internal */ +export async function findScopeChallenge( + requests: readonly JSONRPCRequest[], + authInfo: AuthInfo | undefined, + resolve: ScopeChallengeHandler +): Promise<{ challenge: ScopeChallenge; requestId: RequestId } | undefined> { + for (const request of requests) { + const challenge = await resolve({ request, ...(authInfo !== undefined && { authInfo }) }); + if (challenge !== undefined) { + return { challenge: validateScopeChallenge(challenge), requestId: request.id }; + } + } + return undefined; +} + +function quoteAuthParam(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`); +} + +/** @internal */ +export function createScopeChallengeResponse( + config: ScopeChallengeConfig, + challenge: ScopeChallenge, + responseId: RequestId | null +): Response { + const wwwAuthenticate = + 'Bearer' + + ' error="insufficient_scope"' + + `, scope="${quoteAuthParam(challenge.scopes.join(' '))}"` + + `, resource_metadata="${quoteAuthParam(config.resourceMetadataUrl)}"` + + (challenge.errorDescription === undefined ? '' : `, error_description="${quoteAuthParam(challenge.errorDescription)}"`); + + return Response.json( + { + jsonrpc: '2.0', + error: { code: -32_600, message: 'Insufficient scope' }, + id: responseId + }, + { + status: 403, + headers: { + 'Content-Type': 'application/json', + 'WWW-Authenticate': wwwAuthenticate + } + } + ); +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index ce73cf8a20..c0ab6db98b 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -7,7 +7,7 @@ * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. */ -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core-internal'; +import type { AuthInfo, JSONRPCMessage, JSONRPCRequest, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core-internal'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, isInitializeRequest, @@ -20,6 +20,8 @@ import { } from '@modelcontextprotocol/core-internal'; import { MAX_BATCH_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody'; +import type { ScopeChallengeConfig, ScopeChallengeHandler } from './scopeChallenge'; +import { createScopeChallengeResponse, findScopeChallenge } from './scopeChallenge'; import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; export type StreamId = string; @@ -177,6 +179,9 @@ export interface WebStandardStreamableHTTPServerTransportOptions { * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ supportedProtocolVersions?: string[]; + + /** Enables OAuth scope challenges. `McpServer.connect()` supplies the resolver. */ + scopeChallenge?: ScopeChallengeConfig; } /** @@ -267,6 +272,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _supportedProtocolVersions: string[]; private _keepAliveMs: number; private _maxRequestBodySize: number; + private _scopeChallenge?: ScopeChallengeConfig; + private _scopeChallengeResolver?: ScopeChallengeHandler; sessionId?: string; onclose?: () => void; @@ -286,6 +293,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS; this._maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize); + this._scopeChallenge = options.scopeChallenge; } private startKeepAlive( @@ -304,6 +312,11 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return timer; } + /** Sets the scope challenge resolver for parsed JSON-RPC requests. */ + setScopeChallengeResolver(resolver: ScopeChallengeHandler): void { + this._scopeChallengeResolver = resolver; + } + /** * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op * for the Streamable HTTP transport as connections are managed per-request. @@ -352,6 +365,17 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { ); } + private async _checkScopeChallenge(messages: JSONRPCMessage[], authInfo?: AuthInfo): Promise { + if (!this._scopeChallenge || !this._scopeChallengeResolver) { + return undefined; + } + const requests: JSONRPCRequest[] = messages.filter(message => isJSONRPCRequest(message)); + const result = await findScopeChallenge(requests, authInfo, this._scopeChallengeResolver); + return result === undefined + ? undefined + : createScopeChallengeResponse(this._scopeChallenge, result.challenge, messages.length === 1 ? result.requestId : null); + } + /** * Validates request headers for DNS rebinding protection. * @returns Error response if validation fails, `undefined` if validation passes. @@ -856,6 +880,18 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return this.createJsonErrorResponse(404, -32_001, 'Session not found'); } + // Check before opening SSE so an insufficient token can receive HTTP 403. + let scopeChallengeResponse: Response | undefined; + try { + scopeChallengeResponse = await this._checkScopeChallenge(messages, options?.authInfo); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(500, -32_603, 'Internal server error'); + } + if (scopeChallengeResponse) { + return scopeChallengeResponse; + } + // check if it contains requests const hasRequests = messages.some(element => isJSONRPCRequest(element)); diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts new file mode 100644 index 0000000000..529c2af143 --- /dev/null +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -0,0 +1,224 @@ +import { randomUUID } from 'node:crypto'; + +import type { AuthInfo, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { McpServer } from '../../src/server/mcp'; +import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge'; +import { requireScopes } from '../../src/server/scopeChallenge'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp'; + +const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource'; + +function toolCall(name = 'operate', args: Record = {}, id: string | number = 'call-1'): JSONRPCRequest { + return { + jsonrpc: '2.0', + method: 'tools/call', + params: { name, arguments: args }, + id + }; +} + +function auth(scopes: string[]): AuthInfo { + return { token: 'token', clientId: 'client', scopes }; +} + +describe('requireScopes', () => { + it('requires every supplied scope using exact matches', async () => { + const handler = requireScopes('repo:read', 'org:read'); + const request = toolCall(); + + expect(await handler({ request, authInfo: auth(['repo:read']) })).toEqual({ + scopes: ['repo:read', 'org:read'] + }); + expect(await handler({ request, authInfo: auth(['repo:read', 'org:read']) })).toBeUndefined(); + expect(await handler({ request, authInfo: auth(['repo:read:all', 'org:read']) })).toEqual({ + scopes: ['repo:read', 'org:read'] + }); + }); + + it('leaves unauthenticated requests to the authentication gate', async () => { + expect(await requireScopes('repo:read')({ request: toolCall() })).toBeUndefined(); + }); + + it('rejects invalid static scope declarations', () => { + expect(() => (requireScopes as (...scopes: string[]) => ScopeChallengeHandler)()).toThrow('at least one'); + expect(() => requireScopes('repo read')).toThrow('without whitespace'); + }); +}); + +interface LegacyHarness { + server: McpServer; + transport: WebStandardStreamableHTTPServerTransport; + calls: ReturnType; +} + +async function createLegacyHarness(scopeChallenge: ScopeChallengeHandler): Promise { + const calls = vi.fn(); + const server = new McpServer({ name: 'scope-test', version: '1.0.0' }); + server.registerTool( + 'operate', + { + inputSchema: z.object({ mode: z.string().optional() }), + scopeChallenge + }, + async args => { + calls(args); + return { content: [{ type: 'text', text: 'ok' }] }; + } + ); + server.registerTool('public', { inputSchema: z.object({}) }, async () => ({ content: [{ type: 'text', text: 'public' }] })); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, + scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } + }); + await server.connect(transport); + return { server, transport, calls }; +} + +async function initializeLegacy(transport: WebStandardStreamableHTTPServerTransport): Promise { + const request = new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-11-25', + capabilities: {} + }, + id: 'init' + }) + }); + const response = await transport.handleRequest(request); + return response.headers.get('mcp-session-id')!; +} + +function legacyRequest(body: JSONRPCMessage | JSONRPCMessage[], sessionId: string): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }, + body: JSON.stringify(body) + }); +} + +describe('legacy Streamable HTTP scope preflight', () => { + it('awaits the callback with the full request and auth info before dispatch', async () => { + const callback = vi.fn(async ({ request, authInfo }) => { + await Promise.resolve(); + const mode = (request.params as { arguments?: { mode?: unknown } }).arguments?.mode; + return mode === 'write' && !authInfo?.scopes.includes('repo:write') + ? { scopes: ['repo:write'], errorDescription: 'Write access is required' } + : undefined; + }); + const harness = await createLegacyHarness(callback); + const sessionId = await initializeLegacy(harness.transport); + const response = await harness.transport.handleRequest( + legacyRequest(toolCall('operate', { mode: 'write', nested: { value: 42 } }), sessionId), + { authInfo: auth(['repo:read']) } + ); + + expect(response.status).toBe(403); + const challenge = response.headers.get('WWW-Authenticate'); + expect(challenge).toContain('scope="repo:write"'); + expect(challenge).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`); + expect(challenge).toContain('error_description="Write access is required"'); + expect(callback).toHaveBeenCalledWith({ + request: expect.objectContaining({ + method: 'tools/call', + params: { name: 'operate', arguments: { mode: 'write', nested: { value: 42 } } } + }), + authInfo: auth(['repo:read']) + }); + expect(harness.calls).not.toHaveBeenCalled(); + await harness.transport.close(); + }); + + it('rejects a whole batch on the first challenge before any member executes', async () => { + const callback = vi.fn(requireScopes('repo:read')); + const harness = await createLegacyHarness(callback); + const sessionId = await initializeLegacy(harness.transport); + const response = await harness.transport.handleRequest( + legacyRequest( + [ + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'public', arguments: {} }, id: 'public' }, + toolCall('operate', {}, 'scoped') + ], + sessionId + ), + { authInfo: auth([]) } + ); + + expect(response.status).toBe(403); + expect(((await response.json()) as { id: unknown }).id).toBeNull(); + expect(callback).toHaveBeenCalledTimes(1); + expect(harness.calls).not.toHaveBeenCalled(); + await harness.transport.close(); + }); + + it('fails closed when a callback rejects or returns invalid scopes', async () => { + for (const callback of [ + vi.fn(async () => { + throw new Error('scope lookup failed'); + }), + vi.fn(() => ({ scopes: [] as unknown as [string, ...string[]] })) + ]) { + const harness = await createLegacyHarness(callback); + const onerror = vi.fn(); + harness.transport.onerror = onerror; + const sessionId = await initializeLegacy(harness.transport); + const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), { + authInfo: auth([]) + }); + + expect(response.status).toBe(500); + expect(harness.calls).not.toHaveBeenCalled(); + expect(onerror).toHaveBeenCalledOnce(); + await harness.transport.close(); + } + }); + + it('tracks callback changes across the registered-tool lifecycle', async () => { + const server = new McpServer({ name: 'scope-test', version: '1.0.0' }); + const initial = requireScopes('repo:read'); + const updated = requireScopes('repo:write'); + const tool = server.registerTool('mutable', { scopeChallenge: initial }, async () => ({ content: [] })); + + expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toEqual({ + scopes: ['repo:read'] + }); + tool.disable(); + expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toBeUndefined(); + tool.enable(); + tool.update({ scopeChallenge: updated }); + expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toEqual({ + scopes: ['repo:write'] + }); + tool.update({ scopeChallenge: null }); + expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toBeUndefined(); + tool.remove(); + expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toBeUndefined(); + }); + + it('escapes optional challenge auth parameters', async () => { + const harness = await createLegacyHarness(() => ({ + scopes: ['repo:read'], + errorDescription: String.raw`Needs "repo:read", path\to\thing` + })); + const sessionId = await initializeLegacy(harness.transport); + const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), { + authInfo: auth([]) + }); + + expect(response.headers.get('WWW-Authenticate')).toContain(String.raw`error_description="Needs \"repo:read\", path\\to\\thing"`); + await harness.transport.close(); + }); +}); diff --git a/packages/server/test/server/scopeChallengeModern.test.ts b/packages/server/test/server/scopeChallengeModern.test.ts new file mode 100644 index 0000000000..f50acae3a3 --- /dev/null +++ b/packages/server/test/server/scopeChallengeModern.test.ts @@ -0,0 +1,175 @@ +import type { AuthInfo, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { fromJsonSchema } from '../../src/fromJsonSchema'; +import { createMcpHandler } from '../../src/server/createMcpHandler'; +import { McpServer } from '../../src/server/mcp'; +import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge'; +import { requireScopes } from '../../src/server/scopeChallenge'; + +const MODERN = '2026-07-28'; +const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'scope-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function request(method: string, params: Record, extraHeaders: Record = {}): Request { + const name = typeof params.name === 'string' ? params.name : undefined; + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + 'mcp-method': method, + ...(name !== undefined && { 'mcp-name': name }), + ...extraHeaders + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 7, method, params: { ...params, _meta: ENVELOPE } }) + }); +} + +function call(name: string, args: Record, headers?: Record): Request { + return request('tools/call', { name, arguments: args }, headers); +} + +function auth(scopes: string[]): AuthInfo { + return { token: 'token', clientId: 'client', scopes }; +} + +function createHandler(scopeChallenge: ScopeChallengeHandler, onCall = vi.fn(), responseMode?: 'json' | 'sse') { + return createMcpHandler( + () => { + const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); + server.registerTool( + 'operate', + { + inputSchema: z.object({ mode: z.string().optional(), secret: z.string().optional() }), + scopeChallenge + }, + async args => { + onCall(args); + return { content: [{ type: 'text', text: 'ok' }] }; + } + ); + return server; + }, + { + ...(responseMode !== undefined && { responseMode }), + scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } + } + ); +} + +describe('createMcpHandler scope preflight', () => { + it('passes the full parsed request and auth info to an async callback before invocation', async () => { + const callback = vi.fn(async ({ request, authInfo }) => { + const args = (request.params as { arguments: { mode?: string } }).arguments; + return args.mode === 'write' && !authInfo?.scopes.includes('repo:write') ? { scopes: ['repo:write'] } : undefined; + }); + const onCall = vi.fn(); + const handler = createHandler(callback, onCall); + const incoming = call('operate', { mode: 'write', secret: 'high-cardinality-value' }); + + expect([...incoming.headers.keys()]).not.toContain('mcp-param-secret'); + const response = await handler.fetch(incoming, { authInfo: auth(['repo:read']) }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain('scope="repo:write"'); + expect(response.headers.get('WWW-Authenticate')).not.toContain('error_description'); + expect(callback).toHaveBeenCalledWith({ + request: expect.objectContaining({ + method: 'tools/call', + params: expect.objectContaining({ + arguments: { mode: 'write', secret: 'high-cardinality-value' } + }) + }), + authInfo: auth(['repo:read']) + }); + expect(onCall).not.toHaveBeenCalled(); + }); + + it('runs the callback after Mcp-Param header/body parity checks', async () => { + const callback = vi.fn(() => ({ scopes: ['route:read'] })); + const routeSchema = fromJsonSchema<{ region: string }>({ + type: 'object', + properties: { region: { type: 'string', 'x-mcp-header': 'Region' } as Record }, + required: ['region'] + }); + const handler = createMcpHandler( + () => { + const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); + server.registerTool('route', { inputSchema: routeSchema, scopeChallenge: callback }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + return server; + }, + { scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } } + ); + + const response = await handler.fetch(call('route', { region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' }), { + authInfo: auth([]) + }); + + expect(response.status).toBe(400); + expect(((await response.json()) as { error: { code: number } }).error.code).toBe(-32_020); + expect(callback).not.toHaveBeenCalled(); + }); + + it('keeps challenged tools discoverable and uses exact static all-of checks', async () => { + const handler = createMcpHandler( + () => { + const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); + server.registerTool('scoped', { scopeChallenge: requireScopes('repo:read', 'org:read') }, async () => ({ + content: [] + })); + return server; + }, + { scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } } + ); + + const listResponse = await handler.fetch(request('tools/list', {}), { authInfo: auth([]) }); + expect(listResponse.status).toBe(200); + const body = (await listResponse.json()) as { result: { tools: Array<{ name: string }> } }; + expect(body.result.tools.map(tool => tool.name)).toContain('scoped'); + + const challengeResponse = await handler.fetch(call('scoped', {}), { authInfo: auth(['repo:read']) }); + expect(challengeResponse.status).toBe(403); + expect(challengeResponse.headers.get('WWW-Authenticate')).toContain('scope="repo:read org:read"'); + }); + + it('fails closed before SSE when the callback throws', async () => { + const onCall = vi.fn(); + const handler = createHandler( + async () => { + throw new Error('scope lookup failed'); + }, + onCall, + 'sse' + ); + + const response = await handler.fetch(call('operate', {}), { authInfo: auth([]) }); + + expect(response.status).toBe(500); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(onCall).not.toHaveBeenCalled(); + }); + + it('continues when the callback returns undefined', async () => { + const callback = vi.fn(({ request }: { request: JSONRPCRequest }) => { + const mode = (request.params as { arguments?: { mode?: unknown } }).arguments?.mode; + return mode === 'write' ? { scopes: ['repo:write'] } : undefined; + }); + const onCall = vi.fn(); + const handler = createHandler(callback, onCall); + + const response = await handler.fetch(call('operate', { mode: 'read' }), { authInfo: auth([]) }); + + expect(response.status).toBe(200); + expect(onCall).toHaveBeenCalledOnce(); + }); +}); From f0f73ee66b9bb83c120d70966f938b132157a5fd Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 31 Aug 2026 19:49:20 +0200 Subject: [PATCH 2/7] feat(server): extend scope challenges to all primitives Route request-time OAuth challenges for tools, static and templated resources, and prompts across both modern and legacy HTTP serving. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/scope-challenge-server.md | 11 +- docs/serving/authorization.md | 24 ++- .../guides/serving/authorization.examples.ts | 12 +- .../server/src/server/createMcpHandler.ts | 22 +- packages/server/src/server/mcp.ts | 103 +++++++-- packages/server/src/server/scopeChallenge.ts | 11 +- .../server/test/server/scopeChallenge.test.ts | 5 +- .../test/server/scopeChallengeModern.test.ts | 33 ++- .../server/scopeChallengePrimitives.test.ts | 198 ++++++++++++++++++ 9 files changed, 371 insertions(+), 48 deletions(-) create mode 100644 packages/server/test/server/scopeChallengePrimitives.test.ts diff --git a/.changeset/scope-challenge-server.md b/.changeset/scope-challenge-server.md index 6152d150e7..6c42e34560 100644 --- a/.changeset/scope-challenge-server.md +++ b/.changeset/scope-challenge-server.md @@ -3,10 +3,11 @@ '@modelcontextprotocol/node': minor --- -Add request-time OAuth scope challenges for tools. A tool's `scopeChallenge` -callback receives the parsed request and verified authentication info, then -either continues or returns the exact scope set for an `insufficient_scope` -response. `requireScopes` provides a small helper for static all-of checks. +Add request-time OAuth scope challenges for tools, resources, resource templates, +and prompts. Each primitive's `scopeChallenge` callback receives the parsed +request and verified authentication info, then either continues or returns the +exact scope set for an `insufficient_scope` response. `requireScopes` provides a +small helper for static all-of checks. `createMcpHandler` and Streamable HTTP transports return HTTP 403 with an -`insufficient_scope` challenge before tool execution or SSE setup. +`insufficient_scope` challenge before handler execution or SSE setup. diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 3225119a34..18d8f6ad95 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -1,6 +1,6 @@ --- shape: how-to -description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-tool scopes.' +description: 'Require a bearer token on a server you run: verification, protected-resource metadata, and per-operation scopes.' --- # Require authorization @@ -123,17 +123,25 @@ server.registerTool('whoami', { description: 'Report the authenticated caller' } The per-request factory itself receives the same value as `ctx.authInfo`, so it can register a different tool set per caller before any handler runs. ::: -## Enforce per-tool scopes +## Enforce per-operation scopes -`requiredScopes` gates the whole endpoint. For scope step-up on an individual tool, set its `scopeChallenge` callback 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. +`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. Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request: -```ts source="../../examples/guides/serving/authorization.examples.ts#perToolScopes_challenge" +```ts source="../../examples/guides/serving/authorization.examples.ts#perOperationScopes_challenge" server.registerTool('purge-notes', { scopeChallenge: requireScopes('notes:write') }, async () => ({ content: [{ type: 'text', text: 'All notes deleted' }] })); +server.registerResource('private-notes', 'notes://private', { scopeChallenge: requireScopes('notes:read') }, async uri => ({ + contents: [{ uri: uri.href, text: 'Private notes' }] +})); + +server.registerPrompt('summarize-notes', { scopeChallenge: requireScopes('notes:read') }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Summarize my private notes' } }] +})); + server.registerTool( 'read-repository', { @@ -152,7 +160,11 @@ server.registerTool( ); ``` -Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged tools remain visible in `tools/list`. +Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged primitives remain visible in their list operations. + +::: warning +The callback runs before the primitive's input schema is validated or transformed. Its `request` contains the JSON-parsed wire values, so dynamic authorization should validate or canonicalize any value whose schema changes its meaning before handler invocation. Scope names and `errorDescription` must use printable ASCII so they can be serialized safely in `WWW-Authenticate`. +::: ## Recap @@ -160,5 +172,5 @@ Scope interpretation belongs to your callback; the SDK does not infer hierarchie - `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens. - 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. - `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata. -- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-tool callbacks can trigger HTTP `403` scope step-up before invocation. +- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation. - The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`. diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts index 97c62e63d2..74c69405b9 100644 --- a/examples/guides/serving/authorization.examples.ts +++ b/examples/guides/serving/authorization.examples.ts @@ -79,11 +79,19 @@ function buildServer(): McpServer { }); //#endregion authInfo_handler - //#region perToolScopes_challenge + //#region perOperationScopes_challenge server.registerTool('purge-notes', { scopeChallenge: requireScopes('notes:write') }, async () => ({ content: [{ type: 'text', text: 'All notes deleted' }] })); + server.registerResource('private-notes', 'notes://private', { scopeChallenge: requireScopes('notes:read') }, async uri => ({ + contents: [{ uri: uri.href, text: 'Private notes' }] + })); + + server.registerPrompt('summarize-notes', { scopeChallenge: requireScopes('notes:read') }, async () => ({ + messages: [{ role: 'user', content: { type: 'text', text: 'Summarize my private notes' } }] + })); + server.registerTool( 'read-repository', { @@ -100,7 +108,7 @@ function buildServer(): McpServer { }, async ({ visibility }) => ({ content: [{ type: 'text', text: `Read ${visibility} repository` }] }) ); - //#endregion perToolScopes_challenge + //#endregion perOperationScopes_challenge return server; } diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 6b82396d5d..266f2f2e27 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -837,20 +837,20 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } } } + } - // Run scope preflight after Mcp-Param headers have been checked against the body. - if (options.scopeChallenge !== undefined) { - try { - const result = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); - if (result !== undefined) { - void product.close().catch(reportError); - return createScopeChallengeResponse(options.scopeChallenge, result.challenge, result.requestId); - } - } catch (error) { + // Run scope preflight after Mcp-Param headers have been checked against the body. + if (route.messageKind === 'request' && product instanceof McpServer && options.scopeChallenge !== undefined) { + try { + const result = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); + if (result !== undefined) { void product.close().catch(reportError); - reportError(toError(error)); - return internalServerErrorResponse(route.message.id); + return createScopeChallengeResponse(options.scopeChallenge, result.challenge, result.requestId); } + } catch (error) { + void product.close().catch(reportError); + reportError(toError(error)); + return internalServerErrorResponse(route.message.id); } } diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d5508f3df3..70a5539bfb 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -163,12 +163,49 @@ export class McpServer { /** @internal */ resolveScopeChallenge: ScopeChallengeHandler = context => { - if (context.request.method !== 'tools/call') return; - const toolName = (context.request.params as { name?: unknown } | undefined)?.name; - if (typeof toolName !== 'string') return; - const tool = this._registeredTools[toolName]; - if (tool === undefined || !tool.enabled) return; - return tool.scopeChallenge?.(context); + switch (context.request.method) { + case 'tools/call': { + const toolName = (context.request.params as { name?: unknown } | undefined)?.name; + if (typeof toolName !== 'string') return; + const tool = this._registeredTools[toolName]; + if (tool === undefined || !tool.enabled) return; + return tool.scopeChallenge?.(context); + } + case 'resources/read': { + const resourceUri = (context.request.params as { uri?: unknown } | undefined)?.uri; + if (typeof resourceUri !== 'string') return; + + let uri: URL; + try { + uri = new URL(resourceUri); + } catch { + return; + } + + const resource = this._registeredResources[uri.toString()]; + if (resource !== undefined) { + return resource.enabled ? resource.scopeChallenge?.(context) : undefined; + } + + for (const template of Object.values(this._registeredResourceTemplates)) { + const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); + if (variables) { + return template.enabled ? template.scopeChallenge?.(context) : undefined; + } + } + return; + } + case 'prompts/get': { + const promptName = (context.request.params as { name?: unknown } | undefined)?.name; + if (typeof promptName !== 'string') return; + const prompt = this._registeredPrompts[promptName]; + if (prompt === undefined || !prompt.enabled) return; + return prompt.scopeChallenge?.(context); + } + default: { + return; + } + } }; private _toolHandlersInitialized = false; @@ -517,6 +554,12 @@ export class McpServer { for (const template of Object.values(this._registeredResourceTemplates)) { const variables = template.resourceTemplate.uriTemplate.match(uri.toString()); if (variables) { + if (!template.enabled) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Resource template ${template.resourceTemplate.uriTemplate} disabled` + ); + } return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint); } } @@ -603,31 +646,27 @@ export class McpServer { registerResource( name: string, uriOrTemplate: string, - config: ResourceMetadata & { cacheHint?: CacheHint }, + config: ResourceMetadata & { cacheHint?: CacheHint; scopeChallenge?: ScopeChallengeHandler }, readCallback: ReadResourceCallback ): RegisteredResource; registerResource( name: string, uriOrTemplate: ResourceTemplate, - config: ResourceMetadata & { cacheHint?: CacheHint }, + config: ResourceMetadata & { cacheHint?: CacheHint; scopeChallenge?: ScopeChallengeHandler }, readCallback: ReadResourceTemplateCallback ): RegisteredResourceTemplate; registerResource( name: string, uriOrTemplate: string | ResourceTemplate, - config: ResourceMetadata & { cacheHint?: CacheHint }, + config: ResourceMetadata & { cacheHint?: CacheHint; scopeChallenge?: ScopeChallengeHandler }, readCallback: ReadResourceCallback | ReadResourceTemplateCallback ): RegisteredResource | RegisteredResourceTemplate { - // The cache hint configures the encode-time cache fields of this - // resource's `resources/read` results (2026-07-28); it is not resource - // metadata and never appears on `resources/list` entries. - const cacheHint = config.cacheHint; - let metadata: ResourceMetadata = config; + // These options configure request handling and are not advertised as + // resource metadata by `resources/list`. + const { cacheHint, scopeChallenge, ...resourceMetadata } = config; + const metadata: ResourceMetadata = resourceMetadata; if (cacheHint !== undefined) { assertValidCacheHint(cacheHint, `resource ${name}`); - const rest = { ...config }; - delete rest.cacheHint; - metadata = rest; } if (typeof uriOrTemplate === 'string') { @@ -640,6 +679,7 @@ export class McpServer { (config as BaseMetadata).title, uriOrTemplate, metadata, + scopeChallenge, readCallback as ReadResourceCallback ); if (cacheHint !== undefined) { @@ -659,6 +699,7 @@ export class McpServer { (config as BaseMetadata).title, uriOrTemplate, metadata, + scopeChallenge, readCallback as ReadResourceTemplateCallback ); if (cacheHint !== undefined) { @@ -676,6 +717,7 @@ export class McpServer { title: string | undefined, uri: string, metadata: ResourceMetadata | undefined, + scopeChallenge: ScopeChallengeHandler | undefined, readCallback: ReadResourceCallback ): RegisteredResource { const registeredResource: RegisteredResource = { @@ -683,6 +725,7 @@ export class McpServer { title, metadata, readCallback, + scopeChallenge, enabled: true, disable: () => registeredResource.update({ enabled: false }), enable: () => registeredResource.update({ enabled: true }), @@ -696,6 +739,9 @@ export class McpServer { if (updates.title !== undefined) registeredResource.title = updates.title; if (updates.metadata !== undefined) registeredResource.metadata = updates.metadata; if (updates.callback !== undefined) registeredResource.readCallback = updates.callback; + if (updates.scopeChallenge !== undefined) { + registeredResource.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge; + } if (updates.enabled !== undefined) registeredResource.enabled = updates.enabled; this.sendResourceListChanged(); } @@ -709,6 +755,7 @@ export class McpServer { title: string | undefined, template: ResourceTemplate, metadata: ResourceMetadata | undefined, + scopeChallenge: ScopeChallengeHandler | undefined, readCallback: ReadResourceTemplateCallback ): RegisteredResourceTemplate { const registeredResourceTemplate: RegisteredResourceTemplate = { @@ -716,6 +763,7 @@ export class McpServer { title, metadata, readCallback, + scopeChallenge, enabled: true, disable: () => registeredResourceTemplate.update({ enabled: false }), enable: () => registeredResourceTemplate.update({ enabled: true }), @@ -729,6 +777,9 @@ export class McpServer { if (updates.template !== undefined) registeredResourceTemplate.resourceTemplate = updates.template; if (updates.metadata !== undefined) registeredResourceTemplate.metadata = updates.metadata; if (updates.callback !== undefined) registeredResourceTemplate.readCallback = updates.callback; + if (updates.scopeChallenge !== undefined) { + registeredResourceTemplate.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge; + } if (updates.enabled !== undefined) registeredResourceTemplate.enabled = updates.enabled; this.sendResourceListChanged(); } @@ -752,6 +803,7 @@ export class McpServer { argsSchema: StandardSchemaWithJSON | undefined, callback: PromptCallback, icons: Icon[] | undefined, + scopeChallenge: ScopeChallengeHandler | undefined, _meta: Record | undefined ): RegisteredPrompt { // Track current schema and callback for handler regeneration @@ -763,6 +815,7 @@ export class McpServer { description, argsSchema, icons, + scopeChallenge, _meta, handler: createPromptHandler(name, argsSchema, callback), enabled: true, @@ -777,6 +830,9 @@ export class McpServer { if (updates.title !== undefined) registeredPrompt.title = updates.title; if (updates.description !== undefined) registeredPrompt.description = updates.description; if (updates.icons !== undefined) registeredPrompt.icons = updates.icons; + if (updates.scopeChallenge !== undefined) { + registeredPrompt.scopeChallenge = updates.scopeChallenge === null ? undefined : updates.scopeChallenge; + } if (updates._meta !== undefined) registeredPrompt._meta = updates._meta; // Track if we need to regenerate the handler @@ -1067,6 +1123,8 @@ export class McpServer { description?: string; argsSchema?: Args; icons?: Icon[]; + /** Determines whether this prompt retrieval needs an OAuth scope challenge. */ + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: PromptCallback @@ -1079,6 +1137,7 @@ export class McpServer { description?: string; argsSchema?: Args; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: LegacyPromptCallback @@ -1090,6 +1149,7 @@ export class McpServer { description?: string; argsSchema?: StandardSchemaWithJSON | ZodRawShape; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: PromptCallback | LegacyPromptCallback @@ -1098,7 +1158,7 @@ export class McpServer { throw new Error(`Prompt ${name} is already registered`); } - const { title, description, argsSchema, icons, _meta } = config; + const { title, description, argsSchema, icons, scopeChallenge, _meta } = config; const registeredPrompt = this._createRegisteredPrompt( name, @@ -1107,6 +1167,7 @@ export class McpServer { normalizeRawShapeSchema(argsSchema), cb as PromptCallback, icons, + scopeChallenge, _meta ); @@ -1391,6 +1452,7 @@ export type RegisteredResource = { metadata?: ResourceMetadata; /** Cache hint applied to this resource's `resources/read` results on the 2026-07-28 revision. */ cacheHint?: CacheHint; + scopeChallenge?: ScopeChallengeHandler; readCallback: ReadResourceCallback; enabled: boolean; enable(): void; @@ -1400,6 +1462,7 @@ export type RegisteredResource = { title?: string; uri?: string | null; metadata?: ResourceMetadata; + scopeChallenge?: ScopeChallengeHandler | null; callback?: ReadResourceCallback; enabled?: boolean; }): void; @@ -1421,6 +1484,7 @@ export type RegisteredResourceTemplate = { metadata?: ResourceMetadata; /** Cache hint applied to this template's `resources/read` results on the 2026-07-28 revision. */ cacheHint?: CacheHint; + scopeChallenge?: ScopeChallengeHandler; readCallback: ReadResourceTemplateCallback; enabled: boolean; enable(): void; @@ -1430,6 +1494,7 @@ export type RegisteredResourceTemplate = { title?: string; template?: ResourceTemplate; metadata?: ResourceMetadata; + scopeChallenge?: ScopeChallengeHandler | null; callback?: ReadResourceTemplateCallback; enabled?: boolean; }): void; @@ -1459,6 +1524,7 @@ export type RegisteredPrompt = { description?: string; argsSchema?: StandardSchemaWithJSON; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; /** @hidden */ handler: PromptHandler; @@ -1471,6 +1537,7 @@ export type RegisteredPrompt = { description?: string; argsSchema?: Args; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler | null; _meta?: Record; callback?: PromptCallback; enabled?: boolean; diff --git a/packages/server/src/server/scopeChallenge.ts b/packages/server/src/server/scopeChallenge.ts index 27fad701ce..b73d4beeb7 100644 --- a/packages/server/src/server/scopeChallenge.ts +++ b/packages/server/src/server/scopeChallenge.ts @@ -2,9 +2,9 @@ import type { AuthInfo, JSONRPCRequest, RequestId } from '@modelcontextprotocol/ /** OAuth scopes to request before handling an MCP request. */ export interface ScopeChallenge { - /** The exact, complete scope set to include in the challenge. */ + /** The exact, complete scope set to include in the challenge. Each scope must be printable ASCII without whitespace. */ scopes: readonly [string, ...string[]]; - /** Optional human-readable detail for the OAuth challenge. */ + /** Optional printable-ASCII human-readable detail for the OAuth challenge. */ errorDescription?: string; } @@ -33,8 +33,8 @@ export function supportsScopeChallengeResolver( } function assertScope(scope: unknown, location: string): asserts scope is string { - if (typeof scope !== 'string' || scope.length === 0 || /\s/.test(scope)) { - throw new TypeError(`${location} must be a non-empty OAuth scope without whitespace`); + if (typeof scope !== 'string' || !/^[\u0021-\u007E]+$/.test(scope)) { + throw new TypeError(`${location} must be a non-empty printable ASCII OAuth scope without whitespace`); } } @@ -48,6 +48,9 @@ function validateScopeChallenge(challenge: ScopeChallenge): ScopeChallenge { if (challenge.errorDescription !== undefined && typeof challenge.errorDescription !== 'string') { throw new TypeError('scope challenge errorDescription must be a string'); } + if (challenge.errorDescription !== undefined && !/^[\u0020-\u007E]*$/.test(challenge.errorDescription)) { + throw new TypeError('scope challenge errorDescription must contain only printable ASCII characters'); + } return challenge; } diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index 529c2af143..e2fa55adfc 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -45,6 +45,7 @@ describe('requireScopes', () => { it('rejects invalid static scope declarations', () => { expect(() => (requireScopes as (...scopes: string[]) => ScopeChallengeHandler)()).toThrow('at least one'); expect(() => requireScopes('repo read')).toThrow('without whitespace'); + expect(() => requireScopes('repo:read🚀')).toThrow('printable ASCII'); }); }); @@ -169,7 +170,9 @@ describe('legacy Streamable HTTP scope preflight', () => { vi.fn(async () => { throw new Error('scope lookup failed'); }), - vi.fn(() => ({ scopes: [] as unknown as [string, ...string[]] })) + vi.fn(() => ({ scopes: [] as unknown as [string, ...string[]] })), + vi.fn(() => ({ scopes: ['repo:read🚀'] })), + vi.fn(() => ({ scopes: ['repo:read'], errorDescription: 'Need 🚀 access' })) ]) { const harness = await createLegacyHarness(callback); const onerror = vi.fn(); diff --git a/packages/server/test/server/scopeChallengeModern.test.ts b/packages/server/test/server/scopeChallengeModern.test.ts index f50acae3a3..7b962cf880 100644 --- a/packages/server/test/server/scopeChallengeModern.test.ts +++ b/packages/server/test/server/scopeChallengeModern.test.ts @@ -18,7 +18,8 @@ const ENVELOPE = { }; function request(method: string, params: Record, extraHeaders: Record = {}): Request { - const name = typeof params.name === 'string' ? params.name : undefined; + const candidateName = method === 'resources/read' ? params.uri : params.name; + const name = typeof candidateName === 'string' ? candidateName : undefined; return new Request('http://localhost/mcp', { method: 'POST', headers: { @@ -172,4 +173,34 @@ describe('createMcpHandler scope preflight', () => { expect(response.status).toBe(200); expect(onCall).toHaveBeenCalledOnce(); }); + + it('challenges resource and prompt primitives before dispatch', async () => { + const onRead = vi.fn(async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'secret' }] })); + const onPrompt = vi.fn(async () => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'secret' } }] + })); + const handler = createMcpHandler( + () => { + const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); + server.registerResource('config', 'config://settings', { scopeChallenge: requireScopes('config:read') }, onRead); + server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, onPrompt); + return server; + }, + { scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } } + ); + + const resourceResponse = await handler.fetch(request('resources/read', { uri: 'config://settings' }), { + authInfo: auth([]) + }); + const promptResponse = await handler.fetch(request('prompts/get', { name: 'summarize', arguments: {} }), { + authInfo: auth([]) + }); + + expect(resourceResponse.status).toBe(403); + expect(resourceResponse.headers.get('WWW-Authenticate')).toContain('scope="config:read"'); + expect(promptResponse.status).toBe(403); + expect(promptResponse.headers.get('WWW-Authenticate')).toContain('scope="prompt:read"'); + expect(onRead).not.toHaveBeenCalled(); + expect(onPrompt).not.toHaveBeenCalled(); + }); }); diff --git a/packages/server/test/server/scopeChallengePrimitives.test.ts b/packages/server/test/server/scopeChallengePrimitives.test.ts new file mode 100644 index 0000000000..6d87561b4d --- /dev/null +++ b/packages/server/test/server/scopeChallengePrimitives.test.ts @@ -0,0 +1,198 @@ +import { randomUUID } from 'node:crypto'; + +import type { AuthInfo, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; + +import { McpServer, ResourceTemplate } from '../../src/server/mcp'; +import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge'; +import { requireScopes } from '../../src/server/scopeChallenge'; +import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp'; + +const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource'; + +function auth(scopes: string[]): AuthInfo { + return { token: 'token', clientId: 'client', scopes }; +} + +function readResource(uri: string, id: string | number = 'read-1'): JSONRPCRequest { + return { jsonrpc: '2.0', method: 'resources/read', params: { uri }, id }; +} + +function getPrompt(name: string, id: string | number = 'prompt-1'): JSONRPCRequest { + return { jsonrpc: '2.0', method: 'prompts/get', params: { name, arguments: {} }, id }; +} + +function request(body: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + ...(sessionId !== undefined && { + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-11-25' + }) + }, + body: JSON.stringify(body) + }); +} + +async function initialize(transport: WebStandardStreamableHTTPServerTransport): Promise { + const response = await transport.handleRequest( + request({ + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-11-25', + capabilities: {} + }, + id: 'init' + }) + ); + return response.headers.get('mcp-session-id')!; +} + +async function createHarness(server: McpServer): Promise<{ + transport: WebStandardStreamableHTTPServerTransport; + sessionId: string; +}> { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true, + scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } + }); + await server.connect(transport); + return { transport, sessionId: await initialize(transport) }; +} + +describe('scope challenges for resources and prompts', () => { + it('challenges static resources and prompts before their handlers run', async () => { + const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' }); + const read = vi.fn(async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'secret' }] })); + const render = vi.fn(async () => ({ + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'secret' } }] + })); + server.registerResource( + 'config', + 'config://settings', + { mimeType: 'text/plain', scopeChallenge: requireScopes('config:read') }, + read + ); + server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, render); + const { transport, sessionId } = await createHarness(server); + + const resourceResponse = await transport.handleRequest(request(readResource('config://settings'), sessionId), { + authInfo: auth([]) + }); + const promptResponse = await transport.handleRequest(request(getPrompt('summarize'), sessionId), { + authInfo: auth([]) + }); + + expect(resourceResponse.status).toBe(403); + expect(resourceResponse.headers.get('WWW-Authenticate')).toContain('scope="config:read"'); + expect(promptResponse.status).toBe(403); + expect(promptResponse.headers.get('WWW-Authenticate')).toContain('scope="prompt:read"'); + expect(read).not.toHaveBeenCalled(); + expect(render).not.toHaveBeenCalled(); + await transport.close(); + }); + + it('routes a template resource request to its request-aware callback', async () => { + const callback = vi.fn(({ request: incoming, authInfo }) => { + const uri = (incoming.params as { uri?: unknown }).uri; + const scopes = typeof uri === 'string' && uri.includes('/private/') ? (['repo:read'] as const) : (['public_repo'] as const); + return scopes.every(scope => authInfo?.scopes.includes(scope)) ? undefined : { scopes }; + }); + const read = vi.fn(async (uri: URL) => ({ contents: [{ uri: uri.href, text: 'repository' }] })); + const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' }); + server.registerResource( + 'repository', + new ResourceTemplate('github://{owner}/{visibility}/{repo}', { list: undefined }), + { scopeChallenge: callback }, + read + ); + const { transport, sessionId } = await createHarness(server); + + const privateResponse = await transport.handleRequest(request(readResource('github://octo/private/sdk'), sessionId), { + authInfo: auth(['public_repo']) + }); + const publicResponse = await transport.handleRequest(request(readResource('github://octo/public/sdk'), sessionId), { + authInfo: auth(['public_repo']) + }); + + expect(privateResponse.status).toBe(403); + expect(privateResponse.headers.get('WWW-Authenticate')).toContain('scope="repo:read"'); + expect(publicResponse.status).toBe(200); + expect(callback).toHaveBeenCalledWith({ + request: expect.objectContaining({ + method: 'resources/read', + params: { uri: 'github://octo/private/sdk' } + }), + authInfo: auth(['public_repo']) + }); + expect(read).toHaveBeenCalledOnce(); + await transport.close(); + }); + + it('tracks callback updates and enabled state for every primitive', async () => { + const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' }); + const resource = server.registerResource( + 'config', + 'config://settings', + { scopeChallenge: requireScopes('config:read') }, + async uri => ({ contents: [{ uri: uri.href, text: 'config' }] }) + ); + const template = server.registerResource( + 'repository', + new ResourceTemplate('github://{owner}/{repo}', { list: undefined }), + { scopeChallenge: requireScopes('repo:read') }, + async uri => ({ contents: [{ uri: uri.href, text: 'repository' }] }) + ); + const prompt = server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, async () => ({ + messages: [] + })); + + expect(await server.resolveScopeChallenge({ request: readResource('config://settings'), authInfo: auth([]) })).toEqual({ + scopes: ['config:read'] + }); + expect(await server.resolveScopeChallenge({ request: readResource('github://octo/sdk'), authInfo: auth([]) })).toEqual({ + scopes: ['repo:read'] + }); + expect(await server.resolveScopeChallenge({ request: getPrompt('summarize'), authInfo: auth([]) })).toEqual({ + scopes: ['prompt:read'] + }); + + resource.update({ scopeChallenge: requireScopes('config:admin') }); + template.disable(); + prompt.update({ scopeChallenge: null }); + + expect(await server.resolveScopeChallenge({ request: readResource('config://settings'), authInfo: auth([]) })).toEqual({ + scopes: ['config:admin'] + }); + expect(await server.resolveScopeChallenge({ request: readResource('github://octo/sdk'), authInfo: auth([]) })).toBeUndefined(); + expect(await server.resolveScopeChallenge({ request: getPrompt('summarize'), authInfo: auth([]) })).toBeUndefined(); + }); + + it('leaves malformed and non-invocation requests to normal protocol handling', async () => { + const callback = vi.fn(requireScopes('resource:read')); + const server = new McpServer({ name: 'scope-primitives', version: '1.0.0' }); + server.registerResource('config', 'config://settings', { scopeChallenge: callback }, async uri => ({ + contents: [{ uri: uri.href, text: 'config' }] + })); + + expect( + await server.resolveScopeChallenge({ + request: readResource('not a valid URI'), + authInfo: auth([]) + }) + ).toBeUndefined(); + expect( + await server.resolveScopeChallenge({ + request: { jsonrpc: '2.0', method: 'resources/list', params: {}, id: 'list' }, + authInfo: auth([]) + }) + ).toBeUndefined(); + expect(callback).not.toHaveBeenCalled(); + }); +}); From 0e5cbd8c974e6cb321d60178e808a4b69171457d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 31 Aug 2026 20:02:53 +0200 Subject: [PATCH 3/7] test(conformance): add scope challenge fixture Expose portable low- and full-scope tokens over the existing tool, resource, template, and prompt fixtures for the official SEP-2350 scenario. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/conformance/src/everythingServer.ts | 169 +++++++++++++++-------- 1 file changed, 109 insertions(+), 60 deletions(-) diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 425b4d6647..ff5cc13512 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -12,12 +12,14 @@ import { randomUUID } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; import type { + AuthInfo, CallToolResult, EventId, EventStore, GetPromptResult, InputRequests, InputRequiredResult, + JSONRPCRequest, ReadResourceResult, ServerContext, StreamId @@ -45,6 +47,28 @@ import * as z from 'zod/v4'; const resourceSubscriptions = new Set(); const watchedResourceContent = 'Watched resource content'; +const SCOPE_CHALLENGE_LOW_TOKEN = 'mcp-conformance-scope-low'; +const SCOPE_CHALLENGE_FULL_TOKEN = 'mcp-conformance-scope-full'; +const SCOPE_CHALLENGE_BASELINE_SCOPE = 'mcp:conformance:baseline'; +const SCOPE_CHALLENGE_SCOPES = { + tool: ['mcp:conformance:tools:call', 'mcp:conformance:tools:test_simple_text'], + staticResource: ['mcp:conformance:resources:read', 'mcp:conformance:resources:static'], + templateResource: ['mcp:conformance:resources:read', 'mcp:conformance:resources:template:123'], + prompt: ['mcp:conformance:prompts:get', 'mcp:conformance:prompts:test_simple_prompt'] +} as const; + +type ConformanceScopeChallengeHandler = (context: { + request: JSONRPCRequest; + authInfo?: AuthInfo; +}) => { scopes: readonly [string, ...string[]] } | undefined; + +function requireConformanceScopes(...scopes: readonly [string, ...string[]]): ConformanceScopeChallengeHandler { + return ({ authInfo }) => { + if (authInfo === undefined || scopes.every(scope => authInfo.scopes.includes(scope))) return; + return { scopes }; + }; +} + // Session management const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {}; const servers: { [sessionId: string]: McpServer } = {}; @@ -244,17 +268,15 @@ function createMcpServer() { ); // Simple text tool - mcpServer.registerTool( - 'test_simple_text', - { - description: 'Tests simple text content response' - }, - async (): Promise => { - return { - content: [{ type: 'text', text: 'This is a simple text response for testing.' }] - }; - } - ); + const simpleTextToolConfig = { + description: 'Tests simple text content response', + scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.tool) + }; + mcpServer.registerTool('test_simple_text', simpleTextToolConfig, async (): Promise => { + return { + content: [{ type: 'text', text: 'This is a simple text response for testing.' }] + }; + }); // Image content tool mcpServer.registerTool( @@ -1092,26 +1114,23 @@ function createMcpServer() { // ===== RESOURCES ===== // Static text resource - mcpServer.registerResource( - 'static-text', - 'test://static-text', - { - title: 'Static Text Resource', - description: 'A static text resource for testing', - mimeType: 'text/plain' - }, - async (): Promise => { - return { - contents: [ - { - uri: 'test://static-text', - mimeType: 'text/plain', - text: 'This is the content of the static text resource.' - } - ] - }; - } - ); + const staticTextResourceConfig = { + title: 'Static Text Resource', + description: 'A static text resource for testing', + mimeType: 'text/plain', + scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.staticResource) + }; + mcpServer.registerResource('static-text', 'test://static-text', staticTextResourceConfig, async (): Promise => { + return { + contents: [ + { + uri: 'test://static-text', + mimeType: 'text/plain', + text: 'This is the content of the static text resource.' + } + ] + }; + }); // Static binary resource mcpServer.registerResource( @@ -1136,14 +1155,16 @@ function createMcpServer() { ); // Resource template + const resourceTemplateConfig = { + title: 'Resource Template', + description: 'A resource template with parameter substitution', + mimeType: 'application/json', + scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.templateResource) + }; mcpServer.registerResource( 'template', new ResourceTemplate('test://template/{id}/data', { list: undefined }), - { - title: 'Resource Template', - description: 'A resource template with parameter substitution', - mimeType: 'application/json' - }, + resourceTemplateConfig, async (uri, variables): Promise => { const id = variables.id; return { @@ -1202,26 +1223,24 @@ function createMcpServer() { // ===== PROMPTS ===== // Simple prompt - mcpServer.registerPrompt( - 'test_simple_prompt', - { - title: 'Simple Test Prompt', - description: 'A simple prompt without arguments' - }, - async (): Promise => { - return { - messages: [ - { - role: 'user', - content: { - type: 'text', - text: 'This is a simple prompt for testing.' - } + const simplePromptConfig = { + title: 'Simple Test Prompt', + description: 'A simple prompt without arguments', + scopeChallenge: requireConformanceScopes(...SCOPE_CHALLENGE_SCOPES.prompt) + }; + mcpServer.registerPrompt('test_simple_prompt', simplePromptConfig, async (): Promise => { + return { + messages: [ + { + role: 'user', + content: { + type: 'text', + text: 'This is a simple prompt for testing.' } - ] - }; - } - ); + } + ] + }; + }); // Prompt with arguments mcpServer.registerPrompt( @@ -1386,9 +1405,13 @@ function createMcpServer() { // `createMcpServer()` fixture definition the 2025 sessions use. Legacy traffic // never reaches this handler (see the routing in the POST handler below), so // the 2025 stateful session path is unchanged. -const modernHandler = createMcpHandler(() => createMcpServer(), { - onerror: error => console.error('Modern-era MCP handler error:', error) -}); +const PORT = process.env.PORT || 3000; +const scopeChallengeResourceMetadataUrl = `http://localhost:${PORT}/.well-known/oauth-protected-resource/mcp`; +const modernHandlerOptions = { + onerror: (error: Error) => console.error('Modern-era MCP handler error:', error), + scopeChallenge: { resourceMetadataUrl: scopeChallengeResourceMetadataUrl } +}; +const modernHandler = createMcpHandler(() => createMcpServer(), modernHandlerOptions); const modernNodeHandler = toNodeHandler(modernHandler); /** Normalize a possibly-repeated HTTP header to its first value. */ @@ -1401,6 +1424,33 @@ function headerValue(value: string | string[] | undefined): string | undefined { const app = express(); app.use(express.json()); +app.use((req, _res, next) => { + const authorization = req.header('authorization'); + const token = authorization?.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined; + if (token === SCOPE_CHALLENGE_LOW_TOKEN) { + req.auth = { + token, + clientId: 'mcp-conformance-scope-challenge', + scopes: [SCOPE_CHALLENGE_BASELINE_SCOPE] + }; + } else if (token === SCOPE_CHALLENGE_FULL_TOKEN) { + req.auth = { + token, + clientId: 'mcp-conformance-scope-challenge', + scopes: [ + SCOPE_CHALLENGE_BASELINE_SCOPE, + ...new Set([ + ...SCOPE_CHALLENGE_SCOPES.tool, + ...SCOPE_CHALLENGE_SCOPES.staticResource, + ...SCOPE_CHALLENGE_SCOPES.templateResource, + ...SCOPE_CHALLENGE_SCOPES.prompt + ]) + ] + }; + } + next(); +}); + // DNS rebinding protection: reject non-localhost Host headers app.use(localhostHostValidation()); @@ -1409,7 +1459,7 @@ app.use( cors({ origin: '*', exposedHeaders: ['Mcp-Session-Id'], - allowedHeaders: ['Content-Type', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version', 'mcp-method'] + allowedHeaders: ['Authorization', 'Content-Type', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version', 'mcp-method'] }) ); @@ -1560,7 +1610,6 @@ app.delete('/mcp', async (req: Request, res: Response) => { }); // Start server -const PORT = process.env.PORT || 3000; const httpServer = app.listen(PORT, () => { console.log(`MCP Conformance Test Server running on http://localhost:${PORT}`); console.log(` - MCP endpoint: http://localhost:${PORT}/mcp`); From c51e262f8df3c885582f51e28866f149c8ba9f0c Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 1 Sep 2026 10:12:02 +0200 Subject: [PATCH 4/7] fix(server): enforce OAuth challenge grammar Reject scope and error description values that RFC 6749/6750 cannot represent in a Bearer challenge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/serving/authorization.md | 2 +- packages/server/src/server/scopeChallenge.ts | 12 ++++++------ packages/server/test/server/scopeChallenge.test.ts | 14 +++++++++----- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 18d8f6ad95..97bf4073de 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -163,7 +163,7 @@ server.registerTool( Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged primitives remain visible in their list operations. ::: warning -The callback runs before the primitive's input schema is validated or transformed. Its `request` contains the JSON-parsed wire values, so dynamic authorization should validate or canonicalize any value whose schema changes its meaning before handler invocation. Scope names and `errorDescription` must use printable ASCII so they can be serialized safely in `WWW-Authenticate`. +The callback runs before the primitive's input schema is validated or transformed. Its `request` contains the JSON-parsed wire values, so dynamic authorization should validate or canonicalize any value whose schema changes its meaning before handler invocation. Scope names must follow the OAuth `scope-token` grammar; `errorDescription`, when provided, must follow RFC 6750's `error-description` grammar. ::: ## Recap diff --git a/packages/server/src/server/scopeChallenge.ts b/packages/server/src/server/scopeChallenge.ts index b73d4beeb7..ba10990405 100644 --- a/packages/server/src/server/scopeChallenge.ts +++ b/packages/server/src/server/scopeChallenge.ts @@ -2,9 +2,9 @@ import type { AuthInfo, JSONRPCRequest, RequestId } from '@modelcontextprotocol/ /** OAuth scopes to request before handling an MCP request. */ export interface ScopeChallenge { - /** The exact, complete scope set to include in the challenge. Each scope must be printable ASCII without whitespace. */ + /** The exact, complete scope set to include in the challenge. Each scope must satisfy the OAuth `scope-token` grammar. */ scopes: readonly [string, ...string[]]; - /** Optional printable-ASCII human-readable detail for the OAuth challenge. */ + /** Optional human-readable detail satisfying the RFC 6750 `error-description` grammar. */ errorDescription?: string; } @@ -33,8 +33,8 @@ export function supportsScopeChallengeResolver( } function assertScope(scope: unknown, location: string): asserts scope is string { - if (typeof scope !== 'string' || !/^[\u0021-\u007E]+$/.test(scope)) { - throw new TypeError(`${location} must be a non-empty printable ASCII OAuth scope without whitespace`); + if (typeof scope !== 'string' || !/^[\u0021\u0023-\u005B\u005D-\u007E]+$/.test(scope)) { + throw new TypeError(`${location} must satisfy the OAuth scope-token grammar`); } } @@ -48,8 +48,8 @@ function validateScopeChallenge(challenge: ScopeChallenge): ScopeChallenge { if (challenge.errorDescription !== undefined && typeof challenge.errorDescription !== 'string') { throw new TypeError('scope challenge errorDescription must be a string'); } - if (challenge.errorDescription !== undefined && !/^[\u0020-\u007E]*$/.test(challenge.errorDescription)) { - throw new TypeError('scope challenge errorDescription must contain only printable ASCII characters'); + if (challenge.errorDescription !== undefined && !/^[\u0020-\u0021\u0023-\u005B\u005D-\u007E]+$/.test(challenge.errorDescription)) { + throw new TypeError('scope challenge errorDescription must satisfy the RFC 6750 error-description grammar'); } return challenge; } diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index e2fa55adfc..6039169638 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -44,8 +44,9 @@ describe('requireScopes', () => { it('rejects invalid static scope declarations', () => { expect(() => (requireScopes as (...scopes: string[]) => ScopeChallengeHandler)()).toThrow('at least one'); - expect(() => requireScopes('repo read')).toThrow('without whitespace'); - expect(() => requireScopes('repo:read🚀')).toThrow('printable ASCII'); + for (const scope of ['repo read', 'repo"read', String.raw`repo\read`, 'repo:read🚀']) { + expect(() => requireScopes(scope)).toThrow('scope-token grammar'); + } }); }); @@ -172,6 +173,9 @@ describe('legacy Streamable HTTP scope preflight', () => { }), vi.fn(() => ({ scopes: [] as unknown as [string, ...string[]] })), vi.fn(() => ({ scopes: ['repo:read🚀'] })), + vi.fn(() => ({ scopes: ['repo:read'], errorDescription: '' })), + vi.fn(() => ({ scopes: ['repo:read'], errorDescription: 'Need "repo:read"' })), + vi.fn(() => ({ scopes: ['repo:read'], errorDescription: String.raw`Need repo\read` })), vi.fn(() => ({ scopes: ['repo:read'], errorDescription: 'Need 🚀 access' })) ]) { const harness = await createLegacyHarness(callback); @@ -211,17 +215,17 @@ describe('legacy Streamable HTTP scope preflight', () => { expect(await server.resolveScopeChallenge({ request: toolCall('mutable'), authInfo: auth([]) })).toBeUndefined(); }); - it('escapes optional challenge auth parameters', async () => { + it('serializes an optional challenge description', async () => { const harness = await createLegacyHarness(() => ({ scopes: ['repo:read'], - errorDescription: String.raw`Needs "repo:read", path\to\thing` + errorDescription: 'Needs repo:read, path/to/thing' })); const sessionId = await initializeLegacy(harness.transport); const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), { authInfo: auth([]) }); - expect(response.headers.get('WWW-Authenticate')).toContain(String.raw`error_description="Needs \"repo:read\", path\\to\\thing"`); + expect(response.headers.get('WWW-Authenticate')).toContain('error_description="Needs repo:read, path/to/thing"'); await harness.transport.close(); }); }); From 6813ca8510583b1c9561566d68989dd8f34531c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:20:50 +0000 Subject: [PATCH 5/7] 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. --- .changeset/scope-challenge-server.md | 11 +- docs/serving/authorization.md | 14 +- .../guides/serving/authorization.examples.ts | 8 +- packages/core-internal/src/types/types.ts | 13 ++ packages/server/src/index.ts | 2 +- .../server/src/server/createMcpHandler.ts | 26 ++-- .../src/server/middleware/bearerAuth.ts | 31 +++- packages/server/src/server/scopeChallenge.ts | 48 ++++--- packages/server/src/server/streamableHttp.ts | 21 +-- .../server/test/server/scopeChallenge.test.ts | 39 ++++- .../test/server/scopeChallengeModern.test.ts | 136 +++++++++++++----- .../server/scopeChallengePrimitives.test.ts | 5 +- test/conformance/src/everythingServer.ts | 10 +- 13 files changed, 253 insertions(+), 111 deletions(-) diff --git a/.changeset/scope-challenge-server.md b/.changeset/scope-challenge-server.md index 6c42e34560..50d9242d60 100644 --- a/.changeset/scope-challenge-server.md +++ b/.changeset/scope-challenge-server.md @@ -10,4 +10,13 @@ exact scope set for an `insufficient_scope` response. `requireScopes` provides a small helper for static all-of checks. `createMcpHandler` and Streamable HTTP transports return HTTP 403 with an -`insufficient_scope` challenge before handler execution or SSE setup. +`insufficient_scope` challenge before handler execution or SSE setup. The +preflight is active whenever a registered primitive carries a `scopeChallenge` +callback — there is no handler- or transport-level configuration. The +challenge's `WWW-Authenticate` header is built by the same formatter as the +bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived +from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now +stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return +(new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the +well-known location for the token's RFC 8707 `resource` identifier; the +parameter is omitted when neither is available. diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 97bf4073de..8dcc372710 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -34,13 +34,7 @@ const auth = requireBearerAuth({ }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); -const node = toNodeHandler( - createMcpHandler(buildServer, { - scopeChallenge: { - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) - } - }) -); +const node = toNodeHandler(createMcpHandler(buildServer)); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); ``` @@ -125,7 +119,9 @@ The per-request factory itself receives the same value as `ctx.authInfo`, so it ## Enforce per-operation scopes -`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. +`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. + +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. Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request: @@ -172,5 +168,5 @@ The callback runs before the primitive's input schema is validated or transforme - `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens. - 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. - `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata. -- Verified auth flows `req.auth` → `ctx.http.authInfo`; per-operation callbacks can trigger HTTP `403` scope step-up before invocation. +- 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`. - The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`. diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts index 74c69405b9..4cffc022a0 100644 --- a/examples/guides/serving/authorization.examples.ts +++ b/examples/guides/serving/authorization.examples.ts @@ -35,13 +35,7 @@ const auth = requireBearerAuth({ }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); -const node = toNodeHandler( - createMcpHandler(buildServer, { - scopeChallenge: { - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) - } - }) -); +const node = toNodeHandler(createMcpHandler(buildServer)); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); //#endregion requireBearerAuth_basic diff --git a/packages/core-internal/src/types/types.ts b/packages/core-internal/src/types/types.ts index f2bc9d67fc..61cc7f9b36 100644 --- a/packages/core-internal/src/types/types.ts +++ b/packages/core-internal/src/types/types.ts @@ -751,6 +751,19 @@ export interface AuthInfo { */ resource?: URL; + /** + * URL of the RFC 9728 Protected Resource Metadata document for the + * resource server that accepted this token. + * + * The bearer-auth helpers stamp their configured `resourceMetadataUrl` + * here when verification succeeds, so challenge responses built after + * authentication (for example per-operation `insufficient_scope` scope + * challenges) can advertise the same document as the authentication + * gate's own challenges without separate configuration. Verifiers may + * also populate it directly; a verifier-set value wins. + */ + resourceMetadataUrl?: string; + /** * Additional data associated with the token. * This field should be used for any additional data that needs to be attached to the auth info. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index be95cc92fd..9b3196a80b 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -65,7 +65,7 @@ export { InMemoryServerEventBus } from './server/serverEventBus'; // StdioServerTransport and the serveStdio entry are exported from the './stdio' subpath — server stdio // has only type-level Node imports (erased at compile time), but matching the client's `./stdio` subpath // gives consumers a consistent shape across packages. -export type { ScopeChallenge, ScopeChallengeConfig, ScopeChallengeHandler } from './server/scopeChallenge'; +export type { ScopeChallenge, ScopeChallengeHandler } from './server/scopeChallenge'; export { requireScopes } from './server/scopeChallenge'; export type { EventId, diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 266f2f2e27..5c5a7a2920 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -64,8 +64,7 @@ import { createListenRouter, DEFAULT_MAX_SUBSCRIPTIONS } from './listenRouter'; import { McpServer } from './mcp'; import type { PerRequestResponseMode } from './perRequestTransport'; import { DEFAULT_MAX_REQUEST_BODY_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody'; -import type { ScopeChallengeConfig } from './scopeChallenge'; -import { createScopeChallengeResponse, findScopeChallenge } from './scopeChallenge'; +import { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge'; import type { Server } from './server'; import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server'; import type { ServerEventBus, ServerNotifier } from './serverEventBus'; @@ -214,8 +213,6 @@ export interface CreateMcpHandlerOptions { * @default 4194304 (4 MiB) */ maxRequestBodySize?: number; - /** Enables per-operation OAuth scope challenges. */ - scopeChallenge?: ScopeChallengeConfig; } /** @@ -327,8 +324,7 @@ function createLegacyStatelessFallback( factory: McpServerFactory, onerror?: (error: Error) => void, keepAliveMs?: number, - maxRequestBodySize?: number, - scopeChallenge?: ScopeChallengeConfig + maxRequestBodySize?: number ): LegacyHttpHandler { return async (request, options) => { if (request.method.toUpperCase() !== 'POST') { @@ -343,8 +339,7 @@ function createLegacyStatelessFallback( const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, ...(keepAliveMs !== undefined && { keepAliveMs }), - ...(maxRequestBodySize !== undefined && { maxRequestBodySize }), - ...(scopeChallenge !== undefined && { scopeChallenge }) + ...(maxRequestBodySize !== undefined && { maxRequestBodySize }) }); await product.connect(transport); @@ -721,9 +716,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // The default posture is the stateless fallback; 'reject' is the only way // to turn legacy serving off (modern-only strict). const legacyHandler: LegacyHttpHandler | undefined = - legacy === 'reject' - ? undefined - : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize, options.scopeChallenge); + legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, maxRequestBodySize); async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise { const claimedRevision = route.classification.revision; @@ -839,13 +832,18 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } } - // Run scope preflight after Mcp-Param headers have been checked against the body. - if (route.messageKind === 'request' && product instanceof McpServer && options.scopeChallenge !== undefined) { + // Run scope preflight after Mcp-Param headers have been checked against + // the body. Active whenever the factory's instance registers a + // per-primitive scopeChallenge callback — no handler-level + // configuration exists: the challenge's resource_metadata parameter is + // derived from the verified AuthInfo (stamped by the bearer-auth gate, + // or the token's RFC 8707 resource identifier) and omitted otherwise. + if (route.messageKind === 'request' && product instanceof McpServer) { try { const result = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); if (result !== undefined) { void product.close().catch(reportError); - return createScopeChallengeResponse(options.scopeChallenge, result.challenge, result.requestId); + return createScopeChallengeResponse(result.challenge, result.requestId, scopeChallengeResourceMetadataUrl(authInfo)); } } catch (error) { void product.close().catch(reportError); diff --git a/packages/server/src/server/middleware/bearerAuth.ts b/packages/server/src/server/middleware/bearerAuth.ts index 1169e21336..80c8698a41 100644 --- a/packages/server/src/server/middleware/bearerAuth.ts +++ b/packages/server/src/server/middleware/bearerAuth.ts @@ -50,6 +50,12 @@ export interface BearerAuthOptions { * * Typically built with `getOAuthProtectedResourceMetadataUrl`, exported * from this package. + * + * When verification succeeds the value is also stamped onto the returned + * {@link AuthInfo} (`authInfo.resourceMetadataUrl`, unless the verifier + * already set one), so challenges built after authentication — such as + * per-operation `insufficient_scope` scope challenges — advertise the + * same document without being configured separately. */ resourceMetadataUrl?: string; } @@ -62,18 +68,27 @@ function headerQuotedValue(value: string): string { return value.replaceAll(/[\\"]/g, String.raw`\$&`).replaceAll(/[^\u0020-\u007E]/g, ' '); } -function buildWwwAuthenticateHeader( +/** + * Build a `WWW-Authenticate: Bearer …` challenge header value (RFC 6750). + * + * The single formatter behind every challenge this package emits — the + * bearer-auth 401/403 answers and the per-operation scope-challenge 403 — so + * all challenges from one server agree on parameter order and quoting. Every + * parameter value is emitted as an HTTP quoted-string with `\` and `"` + * escaped and non-printable characters replaced. + */ +export function buildWwwAuthenticateHeader( errorCode: string, description: string, - requiredScopes: string[], + requiredScopes: readonly string[], resourceMetadataUrl: string | undefined ): string { let header = `Bearer error="${headerQuotedValue(errorCode)}", error_description="${headerQuotedValue(description)}"`; if (requiredScopes.length > 0) { - header += `, scope="${requiredScopes.join(' ')}"`; + header += `, scope="${headerQuotedValue(requiredScopes.join(' '))}"`; } if (resourceMetadataUrl) { - header += `, resource_metadata="${resourceMetadataUrl}"`; + header += `, resource_metadata="${headerQuotedValue(resourceMetadataUrl)}"`; } return header; } @@ -120,6 +135,14 @@ export async function verifyBearerToken(authorizationHeader: string | null | und throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired'); } + // Hand the gate's discovery configuration inward with the verified token, + // so challenges built after authentication (per-operation scope + // challenges) advertise the same metadata document. A verifier-set value + // wins over the gate's configuration. + if (options.resourceMetadataUrl !== undefined && authInfo.resourceMetadataUrl === undefined) { + return { ...authInfo, resourceMetadataUrl: options.resourceMetadataUrl }; + } + return authInfo; } diff --git a/packages/server/src/server/scopeChallenge.ts b/packages/server/src/server/scopeChallenge.ts index ba10990405..4eeca2910f 100644 --- a/packages/server/src/server/scopeChallenge.ts +++ b/packages/server/src/server/scopeChallenge.ts @@ -1,5 +1,8 @@ import type { AuthInfo, JSONRPCRequest, RequestId } from '@modelcontextprotocol/core-internal'; +import { buildWwwAuthenticateHeader } from './middleware/bearerAuth'; +import { getOAuthProtectedResourceMetadataUrl } from './middleware/oauthMetadata'; + /** OAuth scopes to request before handling an MCP request. */ export interface ScopeChallenge { /** 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: { authInfo?: AuthInfo; }) => ScopeChallenge | undefined | Promise; -/** Configuration for HTTP `insufficient_scope` challenges. */ -export interface ScopeChallengeConfig { - /** URL of the RFC 9728 protected resource metadata. */ - resourceMetadataUrl: string; -} - /** @internal */ export function supportsScopeChallengeResolver( transport: unknown @@ -90,22 +87,41 @@ export async function findScopeChallenge( return undefined; } -function quoteAuthParam(value: string): string { - return value.replaceAll('\\', '\\\\').replaceAll('"', String.raw`\"`); +/** + * The RFC 9728 Protected Resource Metadata URL to advertise on a scope + * challenge, derived from the verified {@link AuthInfo}: the URL the + * authentication gate stamped (`authInfo.resourceMetadataUrl`, set by the + * bearer-auth helpers from their `resourceMetadataUrl` option), falling back + * to the well-known location for the token's RFC 8707 `resource` identifier, + * or `undefined` when neither is available (the `resource_metadata` parameter + * is then omitted, matching the bearer-auth challenges). + * + * @internal + */ +export function scopeChallengeResourceMetadataUrl(authInfo: AuthInfo | undefined): string | undefined { + if (authInfo?.resourceMetadataUrl !== undefined) { + return authInfo.resourceMetadataUrl; + } + if (authInfo?.resource !== undefined) { + return getOAuthProtectedResourceMetadataUrl(authInfo.resource); + } + return undefined; } /** @internal */ export function createScopeChallengeResponse( - config: ScopeChallengeConfig, challenge: ScopeChallenge, - responseId: RequestId | null + responseId: RequestId | null, + resourceMetadataUrl: string | undefined ): Response { - const wwwAuthenticate = - 'Bearer' + - ' error="insufficient_scope"' + - `, scope="${quoteAuthParam(challenge.scopes.join(' '))}"` + - `, resource_metadata="${quoteAuthParam(config.resourceMetadataUrl)}"` + - (challenge.errorDescription === undefined ? '' : `, error_description="${quoteAuthParam(challenge.errorDescription)}"`); + // One formatter for every challenge this package emits: identical + // parameter order and quoting to the bearer-auth 401/403 answers. + const wwwAuthenticate = buildWwwAuthenticateHeader( + 'insufficient_scope', + challenge.errorDescription ?? 'Insufficient scope', + challenge.scopes, + resourceMetadataUrl + ); return Response.json( { diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index c0ab6db98b..c603aa1b17 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -20,8 +20,8 @@ import { } from '@modelcontextprotocol/core-internal'; import { MAX_BATCH_SIZE, readRequestBody, requestBodyTooLargeMessage, resolveMaxRequestBodySize } from './requestBody'; -import type { ScopeChallengeConfig, ScopeChallengeHandler } from './scopeChallenge'; -import { createScopeChallengeResponse, findScopeChallenge } from './scopeChallenge'; +import type { ScopeChallengeHandler } from './scopeChallenge'; +import { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge'; import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; export type StreamId = string; @@ -179,9 +179,6 @@ export interface WebStandardStreamableHTTPServerTransportOptions { * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ supportedProtocolVersions?: string[]; - - /** Enables OAuth scope challenges. `McpServer.connect()` supplies the resolver. */ - scopeChallenge?: ScopeChallengeConfig; } /** @@ -272,7 +269,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _supportedProtocolVersions: string[]; private _keepAliveMs: number; private _maxRequestBodySize: number; - private _scopeChallenge?: ScopeChallengeConfig; private _scopeChallengeResolver?: ScopeChallengeHandler; sessionId?: string; @@ -293,7 +289,6 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; this._keepAliveMs = options.keepAliveMs ?? DEFAULT_SSE_KEEP_ALIVE_MS; this._maxRequestBodySize = resolveMaxRequestBodySize(options.maxRequestBodySize); - this._scopeChallenge = options.scopeChallenge; } private startKeepAlive( @@ -366,14 +361,22 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } private async _checkScopeChallenge(messages: JSONRPCMessage[], authInfo?: AuthInfo): Promise { - if (!this._scopeChallenge || !this._scopeChallengeResolver) { + // Active whenever a connected McpServer supplied a resolver (it + // resolves per-primitive scopeChallenge callbacks); the challenge's + // resource_metadata parameter is derived from the verified AuthInfo + // and omitted when unavailable. + if (!this._scopeChallengeResolver) { return undefined; } const requests: JSONRPCRequest[] = messages.filter(message => isJSONRPCRequest(message)); const result = await findScopeChallenge(requests, authInfo, this._scopeChallengeResolver); return result === undefined ? undefined - : createScopeChallengeResponse(this._scopeChallenge, result.challenge, messages.length === 1 ? result.requestId : null); + : createScopeChallengeResponse( + result.challenge, + messages.length === 1 ? result.requestId : null, + scopeChallengeResourceMetadataUrl(authInfo) + ); } /** diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index 6039169638..8b65e53922 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -20,8 +20,8 @@ function toolCall(name = 'operate', args: Record = {}, id: stri }; } -function auth(scopes: string[]): AuthInfo { - return { token: 'token', clientId: 'client', scopes }; +function auth(scopes: string[], resourceMetadataUrl?: string): AuthInfo { + return { token: 'token', clientId: 'client', scopes, ...(resourceMetadataUrl !== undefined && { resourceMetadataUrl }) }; } describe('requireScopes', () => { @@ -73,8 +73,7 @@ async function createLegacyHarness(scopeChallenge: ScopeChallengeHandler): Promi server.registerTool('public', { inputSchema: z.object({}) }, async () => ({ content: [{ type: 'text', text: 'public' }] })); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } + enableJsonResponse: true }); await server.connect(transport); return { server, transport, calls }; @@ -125,7 +124,7 @@ describe('legacy Streamable HTTP scope preflight', () => { const sessionId = await initializeLegacy(harness.transport); const response = await harness.transport.handleRequest( legacyRequest(toolCall('operate', { mode: 'write', nested: { value: 42 } }), sessionId), - { authInfo: auth(['repo:read']) } + { authInfo: auth(['repo:read'], RESOURCE_METADATA_URL) } ); expect(response.status).toBe(403); @@ -138,7 +137,7 @@ describe('legacy Streamable HTTP scope preflight', () => { method: 'tools/call', params: { name: 'operate', arguments: { mode: 'write', nested: { value: 42 } } } }), - authInfo: auth(['repo:read']) + authInfo: auth(['repo:read'], RESOURCE_METADATA_URL) }); expect(harness.calls).not.toHaveBeenCalled(); await harness.transport.close(); @@ -228,4 +227,32 @@ describe('legacy Streamable HTTP scope preflight', () => { expect(response.headers.get('WWW-Authenticate')).toContain('error_description="Needs repo:read, path/to/thing"'); await harness.transport.close(); }); + + it('omits resource_metadata when the auth info carries no metadata URL', async () => { + const harness = await createLegacyHarness(requireScopes('repo:write')); + const sessionId = await initializeLegacy(harness.transport); + const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), { + authInfo: auth(['repo:read']) + }); + + expect(response.status).toBe(403); + const challenge = response.headers.get('WWW-Authenticate'); + expect(challenge).toContain('scope="repo:write"'); + expect(challenge).not.toContain('resource_metadata'); + await harness.transport.close(); + }); + + it('derives resource_metadata from the RFC 8707 resource identifier when no URL was stamped', async () => { + const harness = await createLegacyHarness(requireScopes('repo:write')); + const sessionId = await initializeLegacy(harness.transport); + const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), { + authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp') } + }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' + ); + await harness.transport.close(); + }); }); diff --git a/packages/server/test/server/scopeChallengeModern.test.ts b/packages/server/test/server/scopeChallengeModern.test.ts index 7b962cf880..78901352c8 100644 --- a/packages/server/test/server/scopeChallengeModern.test.ts +++ b/packages/server/test/server/scopeChallengeModern.test.ts @@ -6,6 +6,7 @@ import * as z from 'zod/v4'; import { fromJsonSchema } from '../../src/fromJsonSchema'; import { createMcpHandler } from '../../src/server/createMcpHandler'; import { McpServer } from '../../src/server/mcp'; +import { requireBearerAuth } from '../../src/server/middleware/bearerAuth'; import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge'; import { requireScopes } from '../../src/server/scopeChallenge'; @@ -38,11 +39,13 @@ function call(name: string, args: Record, headers?: Record { const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); @@ -59,10 +62,7 @@ function createHandler(scopeChallenge: ScopeChallengeHandler, onCall = vi.fn(), ); return server; }, - { - ...(responseMode !== undefined && { responseMode }), - scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } - } + { ...(responseMode !== undefined && { responseMode }) } ); } @@ -80,8 +80,12 @@ describe('createMcpHandler scope preflight', () => { const response = await handler.fetch(incoming, { authInfo: auth(['repo:read']) }); expect(response.status).toBe(403); - expect(response.headers.get('WWW-Authenticate')).toContain('scope="repo:write"'); - expect(response.headers.get('WWW-Authenticate')).not.toContain('error_description'); + // The bearer-auth formatter builds the header: error, then the default + // description, then the scope set; resource_metadata is omitted when + // the auth info carries no metadata URL. + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="repo:write"' + ); expect(callback).toHaveBeenCalledWith({ request: expect.objectContaining({ method: 'tools/call', @@ -101,16 +105,13 @@ describe('createMcpHandler scope preflight', () => { properties: { region: { type: 'string', 'x-mcp-header': 'Region' } as Record }, required: ['region'] }); - const handler = createMcpHandler( - () => { - const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); - server.registerTool('route', { inputSchema: routeSchema, scopeChallenge: callback }, async () => ({ - content: [{ type: 'text', text: 'ok' }] - })); - return server; - }, - { scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } } - ); + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); + server.registerTool('route', { inputSchema: routeSchema, scopeChallenge: callback }, async () => ({ + content: [{ type: 'text', text: 'ok' }] + })); + return server; + }); const response = await handler.fetch(call('route', { region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' }), { authInfo: auth([]) @@ -122,16 +123,13 @@ describe('createMcpHandler scope preflight', () => { }); it('keeps challenged tools discoverable and uses exact static all-of checks', async () => { - const handler = createMcpHandler( - () => { - const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); - server.registerTool('scoped', { scopeChallenge: requireScopes('repo:read', 'org:read') }, async () => ({ - content: [] - })); - return server; - }, - { scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } } - ); + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); + server.registerTool('scoped', { scopeChallenge: requireScopes('repo:read', 'org:read') }, async () => ({ + content: [] + })); + return server; + }); const listResponse = await handler.fetch(request('tools/list', {}), { authInfo: auth([]) }); expect(listResponse.status).toBe(200); @@ -179,15 +177,12 @@ describe('createMcpHandler scope preflight', () => { const onPrompt = vi.fn(async () => ({ messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'secret' } }] })); - const handler = createMcpHandler( - () => { - const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); - server.registerResource('config', 'config://settings', { scopeChallenge: requireScopes('config:read') }, onRead); - server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, onPrompt); - return server; - }, - { scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } } - ); + const handler = createMcpHandler(() => { + const server = new McpServer({ name: 'modern-scope', version: '1.0.0' }); + server.registerResource('config', 'config://settings', { scopeChallenge: requireScopes('config:read') }, onRead); + server.registerPrompt('summarize', { scopeChallenge: requireScopes('prompt:read') }, onPrompt); + return server; + }); const resourceResponse = await handler.fetch(request('resources/read', { uri: 'config://settings' }), { authInfo: auth([]) @@ -204,3 +199,70 @@ describe('createMcpHandler scope preflight', () => { expect(onPrompt).not.toHaveBeenCalled(); }); }); + +describe('scope challenge resource_metadata derivation', () => { + it('advertises the metadata URL stamped onto the auth info', async () => { + const handler = createHandler(requireScopes('repo:write')); + + const response = await handler.fetch(call('operate', {}), { + authInfo: auth(['repo:read'], RESOURCE_METADATA_URL) + }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="repo:write"' + + `, resource_metadata="${RESOURCE_METADATA_URL}"` + ); + }); + + it('carries the URL configured on requireBearerAuth through to the challenge header', async () => { + // The single configuration site: the bearer-auth gate stamps its + // resourceMetadataUrl onto the AuthInfo it returns, and the scope + // preflight reads it from there. + const gate = requireBearerAuth({ + verifier: { + verifyAccessToken: async token => ({ + token, + clientId: 'client', + scopes: ['repo:read'], + expiresAt: Math.floor(Date.now() / 1000) + 3600 + }) + }, + resourceMetadataUrl: RESOURCE_METADATA_URL + }); + const handler = createHandler(requireScopes('repo:write')); + + const incoming = call('operate', {}, { Authorization: 'Bearer token-1' }); + const gateResult = await gate(incoming); + expect(gateResult).not.toBeInstanceOf(Response); + const authInfo = gateResult as AuthInfo; + expect(authInfo.resourceMetadataUrl).toBe(RESOURCE_METADATA_URL); + + const response = await handler.fetch(incoming, { authInfo }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`); + }); + + it('falls back to the well-known location for the RFC 8707 resource identifier', async () => { + const handler = createHandler(requireScopes('repo:write')); + + const response = await handler.fetch(call('operate', {}), { + authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp') } + }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' + ); + }); + + it('omits resource_metadata entirely when the auth info offers no URL', async () => { + const handler = createHandler(requireScopes('repo:write')); + + const response = await handler.fetch(call('operate', {}), { authInfo: auth(['repo:read']) }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata'); + }); +}); diff --git a/packages/server/test/server/scopeChallengePrimitives.test.ts b/packages/server/test/server/scopeChallengePrimitives.test.ts index 6d87561b4d..852fa39c62 100644 --- a/packages/server/test/server/scopeChallengePrimitives.test.ts +++ b/packages/server/test/server/scopeChallengePrimitives.test.ts @@ -8,8 +8,6 @@ import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge'; import { requireScopes } from '../../src/server/scopeChallenge'; import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp'; -const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource'; - function auth(scopes: string[]): AuthInfo { return { token: 'token', clientId: 'client', scopes }; } @@ -59,8 +57,7 @@ async function createHarness(server: McpServer): Promise<{ }> { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - enableJsonResponse: true, - scopeChallenge: { resourceMetadataUrl: RESOURCE_METADATA_URL } + enableJsonResponse: true }); await server.connect(transport); return { transport, sessionId: await initialize(transport) }; diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index ff5cc13512..25926ba2a7 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -1408,8 +1408,7 @@ function createMcpServer() { const PORT = process.env.PORT || 3000; const scopeChallengeResourceMetadataUrl = `http://localhost:${PORT}/.well-known/oauth-protected-resource/mcp`; const modernHandlerOptions = { - onerror: (error: Error) => console.error('Modern-era MCP handler error:', error), - scopeChallenge: { resourceMetadataUrl: scopeChallengeResourceMetadataUrl } + onerror: (error: Error) => console.error('Modern-era MCP handler error:', error) }; const modernHandler = createMcpHandler(() => createMcpServer(), modernHandlerOptions); const modernNodeHandler = toNodeHandler(modernHandler); @@ -1431,12 +1430,17 @@ app.use((req, _res, next) => { req.auth = { token, clientId: 'mcp-conformance-scope-challenge', - scopes: [SCOPE_CHALLENGE_BASELINE_SCOPE] + scopes: [SCOPE_CHALLENGE_BASELINE_SCOPE], + // Scope-challenge 403s derive their resource_metadata parameter + // from the verified AuthInfo (a real deployment's bearer-auth gate + // stamps this from its resourceMetadataUrl option). + resourceMetadataUrl: scopeChallengeResourceMetadataUrl }; } else if (token === SCOPE_CHALLENGE_FULL_TOKEN) { req.auth = { token, clientId: 'mcp-conformance-scope-challenge', + resourceMetadataUrl: scopeChallengeResourceMetadataUrl, scopes: [ SCOPE_CHALLENGE_BASELINE_SCOPE, ...new Set([ From 54e8dedadeb5136eb7adb44bb044c75f69a3b641 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 2 Sep 2026 14:01:54 +0200 Subject: [PATCH 6/7] fix(server): preserve resource metadata queries Keep RFC 9728 query components when deriving metadata URLs and omit the fallback for abstract RFC 8707 resource identifiers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .changeset/scope-challenge-server.md | 2 +- .../src/server/middleware/oauthMetadata.ts | 5 ++++- packages/server/src/server/scopeChallenge.ts | 8 ++++---- .../server/test/server/oauthMetadata.test.ts | 6 ++++++ .../server/test/server/scopeChallenge.test.ts | 16 ++++++++++++++-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.changeset/scope-challenge-server.md b/.changeset/scope-challenge-server.md index 50d9242d60..567e37f804 100644 --- a/.changeset/scope-challenge-server.md +++ b/.changeset/scope-challenge-server.md @@ -18,5 +18,5 @@ bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return (new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the -well-known location for the token's RFC 8707 `resource` identifier; the +well-known location for an HTTP(S) RFC 8707 `resource` identifier; the parameter is omitted when neither is available. diff --git a/packages/server/src/server/middleware/oauthMetadata.ts b/packages/server/src/server/middleware/oauthMetadata.ts index fa5ac5f455..64296d05ee 100644 --- a/packages/server/src/server/middleware/oauthMetadata.ts +++ b/packages/server/src/server/middleware/oauthMetadata.ts @@ -89,7 +89,10 @@ export function buildOAuthProtectedResourceMetadata(options: AuthMetadataOptions * ``` */ export function getOAuthProtectedResourceMetadataUrl(serverUrl: URL): string { - return new URL(protectedResourceMetadataPath(serverUrl), serverUrl).href; + const metadataUrl = new URL(serverUrl); + metadataUrl.pathname = protectedResourceMetadataPath(serverUrl); + metadataUrl.hash = ''; + return metadataUrl.href; } /** The RFC 9728 path-aware well-known path for a resource URL. */ diff --git a/packages/server/src/server/scopeChallenge.ts b/packages/server/src/server/scopeChallenge.ts index 4eeca2910f..c108cee0f8 100644 --- a/packages/server/src/server/scopeChallenge.ts +++ b/packages/server/src/server/scopeChallenge.ts @@ -92,9 +92,9 @@ export async function findScopeChallenge( * challenge, derived from the verified {@link AuthInfo}: the URL the * authentication gate stamped (`authInfo.resourceMetadataUrl`, set by the * bearer-auth helpers from their `resourceMetadataUrl` option), falling back - * to the well-known location for the token's RFC 8707 `resource` identifier, - * or `undefined` when neither is available (the `resource_metadata` parameter - * is then omitted, matching the bearer-auth challenges). + * to the well-known location for an HTTP(S) RFC 8707 `resource` identifier, or + * `undefined` when neither is available (the `resource_metadata` parameter is + * then omitted, matching the bearer-auth challenges). * * @internal */ @@ -102,7 +102,7 @@ export function scopeChallengeResourceMetadataUrl(authInfo: AuthInfo | undefined if (authInfo?.resourceMetadataUrl !== undefined) { return authInfo.resourceMetadataUrl; } - if (authInfo?.resource !== undefined) { + if (authInfo?.resource?.protocol === 'https:' || authInfo?.resource?.protocol === 'http:') { return getOAuthProtectedResourceMetadataUrl(authInfo.resource); } return undefined; diff --git a/packages/server/test/server/oauthMetadata.test.ts b/packages/server/test/server/oauthMetadata.test.ts index fe3b3c4e20..19bb362d17 100644 --- a/packages/server/test/server/oauthMetadata.test.ts +++ b/packages/server/test/server/oauthMetadata.test.ts @@ -82,6 +82,12 @@ describe('getOAuthProtectedResourceMetadataUrl', () => { 'https://api.example.com/.well-known/oauth-protected-resource' ); }); + + it('preserves the resource identifier query', () => { + expect(getOAuthProtectedResourceMetadataUrl(new URL('https://api.example.com/mcp?tenant=acme'))).toBe( + 'https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme' + ); + }); }); describe('oauthMetadataResponse', () => { diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index 8b65e53922..81ff758bc6 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -246,13 +246,25 @@ describe('legacy Streamable HTTP scope preflight', () => { const harness = await createLegacyHarness(requireScopes('repo:write')); const sessionId = await initializeLegacy(harness.transport); const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), { - authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp') } + authInfo: { ...auth(['repo:read']), resource: new URL('https://api.example.com/mcp?tenant=acme') } }); expect(response.status).toBe(403); expect(response.headers.get('WWW-Authenticate')).toContain( - 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"' + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme"' ); await harness.transport.close(); }); + + it('omits resource_metadata when an abstract RFC 8707 resource identifier cannot locate an RFC 9728 document', async () => { + const harness = await createLegacyHarness(requireScopes('repo:write')); + const sessionId = await initializeLegacy(harness.transport); + const response = await harness.transport.handleRequest(legacyRequest(toolCall(), sessionId), { + authInfo: { ...auth(['repo:read']), resource: new URL('urn:example:mcp') } + }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata'); + await harness.transport.close(); + }); }); From db2ca408882a7908fc0d680f2d26d4654d3003e1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 3 Sep 2026 17:19:01 +0200 Subject: [PATCH 7/7] fix(server): use OAuth body for scope challenges Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/serving/authorization.md | 2 +- .../server/src/server/createMcpHandler.ts | 6 +-- packages/server/src/server/scopeChallenge.ts | 39 +++++-------------- packages/server/src/server/streamableHttp.ts | 10 +---- .../server/test/server/scopeChallenge.test.ts | 26 ++++++++++++- .../test/server/scopeChallengeModern.test.ts | 4 ++ 6 files changed, 44 insertions(+), 43 deletions(-) diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 8dcc372710..5306ff1ada 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -121,7 +121,7 @@ The per-request factory itself receives the same value as `ctx.authInfo`, so it `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. -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. +The challenge uses the same OAuth `insufficient_scope` JSON body and `WWW-Authenticate` formatter as `requireBearerAuth`'s own `403` answer. 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. Use `requireScopes` for a static exact all-of check. Use a callback when the required scope set depends on the request: diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 5c5a7a2920..82cfa061bc 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -840,10 +840,10 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // or the token's RFC 8707 resource identifier) and omitted otherwise. if (route.messageKind === 'request' && product instanceof McpServer) { try { - const result = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); - if (result !== undefined) { + const challenge = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); + if (challenge !== undefined) { void product.close().catch(reportError); - return createScopeChallengeResponse(result.challenge, result.requestId, scopeChallengeResourceMetadataUrl(authInfo)); + return createScopeChallengeResponse(challenge, scopeChallengeResourceMetadataUrl(authInfo)); } } catch (error) { void product.close().catch(reportError); diff --git a/packages/server/src/server/scopeChallenge.ts b/packages/server/src/server/scopeChallenge.ts index c108cee0f8..544175dae9 100644 --- a/packages/server/src/server/scopeChallenge.ts +++ b/packages/server/src/server/scopeChallenge.ts @@ -1,6 +1,7 @@ -import type { AuthInfo, JSONRPCRequest, RequestId } from '@modelcontextprotocol/core-internal'; +import type { AuthInfo, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; -import { buildWwwAuthenticateHeader } from './middleware/bearerAuth'; +import { bearerAuthChallengeResponse } from './middleware/bearerAuth'; import { getOAuthProtectedResourceMetadataUrl } from './middleware/oauthMetadata'; /** OAuth scopes to request before handling an MCP request. */ @@ -77,11 +78,11 @@ export async function findScopeChallenge( requests: readonly JSONRPCRequest[], authInfo: AuthInfo | undefined, resolve: ScopeChallengeHandler -): Promise<{ challenge: ScopeChallenge; requestId: RequestId } | undefined> { +): Promise { for (const request of requests) { const challenge = await resolve({ request, ...(authInfo !== undefined && { authInfo }) }); if (challenge !== undefined) { - return { challenge: validateScopeChallenge(challenge), requestId: request.id }; + return validateScopeChallenge(challenge); } } return undefined; @@ -109,32 +110,12 @@ export function scopeChallengeResourceMetadataUrl(authInfo: AuthInfo | undefined } /** @internal */ -export function createScopeChallengeResponse( - challenge: ScopeChallenge, - responseId: RequestId | null, - resourceMetadataUrl: string | undefined -): Response { - // One formatter for every challenge this package emits: identical - // parameter order and quoting to the bearer-auth 401/403 answers. - const wwwAuthenticate = buildWwwAuthenticateHeader( - 'insufficient_scope', - challenge.errorDescription ?? 'Insufficient scope', - challenge.scopes, - resourceMetadataUrl - ); - - return Response.json( - { - jsonrpc: '2.0', - error: { code: -32_600, message: 'Insufficient scope' }, - id: responseId - }, +export function createScopeChallengeResponse(challenge: ScopeChallenge, resourceMetadataUrl: string | undefined): Response { + return bearerAuthChallengeResponse( + new OAuthError(OAuthErrorCode.InsufficientScope, challenge.errorDescription ?? 'Insufficient scope'), { - status: 403, - headers: { - 'Content-Type': 'application/json', - 'WWW-Authenticate': wwwAuthenticate - } + requiredScopes: [...challenge.scopes], + resourceMetadataUrl } ); } diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index c603aa1b17..e57c6e2e82 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -369,14 +369,8 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return undefined; } const requests: JSONRPCRequest[] = messages.filter(message => isJSONRPCRequest(message)); - const result = await findScopeChallenge(requests, authInfo, this._scopeChallengeResolver); - return result === undefined - ? undefined - : createScopeChallengeResponse( - result.challenge, - messages.length === 1 ? result.requestId : null, - scopeChallengeResourceMetadataUrl(authInfo) - ); + const challenge = await findScopeChallenge(requests, authInfo, this._scopeChallengeResolver); + return challenge === undefined ? undefined : createScopeChallengeResponse(challenge, scopeChallengeResourceMetadataUrl(authInfo)); } /** diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index 81ff758bc6..246d5a049c 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -6,7 +6,7 @@ import * as z from 'zod/v4'; import { McpServer } from '../../src/server/mcp'; import type { ScopeChallengeHandler } from '../../src/server/scopeChallenge'; -import { requireScopes } from '../../src/server/scopeChallenge'; +import { createScopeChallengeResponse, requireScopes } from '../../src/server/scopeChallenge'; import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp'; const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource'; @@ -50,6 +50,25 @@ describe('requireScopes', () => { }); }); +describe('createScopeChallengeResponse', () => { + it('uses an OAuth error body rather than a JSON-RPC Invalid Request error', async () => { + const response = createScopeChallengeResponse( + { scopes: ['repo:write'], errorDescription: 'Write access is required' }, + RESOURCE_METADATA_URL + ); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer error="insufficient_scope", error_description="Write access is required", scope="repo:write"' + + `, resource_metadata="${RESOURCE_METADATA_URL}"` + ); + expect(await response.json()).toEqual({ + error: 'insufficient_scope', + error_description: 'Write access is required' + }); + }); +}); + interface LegacyHarness { server: McpServer; transport: WebStandardStreamableHTTPServerTransport; @@ -159,7 +178,10 @@ describe('legacy Streamable HTTP scope preflight', () => { ); expect(response.status).toBe(403); - expect(((await response.json()) as { id: unknown }).id).toBeNull(); + expect(await response.json()).toEqual({ + error: 'insufficient_scope', + error_description: 'Insufficient scope' + }); expect(callback).toHaveBeenCalledTimes(1); expect(harness.calls).not.toHaveBeenCalled(); await harness.transport.close(); diff --git a/packages/server/test/server/scopeChallengeModern.test.ts b/packages/server/test/server/scopeChallengeModern.test.ts index 78901352c8..9ef717735d 100644 --- a/packages/server/test/server/scopeChallengeModern.test.ts +++ b/packages/server/test/server/scopeChallengeModern.test.ts @@ -86,6 +86,10 @@ describe('createMcpHandler scope preflight', () => { expect(response.headers.get('WWW-Authenticate')).toBe( 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="repo:write"' ); + expect(await response.json()).toEqual({ + error: 'insufficient_scope', + error_description: 'Insufficient scope' + }); expect(callback).toHaveBeenCalledWith({ request: expect.objectContaining({ method: 'tools/call',