diff --git a/.changeset/low-level-server-preflight.md b/.changeset/low-level-server-preflight.md new file mode 100644 index 0000000000..6d5e41d857 --- /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, 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/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index d8aef345eb..ad27e7d634 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)` 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. ## 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..56f692a446 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,46 @@ export class Server extends Protocol { this._negotiatedProtocolVersion = revision; this._boundFromTransport = true; } + // Without a handler, a resolver set directly on the transport stays in charge. + if (this._scopeChallenge !== undefined) this._installScopeChallenge(transport); 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, including when it is + * 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)); + } + } + + /** + * Sets the lookup `createMcpHandler` uses to validate SEP-2243 `Mcp-Param-*` + * 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. Note that this will replace any previous resolver. + * {@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/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/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..3a6281794f 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,75 @@ describe('legacy Streamable HTTP scope preflight', () => { expect(response.headers.get('WWW-Authenticate')).not.toContain('resource_metadata'); await harness.transport.close(); }); + + 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: [] }; + }); + 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']) }); + 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('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('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); + expect(calls).toHaveBeenCalledTimes(1); + }); }); 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'));