From 2b6f3ab602567b8c029a1a039e0fe78259619048 Mon Sep 17 00:00:00 2001 From: Sacha Ichbiah Date: Thu, 29 Oct 2026 18:51:55 +0100 Subject: [PATCH 1/4] feat(server): let a low-level Server take part in HTTP preflight checks createMcpHandler ran SEP-2243 Mcp-Param-* header validation and OAuth scope challenges only when the factory returned an McpServer, so a factory returning a low-level Server skipped both without notice. Server gains setToolInputSchemaResolver and setScopeChallengeHandler. McpServer sets both on its own Server from its registry, and the entries read them from whichever instance the factory returns. Server.connect now installs the scope challenge resolver on a Streamable HTTP transport, so McpServer.connect no longer does it itself. Refs #3126 Co-Authored-By: Claude Opus 5.5 --- .changeset/low-level-server-preflight.md | 5 ++ docs/advanced/low-level-server.md | 2 + packages/server/src/index.ts | 2 +- .../server/src/server/createMcpHandler.ts | 26 ++++++----- packages/server/src/server/mcp.ts | 6 +-- packages/server/src/server/server.ts | 46 +++++++++++++++++++ .../test/server/mcpParamValidation.test.ts | 34 ++++++++++++++ .../server/test/server/scopeChallenge.test.ts | 24 ++++++++++ .../test/server/scopeChallengeModern.test.ts | 27 +++++++++++ 9 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 .changeset/low-level-server-preflight.md diff --git a/.changeset/low-level-server-preflight.md b/.changeset/low-level-server-preflight.md new file mode 100644 index 0000000000..f48fba0a4d --- /dev/null +++ b/.changeset/low-level-server-preflight.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +A low-level `Server` can now take part in the HTTP pre-dispatch checks that were only applied to `McpServer`: `setToolInputSchemaResolver(resolver)` gives `createMcpHandler` the tool input schemas for SEP-2243 `Mcp-Param-*` header validation, and `setScopeChallengeHandler(handler)` answers OAuth scope challenges in `createMcpHandler` and the Streamable HTTP transport. `McpServer` sets both on its own `Server`, so its behavior is unchanged. Without them a low-level `Server` is served as before. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index d8aef345eb..2d7e2e1414 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -124,6 +124,8 @@ createMcpHandler(buildServer); Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md) — applies to this server unchanged. +Two HTTP checks run before dispatch and need to know your primitives: `Mcp-Param-*` headers are compared with a tool's `x-mcp-header` declarations, and a request can be answered with a `403 insufficient_scope` [scope challenge](../serving/authorization.md). `McpServer` supplies both from its registry. On `Server`, call `setToolInputSchemaResolver(name => schema)` with the JSON Schema your `tools/list` advertises, and `setScopeChallengeHandler(callback)` with a callback shaped like a registration's `scopeChallenge`, consulted for every request. Without them, the HTTP entries skip these checks. + For a custom HTTP entry built from `classifyInboundRequest` and `PerRequestHTTPServerTransport`, list the modern revision in the server's `supportedProtocolVersions`: `server.connect(transport)` then binds the instance to the revision the transport was classified at, and a modern revision the server does not list, or the SDK does not implement, is answered with `-32022`. Read the client's identity from `ctx.mcpReq.envelope`; the deprecated `getClientVersion()` and `getClientCapabilities()` stay empty on such an instance. Use a fresh instance for each per-request exchange, as the built-in HTTP entry does. ## Reach the low level from `McpServer` diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 32605dd7e7..4e5fc96388 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -58,7 +58,7 @@ export { PerRequestHTTPServerTransport } from './server/perRequestTransport'; // convenience codec consumers drop into ServerOptions.requestState.verify. export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec'; export { createRequestStateCodec } from './server/requestStateCodec'; -export type { ServerOptions } from './server/server'; +export type { ServerOptions, ToolInputSchemaResolver } from './server/server'; export { Server } from './server/server'; // subscriptions/listen change-event sourcing seam (protocol revision 2026-07-28). export type { ServerEvent, ServerEventBus, ServerNotifier } from './server/serverEventBus'; diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 4ef836c636..f0f0364ad7 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -66,7 +66,7 @@ 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 { installModernOnlyHandlers, preflightHooksOf, seedClientIdentityFromEnvelope, serverIdentityOf } from './server'; import type { ServerEventBus, ServerNotifier } from './serverEventBus'; import { createServerNotifier, InMemoryServerEventBus } from './serverEventBus'; import { DEFAULT_SSE_KEEP_ALIVE_MS } from './sseKeepAlive'; @@ -820,14 +820,15 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // declaration against the request's `Mcp-Param-{Name}` headers and the // body `arguments`. A mismatch (or a missing header for a present body // value, or an invalid Base64 sentinel) emits the same `400` / - // `-32020` (`HeaderMismatch`) shape the edge cross-checks use. Only - // applied when the factory returns an `McpServer` (the registry is the - // schema source); a low-level `Server` factory has no registry, so - // there is nothing to validate against. - if (route.messageKind === 'request' && route.message.method === 'tools/call' && product instanceof McpServer) { + // `-32020` (`HeaderMismatch`) shape the edge cross-checks use. The + // schema source is the instance's tool input schema resolver: + // `McpServer` sets it from its registry, a low-level `Server` through + // `setToolInputSchemaResolver`; without one there is nothing to check. + const preflight = preflightHooksOf(server); + if (route.messageKind === 'request' && route.message.method === 'tools/call' && preflight.toolInputSchema !== undefined) { const callParams = route.message.params as { name?: string; arguments?: Record } | undefined; const toolName = typeof callParams?.name === 'string' ? callParams.name : undefined; - const inputSchema = toolName === undefined ? undefined : product.toolInputSchemaJson(toolName); + const inputSchema = toolName === undefined ? undefined : preflight.toolInputSchema(toolName); if (inputSchema !== undefined) { const scan = scanXMcpHeaderDeclarations(inputSchema); // A tool that is invalid only for a `number`-typed declaration is still checked. @@ -843,14 +844,17 @@ 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 + // the body. Active whenever the factory's instance has a scope + // challenge handler — `McpServer` sets one that consults its + // per-primitive scopeChallenge callbacks, a low-level `Server` sets its + // own through `setScopeChallengeHandler`; 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) { + const scopeChallenge = preflight.scopeChallenge; + if (route.messageKind === 'request' && scopeChallenge !== undefined) { try { - const challenge = await findScopeChallenge([route.message], authInfo, context => product.resolveScopeChallenge(context)); + const challenge = await findScopeChallenge([route.message], authInfo, scopeChallenge); if (challenge !== undefined) { void product.close().catch(reportError); return createScopeChallengeResponse(challenge, scopeChallengeResourceMetadataUrl(authInfo)); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index bfb3b798b0..e5ffbb8c29 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -48,7 +48,6 @@ 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'; @@ -151,6 +150,8 @@ export class McpServer { constructor(serverInfo: Implementation, options?: McpServerOptions) { this.server = new Server(serverInfo, options); + this.server.setScopeChallengeHandler(context => this.resolveScopeChallenge(context)); + this.server.setToolInputSchemaResolver(name => this.toolInputSchemaJson(name)); this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements); // Per the MCP spec, a server that declares a primitive capability MUST respond to its @@ -182,9 +183,6 @@ export class McpServer { * ``` */ async connect(transport: Transport): Promise { - if (supportsScopeChallengeResolver(transport)) { - transport.setScopeChallengeResolver(context => this.resolveScopeChallenge(context)); - } return await this.server.connect(transport); } diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 441857f646..0051d35c6c 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -67,6 +67,8 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims' import { coerceEmbeddedInputRequest, LegacyInputRequiredShim, resolveLegacyShimOptions } from './legacyInputRequiredShim'; import { perRequestClassificationOf } from './perRequestTransport'; +import type { ScopeChallengeHandler } from './scopeChallenge'; +import { supportsScopeChallengeResolver } from './scopeChallenge'; /** * The request methods whose 2026-07-28 result vocabulary includes @@ -210,6 +212,16 @@ export type ServerOptions = ProtocolOptions & { let writeClientIdentity: (server: Server, identity: PerRequestClientIdentity) => void; let installDiscoverHandler: (server: Server, servedModernVersions: readonly string[]) => void; let readServerIdentity: (server: Server) => Implementation; +let readPreflightHooks: (server: Server) => PreflightHooks; + +/** The callbacks the HTTP entry consults before dispatching a request to an instance. */ +export interface PreflightHooks { + scopeChallenge?: ScopeChallengeHandler; + toolInputSchema?: ToolInputSchemaResolver; +} + +/** Looks up a tool's JSON `inputSchema`, as `tools/list` emits it, or answers `undefined` for an unknown tool. */ +export type ToolInputSchemaResolver = (name: string) => Record | undefined; /** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */ export interface PerRequestClientIdentity { @@ -254,6 +266,11 @@ export function serverIdentityOf(server: Server): Implementation { return readServerIdentity(server); } +/** Package-internal: the instance's pre-dispatch callbacks, for the HTTP entry's preflight. Not public API. */ +export function preflightHooksOf(server: Server): PreflightHooks { + return readPreflightHooks(server); +} + /** * An MCP server on top of a pluggable transport. * @@ -284,7 +301,10 @@ export class Server extends Protocol { server.setRequestHandler('server/discover', () => server._ondiscover()); }; readServerIdentity = server => server._serverInfo; + readPreflightHooks = server => ({ scopeChallenge: server._scopeChallenge, toolInputSchema: server._toolInputSchema }); } + private _scopeChallenge?: ScopeChallengeHandler; + private _toolInputSchema?: ToolInputSchemaResolver; private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -377,9 +397,35 @@ export class Server extends Protocol { this._negotiatedProtocolVersion = revision; this._boundFromTransport = true; } + const scopeChallenge = this._scopeChallenge; + if (scopeChallenge !== undefined && supportsScopeChallengeResolver(transport)) { + transport.setScopeChallengeResolver(context => scopeChallenge(context)); + } await super.connect(transport); } + /** + * Sets the callback that decides, before a request is dispatched, whether it + * needs an OAuth scope challenge (`403 insufficient_scope`). The HTTP entries + * consult it for every request this instance serves. This is the low-level + * counterpart of the per-primitive `scopeChallenge` option of + * {@linkcode server/mcp.McpServer | McpServer}, which sets it itself. + */ + setScopeChallengeHandler(handler: ScopeChallengeHandler): void { + this._scopeChallenge = handler; + } + + /** + * Sets the lookup `createMcpHandler` uses to validate SEP-2243 `Mcp-Param-*` + * headers on a `tools/call` before dispatch: the named tool's JSON + * `inputSchema`, as this instance's `tools/list` emits it, or `undefined` + * for an unknown tool. {@linkcode server/mcp.McpServer | McpServer} sets it + * itself. + */ + setToolInputSchemaResolver(resolver: ToolInputSchemaResolver): void { + this._toolInputSchema = resolver; + } + /** * Registers the built-in `logging/setLevel` request handler. * diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 15f78c32eb..cdd0b08967 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -22,6 +22,7 @@ import { fromJsonSchema } from '../../src/fromJsonSchema'; import { createMcpHandler } from '../../src/server/createMcpHandler'; import { invoke } from '../../src/server/invoke'; import { McpServer } from '../../src/server/mcp'; +import { Server } from '../../src/server/server'; const MODERN = '2026-07-28'; const ENVELOPE = { @@ -143,6 +144,39 @@ describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)' }); }); +describe('SEP-2243 Mcp-Param-* server validation for a low-level Server', () => { + function lowLevelFactory(withResolver: boolean, onCall = vi.fn()): () => Server { + return () => { + const s = new Server({ name: 'param-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + s.setRequestHandler('tools/call', async request => { + onCall(request.params.arguments); + return { content: [] }; + }); + if (withResolver) s.setToolInputSchemaResolver(name => (name === 'route' ? REGION_INPUT_SCHEMA : undefined)); + return s; + }; + } + + it('validates headers against the schema its resolver returns', async () => { + const onCall = vi.fn(); + const handler = createMcpHandler(lowLevelFactory(true, onCall)); + const matching = await handler.fetch(call({ region: 'us-west1' }, { 'Mcp-Param-Region': 'us-west1' })); + expect(matching.status).toBe(200); + const mismatched = await handler.fetch(call({ region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' })); + expect(mismatched.status).toBe(400); + const body = (await mismatched.json()) as { error: { code: number; data?: { mismatch?: { header?: string } } } }; + expect(body.error.code).toBe(-32_020); + expect(body.error.data?.mismatch?.header).toBe('Mcp-Param-Region'); + expect(onCall).toHaveBeenCalledTimes(1); + }); + + it('checks nothing without a resolver', async () => { + const handler = createMcpHandler(lowLevelFactory(false)); + const response = await handler.fetch(call({ region: 'us-west1' }, { 'Mcp-Param-Region': 'eu' })); + expect(response.status).toBe(200); + }); +}); + describe('SEP-2243 registerTool declaration-validity check', () => { it('warns on an invalid x-mcp-header declaration when tools are listed, not at registration', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index 246d5a049c..af35a6622a 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -7,6 +7,7 @@ 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 { Server } from '../../src/server/server'; import { WebStandardStreamableHTTPServerTransport } from '../../src/server/streamableHttp'; const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource'; @@ -289,4 +290,27 @@ describe('legacy Streamable HTTP scope preflight', () => { expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata'); await harness.transport.close(); }); + + it('challenges a low-level Server through its scope challenge handler', async () => { + const calls = vi.fn(); + const server = new Server({ name: 'scope-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => { + calls(); + return { content: [] }; + }); + server.setScopeChallengeHandler(requireScopes('repo:write')); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + await server.connect(transport); + const sessionId = await initializeLegacy(transport); + + const response = await transport.handleRequest(legacyRequest(toolCall('operate'), sessionId), { authInfo: auth(['repo:read']) }); + + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain('scope="repo:write"'); + expect(calls).not.toHaveBeenCalled(); + await transport.close(); + }); }); diff --git a/packages/server/test/server/scopeChallengeModern.test.ts b/packages/server/test/server/scopeChallengeModern.test.ts index 9ef717735d..1660e77d23 100644 --- a/packages/server/test/server/scopeChallengeModern.test.ts +++ b/packages/server/test/server/scopeChallengeModern.test.ts @@ -9,6 +9,7 @@ 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'; +import { Server } from '../../src/server/server'; const MODERN = '2026-07-28'; const RESOURCE_METADATA_URL = 'https://auth.example.com/.well-known/oauth-protected-resource'; @@ -204,6 +205,32 @@ describe('createMcpHandler scope preflight', () => { }); }); +describe('createMcpHandler scope preflight for a low-level Server', () => { + it('challenges through the handler set with setScopeChallengeHandler', async () => { + const onCall = vi.fn(); + const handler = createMcpHandler(() => { + const server = new Server({ name: 'modern-scope', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => { + onCall(); + return { content: [] }; + }); + server.setScopeChallengeHandler(requireScopes('repo:write')); + return server; + }); + + const denied = await handler.fetch(call('operate', {}), { authInfo: auth(['repo:read']) }); + expect(denied.status).toBe(403); + expect(denied.headers.get('WWW-Authenticate')).toBe( + 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="repo:write"' + ); + expect(onCall).not.toHaveBeenCalled(); + + const allowed = await handler.fetch(call('operate', {}), { authInfo: auth(['repo:write']) }); + expect(allowed.status).toBe(200); + expect(onCall).toHaveBeenCalledTimes(1); + }); +}); + describe('scope challenge resource_metadata derivation', () => { it('advertises the metadata URL stamped onto the auth info', async () => { const handler = createHandler(requireScopes('repo:write')); From 47235afe1c35007149834286ca0a84f826aa9005 Mon Sep 17 00:00:00 2001 From: Sacha Ichbiah Date: Thu, 29 Oct 2026 23:16:36 +0100 Subject: [PATCH 2/4] fix(server): honor a scope challenge handler set after connect() Server.connect installed the handler it had at connect time, so on the Streamable HTTP transport a handler set afterwards was ignored while createMcpHandler honored it. The transport now gets a resolver that reads the handler per request. The docs also say the tool input schema resolver must return the schema tools/list emits. Co-Authored-By: Claude Opus 5.5 --- docs/advanced/low-level-server.md | 2 +- packages/server/src/server/server.ts | 16 ++++++----- .../server/test/server/scopeChallenge.test.ts | 27 ++++++++++++++----- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 2d7e2e1414..ad27e7d634 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -124,7 +124,7 @@ createMcpHandler(buildServer); Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md) — applies to this server unchanged. -Two HTTP checks run before dispatch and need to know your primitives: `Mcp-Param-*` headers are compared with a tool's `x-mcp-header` declarations, and a request can be answered with a `403 insufficient_scope` [scope challenge](../serving/authorization.md). `McpServer` supplies both from its registry. On `Server`, call `setToolInputSchemaResolver(name => schema)` with the JSON Schema your `tools/list` advertises, and `setScopeChallengeHandler(callback)` with a callback shaped like a registration's `scopeChallenge`, consulted for every request. Without them, the HTTP entries skip these checks. +Two HTTP checks run before dispatch and need to know your primitives: `Mcp-Param-*` headers are compared with a tool's `x-mcp-header` declarations, and a request can be answered with a `403 insufficient_scope` [scope challenge](../serving/authorization.md). `McpServer` supplies both from its registry. On `Server`, call `setToolInputSchemaResolver(name => schema)` and return exactly the `inputSchema` your `tools/list` advertises for that tool: headers are validated against what it returns, so a different schema checks them against one clients never saw. Also call `setScopeChallengeHandler(callback)` with a callback shaped like a registration's `scopeChallenge`, consulted for every request. Without them, the HTTP entries skip these checks. For a custom HTTP entry built from `classifyInboundRequest` and `PerRequestHTTPServerTransport`, list the modern revision in the server's `supportedProtocolVersions`: `server.connect(transport)` then binds the instance to the revision the transport was classified at, and a modern revision the server does not list, or the SDK does not implement, is answered with `-32022`. Read the client's identity from `ctx.mcpReq.envelope`; the deprecated `getClientVersion()` and `getClientCapabilities()` stay empty on such an instance. Use a fresh instance for each per-request exchange, as the built-in HTTP entry does. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 0051d35c6c..4dfe14c582 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -397,9 +397,9 @@ export class Server extends Protocol { this._negotiatedProtocolVersion = revision; this._boundFromTransport = true; } - const scopeChallenge = this._scopeChallenge; - if (scopeChallenge !== undefined && supportsScopeChallengeResolver(transport)) { - transport.setScopeChallengeResolver(context => scopeChallenge(context)); + // Read at request time, so a handler set after connect() still applies. + if (supportsScopeChallengeResolver(transport)) { + transport.setScopeChallengeResolver(context => this._scopeChallenge?.(context)); } await super.connect(transport); } @@ -407,7 +407,8 @@ export class Server extends Protocol { /** * Sets the callback that decides, before a request is dispatched, whether it * needs an OAuth scope challenge (`403 insufficient_scope`). The HTTP entries - * consult it for every request this instance serves. This is the low-level + * consult it for every request this instance serves, including when it is + * set after `connect()`. This is the low-level * counterpart of the per-primitive `scopeChallenge` option of * {@linkcode server/mcp.McpServer | McpServer}, which sets it itself. */ @@ -418,9 +419,10 @@ export class Server extends Protocol { /** * Sets the lookup `createMcpHandler` uses to validate SEP-2243 `Mcp-Param-*` * headers on a `tools/call` before dispatch: the named tool's JSON - * `inputSchema`, as this instance's `tools/list` emits it, or `undefined` - * for an unknown tool. {@linkcode server/mcp.McpServer | McpServer} sets it - * itself. + * `inputSchema`, or `undefined` for an unknown tool. It must return the + * same schema this instance's `tools/list` emits, since clients derive the + * headers from that one. {@linkcode server/mcp.McpServer | McpServer} sets + * it itself. */ setToolInputSchemaResolver(resolver: ToolInputSchemaResolver): void { this._toolInputSchema = resolver; diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index af35a6622a..f56db33be9 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -291,26 +291,39 @@ describe('legacy Streamable HTTP scope preflight', () => { await harness.transport.close(); }); - it('challenges a low-level Server through its scope challenge handler', async () => { + async function lowLevelCall(handler: 'before connect' | 'after connect' | 'none') { const calls = vi.fn(); const server = new Server({ name: 'scope-test', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/call', async () => { calls(); return { content: [] }; }); - server.setScopeChallengeHandler(requireScopes('repo:write')); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true }); + if (handler === 'before connect') server.setScopeChallengeHandler(requireScopes('repo:write')); await server.connect(transport); + if (handler === 'after connect') server.setScopeChallengeHandler(requireScopes('repo:write')); const sessionId = await initializeLegacy(transport); - const response = await transport.handleRequest(legacyRequest(toolCall('operate'), sessionId), { authInfo: auth(['repo:read']) }); - - expect(response.status).toBe(403); - expect(response.headers.get('WWW-Authenticate')).toContain('scope="repo:write"'); - expect(calls).not.toHaveBeenCalled(); await transport.close(); + return { response, calls }; + } + + it.each(['before connect', 'after connect'] as const)( + 'challenges a low-level Server whose scope challenge handler is set %s', + async handler => { + const { response, calls } = await lowLevelCall(handler); + expect(response.status).toBe(403); + expect(response.headers.get('WWW-Authenticate')).toContain('scope="repo:write"'); + expect(calls).not.toHaveBeenCalled(); + } + ); + + it('dispatches to a low-level Server without a scope challenge handler', async () => { + const { response, calls } = await lowLevelCall('none'); + expect(response.status).toBe(200); + expect(calls).toHaveBeenCalledTimes(1); }); }); From dbb28352c95eeb3253b37bf63ae7e3f9df8185ef Mon Sep 17 00:00:00 2001 From: Sacha Ichbiah Date: Thu, 29 Oct 2026 23:40:37 +0100 Subject: [PATCH 3/4] docs(server): say the preflight setters replace earlier values setScopeChallengeHandler and setToolInputSchemaResolver replace any previous value, like setRequestHandler; a replaced scope challenge handler applies from the next request, including on an open Streamable HTTP session, which a new test pins. Co-Authored-By: Claude Opus 5.5 --- packages/server/src/server/server.ts | 9 +++++---- .../server/test/server/scopeChallenge.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 4dfe14c582..c5e552a512 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -408,8 +408,9 @@ export class Server extends Protocol { * Sets the callback that decides, before a request is dispatched, whether it * needs an OAuth scope challenge (`403 insufficient_scope`). The HTTP entries * consult it for every request this instance serves, including when it is - * set after `connect()`. This is the low-level - * counterpart of the per-primitive `scopeChallenge` option of + * set after `connect()`. Note that this will replace any previous handler, + * from the next request on. This is the low-level counterpart of the + * per-primitive `scopeChallenge` option of * {@linkcode server/mcp.McpServer | McpServer}, which sets it itself. */ setScopeChallengeHandler(handler: ScopeChallengeHandler): void { @@ -421,8 +422,8 @@ export class Server extends Protocol { * headers on a `tools/call` before dispatch: the named tool's JSON * `inputSchema`, or `undefined` for an unknown tool. It must return the * same schema this instance's `tools/list` emits, since clients derive the - * headers from that one. {@linkcode server/mcp.McpServer | McpServer} sets - * it itself. + * headers from that one. Note that this will replace any previous resolver. + * {@linkcode server/mcp.McpServer | McpServer} sets it itself. */ setToolInputSchemaResolver(resolver: ToolInputSchemaResolver): void { this._toolInputSchema = resolver; diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index f56db33be9..ce030cc592 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -321,6 +321,24 @@ describe('legacy Streamable HTTP scope preflight', () => { } ); + it('applies a replaced scope challenge handler to later requests on an open session', async () => { + const server = new Server({ name: 'scope-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [] })); + server.setScopeChallengeHandler(requireScopes('repo:write')); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + await server.connect(transport); + const sessionId = await initializeLegacy(transport); + const call = () => transport.handleRequest(legacyRequest(toolCall('operate'), sessionId), { authInfo: auth(['repo:read']) }); + + expect((await call()).status).toBe(403); + server.setScopeChallengeHandler(requireScopes('repo:read')); + expect((await call()).status).toBe(200); + await transport.close(); + }); + it('dispatches to a low-level Server without a scope challenge handler', async () => { const { response, calls } = await lowLevelCall('none'); expect(response.status).toBe(200); From 31a4a4c1991a43951945180bc0fbe4cf3469fa1f Mon Sep 17 00:00:00 2001 From: Sacha Ichbiah Date: Fri, 30 Oct 2026 12:31:53 +0100 Subject: [PATCH 4/4] fix(server): keep a transport's own scope challenge resolver Server.connect replaced any resolver set directly on a Streamable HTTP transport, even for a low-level Server that never set a handler, so that setup silently stopped answering 403. connect now installs the server's resolver only when a handler is set, and setScopeChallengeHandler installs it on the connected transport when called later. The JSDoc states that each request is checked once, before dispatch, against the handler in place when its check runs. Co-Authored-By: Claude Opus 5.5 --- .changeset/low-level-server-preflight.md | 2 +- packages/server/src/server/server.ts | 22 +++++++++++++------ packages/server/src/server/streamableHttp.ts | 5 +++-- .../server/test/server/scopeChallenge.test.ts | 17 ++++++++++++++ 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/.changeset/low-level-server-preflight.md b/.changeset/low-level-server-preflight.md index f48fba0a4d..6d5e41d857 100644 --- a/.changeset/low-level-server-preflight.md +++ b/.changeset/low-level-server-preflight.md @@ -2,4 +2,4 @@ '@modelcontextprotocol/server': minor --- -A low-level `Server` can now take part in the HTTP pre-dispatch checks that were only applied to `McpServer`: `setToolInputSchemaResolver(resolver)` gives `createMcpHandler` the tool input schemas for SEP-2243 `Mcp-Param-*` header validation, and `setScopeChallengeHandler(handler)` answers OAuth scope challenges in `createMcpHandler` and the Streamable HTTP transport. `McpServer` sets both on its own `Server`, so its behavior is unchanged. Without them a low-level `Server` is served as before. +A low-level `Server` can now take part in the HTTP pre-dispatch checks that were only applied to `McpServer`: `setToolInputSchemaResolver(resolver)` gives `createMcpHandler` the tool input schemas for SEP-2243 `Mcp-Param-*` header validation, and `setScopeChallengeHandler(handler)` answers OAuth scope challenges in `createMcpHandler` and the Streamable HTTP transport. `McpServer` sets both on its own `Server`, so its behavior is unchanged. Without them a low-level `Server` is served as before, and a resolver set directly on a Streamable HTTP transport with `setScopeChallengeResolver` stays in effect; once `setScopeChallengeHandler` is called, the server's handler takes its place. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index c5e552a512..56f692a446 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -397,10 +397,8 @@ export class Server extends Protocol { this._negotiatedProtocolVersion = revision; this._boundFromTransport = true; } - // Read at request time, so a handler set after connect() still applies. - if (supportsScopeChallengeResolver(transport)) { - transport.setScopeChallengeResolver(context => this._scopeChallenge?.(context)); - } + // Without a handler, a resolver set directly on the transport stays in charge. + if (this._scopeChallenge !== undefined) this._installScopeChallenge(transport); await super.connect(transport); } @@ -408,13 +406,23 @@ export class Server extends Protocol { * Sets the callback that decides, before a request is dispatched, whether it * needs an OAuth scope challenge (`403 insufficient_scope`). The HTTP entries * consult it for every request this instance serves, including when it is - * set after `connect()`. Note that this will replace any previous handler, - * from the next request on. This is the low-level counterpart of the - * per-primitive `scopeChallenge` option of + * set after `connect()`; on a Streamable HTTP transport it then takes the + * place of any resolver set on the transport directly. Each request is + * checked once, before dispatch, against the handler in place when its check + * runs. Note that this will replace any previous handler. This is the + * low-level counterpart of the per-primitive `scopeChallenge` option of * {@linkcode server/mcp.McpServer | McpServer}, which sets it itself. */ setScopeChallengeHandler(handler: ScopeChallengeHandler): void { this._scopeChallenge = handler; + if (this.transport !== undefined) this._installScopeChallenge(this.transport); + } + + private _installScopeChallenge(transport: Transport): void { + if (supportsScopeChallengeResolver(transport)) { + // Read per request, so a replaced handler applies from the next request. + transport.setScopeChallengeResolver(context => this._scopeChallenge?.(context)); + } } /** diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index 2484ae19b0..96eae5b13c 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -370,8 +370,9 @@ 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 + // Active whenever a resolver is set: by a connected server with a scope + // challenge handler (McpServer resolves its per-primitive callbacks), + // or directly on this transport; the challenge's // resource_metadata parameter is derived from the verified AuthInfo // and omitted when unavailable. if (!this._scopeChallengeResolver) { diff --git a/packages/server/test/server/scopeChallenge.test.ts b/packages/server/test/server/scopeChallenge.test.ts index ce030cc592..3a6281794f 100644 --- a/packages/server/test/server/scopeChallenge.test.ts +++ b/packages/server/test/server/scopeChallenge.test.ts @@ -339,6 +339,23 @@ describe('legacy Streamable HTTP scope preflight', () => { await transport.close(); }); + it('keeps a resolver set directly on the transport when a low-level Server has no handler', async () => { + const server = new Server({ name: 'scope-test', version: '1.0.0' }, { capabilities: { tools: {} } }); + server.setRequestHandler('tools/call', async () => ({ content: [] })); + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + enableJsonResponse: true + }); + transport.setScopeChallengeResolver(requireScopes('repo:write')); + await server.connect(transport); + const sessionId = await initializeLegacy(transport); + + const response = await transport.handleRequest(legacyRequest(toolCall('operate'), sessionId), { authInfo: auth(['repo:read']) }); + + expect(response.status).toBe(403); + await transport.close(); + }); + it('dispatches to a low-level Server without a scope challenge handler', async () => { const { response, calls } = await lowLevelCall('none'); expect(response.status).toBe(200);