Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/low-level-server-preflight.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
26 changes: 15 additions & 11 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown> } | 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.
Expand All @@ -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));
Expand Down
6 changes: 2 additions & 4 deletions packages/server/src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -182,9 +183,6 @@ export class McpServer {
* ```
*/
async connect(transport: Transport): Promise<void> {
if (supportsScopeChallengeResolver(transport)) {
transport.setScopeChallengeResolver(context => this.resolveScopeChallenge(context));
}
return await this.server.connect(transport);
}

Expand Down
57 changes: 57 additions & 0 deletions packages/server/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> | undefined;

/** Connection-scoped client-identity fields backfilled per request from a validated `_meta` envelope. */
export interface PerRequestClientIdentity {
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -284,7 +301,10 @@ export class Server extends Protocol<ServerContext> {
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;
Expand Down Expand Up @@ -377,9 +397,46 @@ export class Server extends Protocol<ServerContext> {
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.
*
Expand Down
5 changes: 3 additions & 2 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,9 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
}

private async _checkScopeChallenge(messages: JSONRPCMessage[], authInfo?: AuthInfo): Promise<Response | undefined> {
// 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) {
Expand Down
34 changes: 34 additions & 0 deletions packages/server/test/server/mcpParamValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading