diff --git a/.changeset/scope-challenge-server.md b/.changeset/scope-challenge-server.md new file mode 100644 index 0000000000..567e37f804 --- /dev/null +++ b/.changeset/scope-challenge-server.md @@ -0,0 +1,22 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/node': minor +--- + +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 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 an HTTP(S) RFC 8707 `resource` identifier; the +parameter is omitted when neither is available. 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..5306ff1ada 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 @@ -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 }; @@ -116,23 +117,49 @@ 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 a scope only some tools need, check inside the handler — the handler is the only place that knows which tool is executing. +`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. -```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' }] }; -}); +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: + +```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', + { + 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. +Scope interpretation belongs to your callback; the SDK does not infer hierarchies, alternatives, or missing scopes. Challenged primitives remain visible in their list operations. -::: 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). +::: 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 must follow the OAuth `scope-token` grammar; `errorDescription`, when provided, must follow RFC 6750's `error-description` grammar. ::: ## Recap @@ -141,5 +168,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-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 bc6887d9e0..4cffc022a0 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 }; @@ -72,14 +73,36 @@ 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 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', + { + 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 perOperationScopes_challenge return server; } 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/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..9b3196a80b 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, 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..82cfa061bc 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -64,6 +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 { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge'; import type { Server } from './server'; import { installModernOnlyHandlers, seedClientIdentityFromEnvelope, serverIdentityOf } from './server'; import type { ServerEventBus, ServerNotifier } from './serverEventBus'; @@ -831,6 +832,26 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } } + // 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 challenge = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); + if (challenge !== undefined) { + void product.close().catch(reportError); + return createScopeChallengeResponse(challenge, scopeChallengeResourceMetadataUrl(authInfo)); + } + } 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 — // both before the instance is connected to the per-request transport. setNegotiatedProtocolVersion(server, claimedRevision); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index d2e40181e4..70a5539bfb 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,53 @@ export class McpServer { await this.server.close(); } + /** @internal */ + resolveScopeChallenge: ScopeChallengeHandler = 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; private setToolRequestHandlers() { @@ -502,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); } } @@ -588,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') { @@ -625,6 +679,7 @@ export class McpServer { (config as BaseMetadata).title, uriOrTemplate, metadata, + scopeChallenge, readCallback as ReadResourceCallback ); if (cacheHint !== undefined) { @@ -644,6 +699,7 @@ export class McpServer { (config as BaseMetadata).title, uriOrTemplate, metadata, + scopeChallenge, readCallback as ReadResourceTemplateCallback ); if (cacheHint !== undefined) { @@ -661,6 +717,7 @@ export class McpServer { title: string | undefined, uri: string, metadata: ResourceMetadata | undefined, + scopeChallenge: ScopeChallengeHandler | undefined, readCallback: ReadResourceCallback ): RegisteredResource { const registeredResource: RegisteredResource = { @@ -668,6 +725,7 @@ export class McpServer { title, metadata, readCallback, + scopeChallenge, enabled: true, disable: () => registeredResource.update({ enabled: false }), enable: () => registeredResource.update({ enabled: true }), @@ -681,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(); } @@ -694,6 +755,7 @@ export class McpServer { title: string | undefined, template: ResourceTemplate, metadata: ResourceMetadata | undefined, + scopeChallenge: ScopeChallengeHandler | undefined, readCallback: ReadResourceTemplateCallback ): RegisteredResourceTemplate { const registeredResourceTemplate: RegisteredResourceTemplate = { @@ -701,6 +763,7 @@ export class McpServer { title, metadata, readCallback, + scopeChallenge, enabled: true, disable: () => registeredResourceTemplate.update({ enabled: false }), enable: () => registeredResourceTemplate.update({ enabled: true }), @@ -714,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(); } @@ -737,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 @@ -748,6 +815,7 @@ export class McpServer { description, argsSchema, icons, + scopeChallenge, _meta, handler: createPromptHandler(name, argsSchema, callback), enabled: true, @@ -762,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 @@ -811,6 +882,7 @@ export class McpServer { annotations: ToolAnnotations | undefined, icons: Icon[] | undefined, execution: ToolExecution | undefined, + scopeChallenge: ScopeChallengeHandler | undefined, _meta: Record | undefined, handler: AnyToolHandler ): RegisteredTool { @@ -856,6 +928,7 @@ export class McpServer { annotations, icons, execution, + scopeChallenge, _meta, handler: handler, executor: createToolExecutor(inputSchema, handler), @@ -911,6 +984,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 +1035,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 +1051,7 @@ export class McpServer { outputSchema?: OutputArgs; annotations?: ToolAnnotations; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: LegacyToolCallback @@ -986,6 +1065,7 @@ export class McpServer { outputSchema?: StandardSchemaWithJSON | ZodRawShape; annotations?: ToolAnnotations; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: ToolCallback | LegacyToolCallback @@ -994,8 +1074,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 +1084,7 @@ export class McpServer { annotations, icons, undefined, + scopeChallenge, _meta, cb as ToolCallback ); @@ -1043,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 @@ -1055,6 +1137,7 @@ export class McpServer { description?: string; argsSchema?: Args; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: LegacyPromptCallback @@ -1066,6 +1149,7 @@ export class McpServer { description?: string; argsSchema?: StandardSchemaWithJSON | ZodRawShape; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, cb: PromptCallback | LegacyPromptCallback @@ -1074,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, @@ -1083,6 +1167,7 @@ export class McpServer { normalizeRawShapeSchema(argsSchema), cb as PromptCallback, icons, + scopeChallenge, _meta ); @@ -1278,6 +1363,7 @@ export type RegisteredTool = { annotations?: ToolAnnotations; icons?: Icon[]; execution?: ToolExecution; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; handler: AnyToolHandler; /** @hidden */ @@ -1293,6 +1379,7 @@ export type RegisteredTool = { outputSchema?: StandardSchemaWithJSON; annotations?: ToolAnnotations; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler | null; _meta?: Record; callback?: ToolCallback; enabled?: boolean; @@ -1365,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; @@ -1374,6 +1462,7 @@ export type RegisteredResource = { title?: string; uri?: string | null; metadata?: ResourceMetadata; + scopeChallenge?: ScopeChallengeHandler | null; callback?: ReadResourceCallback; enabled?: boolean; }): void; @@ -1395,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; @@ -1404,6 +1494,7 @@ export type RegisteredResourceTemplate = { title?: string; template?: ResourceTemplate; metadata?: ResourceMetadata; + scopeChallenge?: ScopeChallengeHandler | null; callback?: ReadResourceTemplateCallback; enabled?: boolean; }): void; @@ -1433,6 +1524,7 @@ export type RegisteredPrompt = { description?: string; argsSchema?: StandardSchemaWithJSON; icons?: Icon[]; + scopeChallenge?: ScopeChallengeHandler; _meta?: Record; /** @hidden */ handler: PromptHandler; @@ -1445,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/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/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 new file mode 100644 index 0000000000..544175dae9 --- /dev/null +++ b/packages/server/src/server/scopeChallenge.ts @@ -0,0 +1,121 @@ +import type { AuthInfo, JSONRPCRequest } from '@modelcontextprotocol/core-internal'; +import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; + +import { bearerAuthChallengeResponse } 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. */ + scopes: readonly [string, ...string[]]; + /** Optional human-readable detail satisfying the RFC 6750 `error-description` grammar. */ + errorDescription?: string; +} + +/** Determines whether an MCP request needs an OAuth scope challenge. */ +export type ScopeChallengeHandler = (context: { + request: JSONRPCRequest; + authInfo?: AuthInfo; +}) => ScopeChallenge | undefined | Promise; + +/** @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' || !/^[\u0021\u0023-\u005B\u005D-\u007E]+$/.test(scope)) { + throw new TypeError(`${location} must satisfy the OAuth scope-token grammar`); + } +} + +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'); + } + 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; +} + +/** + * 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 { + for (const request of requests) { + const challenge = await resolve({ request, ...(authInfo !== undefined && { authInfo }) }); + if (challenge !== undefined) { + return validateScopeChallenge(challenge); + } + } + return undefined; +} + +/** + * 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 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 + */ +export function scopeChallengeResourceMetadataUrl(authInfo: AuthInfo | undefined): string | undefined { + if (authInfo?.resourceMetadataUrl !== undefined) { + return authInfo.resourceMetadataUrl; + } + if (authInfo?.resource?.protocol === 'https:' || authInfo?.resource?.protocol === 'http:') { + return getOAuthProtectedResourceMetadataUrl(authInfo.resource); + } + return undefined; +} + +/** @internal */ +export function createScopeChallengeResponse(challenge: ScopeChallenge, resourceMetadataUrl: string | undefined): Response { + return bearerAuthChallengeResponse( + new OAuthError(OAuthErrorCode.InsufficientScope, challenge.errorDescription ?? 'Insufficient scope'), + { + requiredScopes: [...challenge.scopes], + resourceMetadataUrl + } + ); +} diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index ce73cf8a20..e57c6e2e82 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 { ScopeChallengeHandler } from './scopeChallenge'; +import { createScopeChallengeResponse, findScopeChallenge, scopeChallengeResourceMetadataUrl } from './scopeChallenge'; import { armSseKeepAlive, DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; export type StreamId = string; @@ -267,6 +269,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { private _supportedProtocolVersions: string[]; private _keepAliveMs: number; private _maxRequestBodySize: number; + private _scopeChallengeResolver?: ScopeChallengeHandler; sessionId?: string; onclose?: () => void; @@ -304,6 +307,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 +360,19 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { ); } + private async _checkScopeChallenge(messages: JSONRPCMessage[], authInfo?: AuthInfo): Promise { + // 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 challenge = await findScopeChallenge(requests, authInfo, this._scopeChallengeResolver); + return challenge === undefined ? undefined : createScopeChallengeResponse(challenge, scopeChallengeResourceMetadataUrl(authInfo)); + } + /** * Validates request headers for DNS rebinding protection. * @returns Error response if validation fails, `undefined` if validation passes. @@ -856,6 +877,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/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 new file mode 100644 index 0000000000..246d5a049c --- /dev/null +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -0,0 +1,292 @@ +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 { 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'; + +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[], resourceMetadataUrl?: string): AuthInfo { + return { token: 'token', clientId: 'client', scopes, ...(resourceMetadataUrl !== undefined && { resourceMetadataUrl }) }; +} + +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'); + for (const scope of ['repo read', 'repo"read', String.raw`repo\read`, 'repo:read🚀']) { + expect(() => requireScopes(scope)).toThrow('scope-token grammar'); + } + }); +}); + +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; + 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 + }); + 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'], RESOURCE_METADATA_URL) } + ); + + 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'], RESOURCE_METADATA_URL) + }); + 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()).toEqual({ + error: 'insufficient_scope', + error_description: 'Insufficient scope' + }); + 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[]] })), + 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); + 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('serializes an optional challenge description', async () => { + const harness = await createLegacyHarness(() => ({ + scopes: ['repo:read'], + 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('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?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?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(); + }); +}); diff --git a/packages/server/test/server/scopeChallengeModern.test.ts b/packages/server/test/server/scopeChallengeModern.test.ts new file mode 100644 index 0000000000..9ef717735d --- /dev/null +++ b/packages/server/test/server/scopeChallengeModern.test.ts @@ -0,0 +1,272 @@ +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 { requireBearerAuth } from '../../src/server/middleware/bearerAuth'; +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 candidateName = method === 'resources/read' ? params.uri : params.name; + const name = typeof candidateName === 'string' ? candidateName : 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[], resourceMetadataUrl?: string): AuthInfo { + return { token: 'token', clientId: 'client', scopes, ...(resourceMetadataUrl !== undefined && { resourceMetadataUrl }) }; +} + +function createHandler(scopeChallenge: ScopeChallengeHandler, onCall = vi.fn(), responseMode?: 'json' | 'sse') { + // No handler-level scope-challenge configuration exists: registering the + // callback on the primitive is all it takes to arm the preflight. + 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 }) } + ); +} + +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); + // 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(await response.json()).toEqual({ + error: 'insufficient_scope', + error_description: 'Insufficient scope' + }); + 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; + }); + + 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; + }); + + 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(); + }); + + 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; + }); + + 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(); + }); +}); + +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 new file mode 100644 index 0000000000..852fa39c62 --- /dev/null +++ b/packages/server/test/server/scopeChallengePrimitives.test.ts @@ -0,0 +1,195 @@ +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'; + +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 + }); + 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(); + }); +}); diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 425b4d6647..25926ba2a7 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,12 @@ 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) +}; +const modernHandler = createMcpHandler(() => createMcpServer(), modernHandlerOptions); const modernNodeHandler = toNodeHandler(modernHandler); /** Normalize a possibly-repeated HTTP header to its first value. */ @@ -1401,6 +1423,38 @@ 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], + // 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([ + ...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 +1463,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 +1614,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`);