diff --git a/.changeset/sep-2243-std-header-server.md b/.changeset/sep-2243-std-header-server.md new file mode 100644 index 0000000000..1ffbc57341 --- /dev/null +++ b/.changeset/sep-2243-std-header-server.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server': minor +--- + +SEP-2243 standard-header server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now enforces the required `Mcp-Method` and `Mcp-Name` standard request headers in addition to the existing `MCP-Protocol-Version` and `Mcp-Method` cross-checks: a modern request without an `Mcp-Method` header, a `tools/call` / `prompts/get` / `resources/read` request without an `Mcp-Name` header, an `Mcp-Name` header carrying an invalid `=?base64?…?=` sentinel, and an `Mcp-Name` header whose (decoded) value disagrees with the body's `params.name` / `params.uri` are all rejected with `400 Bad Request` and JSON-RPC `-32001` (`HeaderMismatch`). The 2025-era serving paths are unchanged. + +New public surface: + +- `@modelcontextprotocol/core`: `validateStandardRequestHeaders` (function), `MCP_NAME_HEADER_SOURCE` (const), the `mcpNameHeader` field on `InboundHttpRequest`, and the `'standard-header-validation'` member of `InboundValidationRung` (with `client-capabilities` / `param-header-validation` renumbered). diff --git a/docs/migration.md b/docs/migration.md index 5c5c63a8bb..c78eaf9fc0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1056,16 +1056,16 @@ versionNegotiation: { } ``` -`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an -already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). +`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an already-constructed +instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting). -Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass -in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. +Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass in a +request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision. On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize` -handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and -other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that -protocol revision. +handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other +long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol +revision. ### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler` @@ -1140,6 +1140,10 @@ skips the reserved standard/auth header names so a per-request header cannot ove `Client.listTools()` (and the client's internal `tools/list` cache) exclude tool definitions whose `x-mcp-header` declarations violate the spec's constraints, logging a warning naming the tool and the reason. Browser clients skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS); calling an `x-mcp-header` tool with a non-null designated argument from a browser against a conforming SEP-2243 server is therefore a known limitation. +On the modern path, `createMcpHandler` also validates the SEP-2243 **standard** request-metadata headers against the body and rejects with the same `400` / `-32001` (`HeaderMismatch`) when the `MCP-Protocol-Version` or `Mcp-Method` header disagrees with the body, when the +required `Mcp-Method` header is absent, when the required `Mcp-Name` header is absent on a `tools/call` / `prompts/get` / `resources/read` request, and when the (Base64-sentinel-decoded) `Mcp-Name` value disagrees with `params.name` / `params.uri`. These checks only fire on the +modern (2026-07-28) serving path — 2025-era traffic is unchanged — and a hand-built modern HTTP request must carry the `Mcp-Method` (and where applicable `Mcp-Name`) header; the SDK client already sends them. + ### Serving the 2026-07-28 draft revision on stdio: `serveStdio` The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that @@ -1213,9 +1217,11 @@ const handler = createMcpHandler(() => buildServer()); handler.notify.toolsChanged(); ``` -**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers -fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler` -registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers fire +on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves +once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if +you still want events); change notifications dispatch to the existing `setNotificationHandler` registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter +instead. ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver diff --git a/examples/json-response/client.ts b/examples/json-response/client.ts index 147d91f097..d047a0a59d 100644 --- a/examples/json-response/client.ts +++ b/examples/json-response/client.ts @@ -18,7 +18,8 @@ runClient('json-response', async () => { headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', - 'mcp-protocol-version': '2026-07-28' + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': 'tools/list' }, body: JSON.stringify({ jsonrpc: '2.0', diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 440422c130..9c7495a57c 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -332,10 +332,9 @@ export class StreamableHTTPClientTransport implements Transport { // non-ASCII name/URI (or one with leading/trailing whitespace, // control characters, or CR/LF) cannot make `Headers.set()` throw a // TypeError or silently normalize to a value that differs from the - // body. The spec's value-encoding rules apply to `Mcp-Name`; this - // SDK's server does not yet cross-check `Mcp-Name` against the body - // (tracked in expected-failures.yaml) — when it does it will decode - // the sentinel before comparison. + // body. The spec's value-encoding rules apply to `Mcp-Name`; the SDK + // server's `validateStandardRequestHeaders` decodes the sentinel via + // `decodeMcpParamValue` before the `Mcp-Name` ↔ body cross-check. const params = message.params as { name?: unknown; uri?: unknown } | undefined; const nameHeader = message.method === 'resources/read' diff --git a/packages/core/src/shared/inboundClassification.ts b/packages/core/src/shared/inboundClassification.ts index 800d65941e..4f4be395cb 100644 --- a/packages/core/src/shared/inboundClassification.ts +++ b/packages/core/src/shared/inboundClassification.ts @@ -69,6 +69,12 @@ import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors. import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js'; import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js'; import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js'; +// Value encoding is shared between the standard `Mcp-Name` header and the +// custom `Mcp-Param-*` headers; the codec module already imports the +// `HeaderMismatch` constant and rejection type from here, so this is a benign +// two-module cycle (both sides only consume the other's exports inside +// function bodies, never at module-evaluation time). +import { decodeMcpParamValue } from './mcpParamHeaders.js'; import { isModernProtocolVersion } from './protocolEras.js'; /* ------------------------------------------------------------------------ * @@ -88,6 +94,8 @@ export interface InboundHttpRequest { protocolVersionHeader?: string; /** The value of the `Mcp-Method` header, when present. */ mcpMethodHeader?: string; + /** The value of the `Mcp-Name` header, when present. */ + mcpNameHeader?: string; /** The parsed JSON request body (`undefined` for body-less methods). */ body?: unknown; } @@ -161,6 +169,7 @@ export type InboundValidationRung = | 'envelope' | 'method-registry' | 'request-params' + | 'standard-header-validation' | 'client-capabilities' | 'param-header-validation'; @@ -305,9 +314,25 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor rationale: 'Per-method params validation; emitted in-band by the dispatch layer (HTTP 200), never via the ladder status table.' }, { - rung: 'client-capabilities', + rung: 'standard-header-validation', order: 7, evaluatedAt: 'pre-dispatch', + codes: [HEADER_MISMATCH_ERROR_CODE], + conformance: ['http-header-validation'], + rationale: + 'SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers — presence, sentinel decoding, and `Mcp-Name` ↔ body cross-check ' + + '— are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before ' + + 'dispatch. The classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' + + '`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. Evaluated before the ' + + 'capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by ' + + 'the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the ' + + 'observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request ' + + 'that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted.' + }, + { + rung: 'client-capabilities', + order: 8, + evaluatedAt: 'pre-dispatch', codes: [ProtocolErrorCode.MissingRequiredClientCapability], conformance: ['server-stateless'], rationale: @@ -320,7 +345,7 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor }, { rung: 'param-header-validation', - order: 8, + order: 9, evaluatedAt: 'pre-dispatch', codes: [HEADER_MISMATCH_ERROR_CODE], conformance: ['http-custom-header-server-validation'], @@ -396,9 +421,14 @@ function rejection( }; } -function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection { +function crossCheckMismatch( + cell: string, + header: string, + body: string, + rung: InboundValidationRung = 'era-classification' +): InboundLadderRejection { return rejection( - 'era-classification', + rung, cell, 400, new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, { @@ -408,6 +438,110 @@ function crossCheckMismatch(cell: string, header: string, body: string): Inbound ); } +/** + * The methods whose body carries a `params.name` / `params.uri` value the + * `Mcp-Name` header must mirror, and which body field supplies it (SEP-2243 + * § Standard Request Headers, `Required For` column). + */ +export const MCP_NAME_HEADER_SOURCE: Readonly> = { + 'tools/call': 'name', + 'prompts/get': 'name', + 'resources/read': 'uri' +}; + +/** + * SEP-2243 standard-header server-side validation, evaluated by the HTTP + * entry on a modern-classified request immediately after + * {@linkcode classifyInboundRequest} returns a modern route. + * + * Returns the `-32001` (`HeaderMismatch`) ladder rejection (HTTP `400`, + * `standard-header-validation` rung — the same shape + * {@linkcode classifyInboundRequest} already emits on the edge + * `era-classification` rung for the `MCP-Protocol-Version` and + * `Mcp-Method` *mismatch* cells) when: + * + * - the required `Mcp-Method` header is absent; + * - the required `Mcp-Name` header is absent on a `tools/call`, + * `prompts/get`, or `resources/read` request whose body carries the + * `params.name` / `params.uri` value the header mirrors; + * - the `Mcp-Name` header carries an invalid `=?base64?…?=` sentinel; or + * - the (decoded) `Mcp-Name` value disagrees with the body's + * `params.name` / `params.uri`. + * + * Returns `undefined` (pass) for notifications (the spec table reads + * "All requests"), for methods that have no `Mcp-Name` source, and when the + * headers agree with the body. Never enforced on legacy traffic — the entry + * only calls this on a modern route. + * + * Kept separate from {@linkcode classifyInboundRequest} so that a body-only + * call to the classifier (no headers passed) keeps routing a modern request + * unchanged: the classifier remains a pure body-primary router, and this + * function is the presence/`Mcp-Name` half of the standard-header rung the + * entry layers on top. + */ +export function validateStandardRequestHeaders(request: InboundHttpRequest, route: InboundModernRoute): InboundLadderRejection | undefined { + if (route.messageKind !== 'request') { + return undefined; + } + const method = route.message.method; + + if (request.mcpMethodHeader === undefined) { + return crossCheckMismatch( + 'method-header-missing', + '(missing)', + `the body names method ${method} but the required Mcp-Method header is absent`, + 'standard-header-validation' + ); + } + + // `method` is the JSON-RPC method string from the body — peer-controlled, + // so guard the plain-object lookup against `Object.prototype` collisions + // (`constructor`, `toString`, …) the same way the client-capability table + // lookup does. + const sourceField = Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) ? MCP_NAME_HEADER_SOURCE[method] : undefined; + if (sourceField === undefined) { + return undefined; + } + const params = route.message.params as Record | undefined; + const sourceValue = params?.[sourceField]; + const bodyValue = typeof sourceValue === 'string' ? sourceValue : undefined; + + if (request.mcpNameHeader === undefined) { + // The header is required for these methods whenever the body carries + // the source value. A body without `params.name`/`params.uri` is a + // params-validation failure further down the ladder; this rung only + // answers the missing-header case it can observe. + if (bodyValue === undefined) { + return undefined; + } + return crossCheckMismatch( + 'name-header-missing', + '(missing)', + `the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent`, + 'standard-header-validation' + ); + } + + const decoded = decodeMcpParamValue(request.mcpNameHeader); + if (decoded === undefined) { + return crossCheckMismatch( + 'name-header-invalid-encoding', + request.mcpNameHeader, + 'the Mcp-Name header carries an invalid Base64 sentinel value', + 'standard-header-validation' + ); + } + if (bodyValue !== undefined && decoded !== bodyValue) { + return crossCheckMismatch( + 'name-header-mismatch', + request.mcpNameHeader, + `the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`, + 'standard-header-validation' + ); + } + return undefined; +} + function isPlainObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } diff --git a/packages/core/test/shared/standardHeaderValidation.test.ts b/packages/core/test/shared/standardHeaderValidation.test.ts new file mode 100644 index 0000000000..97baadb407 --- /dev/null +++ b/packages/core/test/shared/standardHeaderValidation.test.ts @@ -0,0 +1,191 @@ +/** + * SEP-2243 standard-header server-side validation + * (`validateStandardRequestHeaders`). + * + * Evaluated by the HTTP entry on a modern-classified request immediately + * after `classifyInboundRequest` returns a modern route: rejects `400` / + * `-32001` (`HeaderMismatch`) when the required `Mcp-Method` header is + * absent, when the required `Mcp-Name` header is absent on a `tools/call` / + * `prompts/get` / `resources/read` request, when the `Mcp-Name` header + * carries an invalid Base64 sentinel, and when its (decoded) value disagrees + * with the body's `params.name` / `params.uri`. Never enforced on + * notifications or on methods without an `Mcp-Name` source. + * + * The classifier itself is left unchanged by these rungs (it stays a + * body-primary router that passes a modern request through when no headers + * are supplied) — this function is the presence/`Mcp-Name` half of the + * standard-header rung the entry layers on top, so the existing + * `inboundClassification` and cell-sheet tests stay byte-untouched. + */ +import { describe, expect, test } from 'vitest'; + +import type { InboundHttpRequest, InboundLadderRejection, InboundModernRoute } from '../../src/shared/inboundClassification.js'; +import { classifyInboundRequest, MCP_NAME_HEADER_SOURCE, validateStandardRequestHeaders } from '../../src/shared/inboundClassification.js'; +import { encodeMcpParamValue } from '../../src/shared/mcpParamHeaders.js'; +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants.js'; + +const MODERN = '2026-07-28'; +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN, + [CLIENT_INFO_META_KEY]: { name: 'std-header-test', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} +}; + +function modernPost( + method: string, + params: Record, + headers: { mcpMethod?: string; mcpName?: string } = {} +): { request: InboundHttpRequest; route: InboundModernRoute } { + const request: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + ...(headers.mcpMethod !== undefined && { mcpMethodHeader: headers.mcpMethod }), + ...(headers.mcpName !== undefined && { mcpNameHeader: headers.mcpName }), + body: { jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(request); + if (outcome.kind !== 'modern') { + throw new Error(`expected a modern route, got ${outcome.kind}`); + } + return { request, route: outcome }; +} + +function expectRejection(result: InboundLadderRejection | undefined, cell: string): void { + expect(result).toBeDefined(); + expect(result?.kind).toBe('reject'); + expect(result?.cell).toBe(cell); + expect(result?.rung).toBe('standard-header-validation'); + expect(result?.httpStatus).toBe(400); + expect(result?.code).toBe(-32_001); + expect(result?.settled).toBe(true); +} + +describe('SEP-2243 standard-header validation (Mcp-Method presence)', () => { + test('a modern request without an Mcp-Method header is rejected (method-header-missing)', () => { + const { request, route } = modernPost('tools/list', {}); + expectRejection(validateStandardRequestHeaders(request, route), 'method-header-missing'); + }); + + test('a present Mcp-Method header passes for a method with no Mcp-Name source', () => { + const { request, route } = modernPost('tools/list', {}, { mcpMethod: 'tools/list' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Method mismatch cell stays inside classifyInboundRequest (precedence over presence)', () => { + // The mismatch is answered by the classifier itself; this function + // never sees a route for that input. Asserted here so the + // standard-header rung's two halves stay observably ordered. + const inbound: InboundHttpRequest = { + httpMethod: 'POST', + protocolVersionHeader: MODERN, + mcpMethodHeader: 'prompts/list', + body: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: ENVELOPE } } + }; + const outcome = classifyInboundRequest(inbound); + expect(outcome.kind).toBe('reject'); + expect((outcome as InboundLadderRejection).cell).toBe('method-header-mismatch'); + }); + + test('notifications are never enforced', () => { + const route: InboundModernRoute = { + kind: 'modern', + messageKind: 'notification', + message: { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 1 } }, + classification: { era: 'modern', revision: MODERN } + }; + expect(validateStandardRequestHeaders({ httpMethod: 'POST' }, route)).toBeUndefined(); + }); +}); + +describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check)', () => { + test('a tools/call without an Mcp-Name header is rejected (name-header-missing)', () => { + const { request, route } = modernPost('tools/call', { name: 'echo', arguments: {} }, { mcpMethod: 'tools/call' }); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-missing'); + }); + + test('a resources/read without an Mcp-Name header is rejected and names params.uri', () => { + const { request, route } = modernPost('resources/read', { uri: 'file:///a' }, { mcpMethod: 'resources/read' }); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-missing'); + expect(result?.message).toContain('params.uri'); + }); + + test('a tools/call whose body has no params.name passes the Mcp-Name presence rung', () => { + // The missing `params.name` is a request-params failure further down + // the ladder; this rung only answers what it can observe. + const { request, route } = modernPost('tools/call', { arguments: {} }, { mcpMethod: 'tools/call' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('an Mcp-Name header disagreeing with params.name is rejected (name-header-mismatch)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: 'wrong_tool_name' } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect((result?.data as { mismatch?: { header?: string } })?.mismatch?.header).toBe('wrong_tool_name'); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (matching)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'Hello, 世界', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('Hello, 世界') } + ); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a Base64-sentinel Mcp-Name decodes before comparison (mismatch names the decoded value)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: encodeMcpParamValue('not-echo') } + ); + const result = validateStandardRequestHeaders(request, route); + expectRejection(result, 'name-header-mismatch'); + expect(result?.message).toContain('"not-echo"'); + }); + + test('an invalid Base64 sentinel in Mcp-Name is rejected (name-header-invalid-encoding)', () => { + const { request, route } = modernPost( + 'tools/call', + { name: 'echo', arguments: {} }, + { mcpMethod: 'tools/call', mcpName: '=?base64?SGVs!!!bG8=?=' } + ); + expectRejection(validateStandardRequestHeaders(request, route), 'name-header-invalid-encoding'); + }); + + test('a matching Mcp-Name on a prompts/get passes', () => { + const { request, route } = modernPost('prompts/get', { name: 'greeting' }, { mcpMethod: 'prompts/get', mcpName: 'greeting' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('a matching Mcp-Name on a resources/read compares against params.uri', () => { + const uri = 'file:///projects/app/config.json'; + const { request, route } = modernPost('resources/read', { uri }, { mcpMethod: 'resources/read', mcpName: uri }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); + + test('the Mcp-Name source map covers exactly the spec table', () => { + expect(MCP_NAME_HEADER_SOURCE).toEqual({ 'tools/call': 'name', 'prompts/get': 'name', 'resources/read': 'uri' }); + }); + + test('a method colliding with Object.prototype members is treated as off-table (passes through to dispatch)', () => { + // `constructor` would return Object.prototype.constructor on a bare + // lookup; the Object.hasOwn guard keeps the early-return firing. + const { request, route } = modernPost('constructor', {}, { mcpMethod: 'constructor', mcpName: '=?base64?!!?=' }); + expect(validateStandardRequestHeaders(request, route)).toBeUndefined(); + }); +}); + +describe('classifyInboundRequest is unchanged by the standard-header presence rung', () => { + test('a body-only modern request (no headers passed) still routes modern', () => { + const outcome = classifyInboundRequest({ + httpMethod: 'POST', + body: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'echo', arguments: {}, _meta: ENVELOPE } } + }); + expect(outcome.kind).toBe('modern'); + }); +}); diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 5d02871a6e..4f51c2ccc3 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -52,7 +52,8 @@ import { setNegotiatedProtocolVersion, SUPPORTED_MODERN_PROTOCOL_VERSIONS, UnsupportedProtocolVersionError, - validateMcpParamHeaders + validateMcpParamHeaders, + validateStandardRequestHeaders } from '@modelcontextprotocol/core'; import { invoke } from './invoke.js'; @@ -471,6 +472,7 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno httpMethod, protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined, mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined, ...(body !== undefined && { body }) }); return { step: 'classified', outcome, body, parsedBody, forwardRequest }; @@ -644,6 +646,30 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa return jsonRpcErrorResponse(400, error.code, error.message, error.data, echoableRequestId(route.message)); } + // SEP-2243 standard-header presence and `Mcp-Name` cross-check + // (`standard-header-validation` rung; the `MCP-Protocol-Version` and + // `Mcp-Method` *mismatch* cells are already answered inside + // `classifyInboundRequest` on the edge `era-classification` rung). + // Evaluated after the supported-revision + // gate so an envelope naming a revision this endpoint does not serve + // is still answered with `-32004` (the supported list is the more + // useful answer to a client speaking the wrong revision); evaluated + // before the capability gate, the factory call, and the + // `Mcp-Param-*` rung so a request that fails several rungs is + // answered by the standard-header rung first. + const stdHeaderRejection = validateStandardRequestHeaders( + { + httpMethod: request.method, + mcpMethodHeader: request.headers.get('mcp-method') ?? undefined, + mcpNameHeader: request.headers.get('mcp-name') ?? undefined + }, + route + ); + if (stdHeaderRejection !== undefined) { + reportError(new Error(`Rejected inbound request (${stdHeaderRejection.cell}): ${stdHeaderRejection.message}`)); + return rejectionResponse(stdHeaderRejection, echoableRequestId(route.message)); + } + const meta = route.messageKind === 'request' ? requestMetaOf(route.message.params) : undefined; const declaredClientCapabilities = meta?.[CLIENT_CAPABILITIES_META_KEY] as ClientCapabilities | undefined; diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 0a069376ac..ee5e8dcf9e 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -41,12 +41,30 @@ function modernToolsCall(name: string, args: Record, envelope: }; } +/** + * The SEP-2243 standard headers a conformant client derives from the body it + * sends. Only emitted for a body carrying a modern envelope claim, so legacy + * test cells stay byte-untouched; spread before any explicit `headers` so a + * caller that needs to test a stripped or disagreeing header can override. + */ +function bodyDerivedStandardHeaders(body: unknown): Record { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; + const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; + if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; + const out: Record = {}; + if (typeof b.method === 'string') out['mcp-method'] = b.method; + const name = b.method === 'resources/read' ? b.params.uri : b.params.name; + if (typeof name === 'string') out['mcp-name'] = name; + return out; +} + function postRequest(body: unknown, headers: Record = {}): Request { return new Request('http://localhost/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body), ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) @@ -782,6 +800,7 @@ describe('createMcpHandler — handler faces', () => { const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); await handler.node(req, res, parsed); expect(res.statusCode).toBe(200); expect(await body()).toContain('pre-parsed'); @@ -939,7 +958,8 @@ function nodeRequestResponse(body: unknown): { headers: { host: 'localhost:3000', 'content-type': 'application/json', - accept: 'application/json, text/event-stream' + accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body) } as Record }); diff --git a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts index 3decb71b60..9d40e8e66f 100644 --- a/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts +++ b/packages/server/test/server/createMcpHandlerCapabilityGate.test.ts @@ -33,7 +33,12 @@ const envelope = (clientCapabilities: ClientCapabilities) => ({ function postEcho(clientCapabilities: ClientCapabilities): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'echo' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 7, diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 479e17b905..980e7e7578 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -27,7 +27,11 @@ const ENVELOPE = { function listenRequest(id: string | number, filter: Record): Request { return new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, @@ -165,7 +169,11 @@ describe('createMcpHandler — subscriptions/listen', () => { const response = await handler.fetch( new Request('http://localhost/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'subscriptions/listen', params: { _meta: ENVELOPE } }) }) ); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 015d63203d..8c49d92b49 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -52,6 +52,7 @@ function call(args: Record, paramHeaders: Record McpServer { + return () => { + const s = new McpServer({ name: 'std-header-server', version: '1.0.0' }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string().optional() }) }, async ({ text }) => ({ + content: [{ type: 'text', text: text ?? 'ok' }] + })); + return s; + }; +} + +function modernRequest(method: string, params: Record, headers: Record = {}): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': MODERN, + ...headers + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 5, method, params: { ...params, _meta: ENVELOPE } }) + }); +} + +async function expectHeaderMismatch(response: Response): Promise<{ code: number; message: string }> { + expect(response.status).toBe(400); + const body = (await response.json()) as { id: unknown; error: { code: number; message: string } }; + expect(body.id).toBe(5); + expect(body.error.code).toBe(-32_001); + return body.error; +} + +describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', () => { + it('a fully conformant tools/call passes and dispatches', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: { text: 'hi' } }, { 'mcp-method': 'tools/call', 'mcp-name': 'echo' }) + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0]?.text).toBe('hi'); + }); + + it('a missing Mcp-Method header is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch(await handler.fetch(modernRequest('tools/list', {}))); + expect(error.message).toContain('Mcp-Method header is absent'); + }); + + it('a missing Mcp-Name header on tools/call is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch(modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call' })) + ); + expect(error.message).toContain('Mcp-Name header is absent'); + }); + + it('an Mcp-Name header disagreeing with params.name is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + const error = await expectHeaderMismatch( + await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': 'wrong' }) + ) + ); + expect(error.message).toContain('Mcp-Name header names "wrong"'); + }); + + it('Mcp-Name accepts an OWS-padded value (RFC 9110 §5.5; Fetch Headers normalises)', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest('tools/call', { name: 'echo', arguments: {} }, { 'mcp-method': 'tools/call', 'mcp-name': ' echo ' }) + ); + expect(response.status).toBe(200); + }); + + it('Mcp-Name decodes a Base64 sentinel before comparison', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': encodeMcpParamValue('echo') } + ) + ); + // `encodeMcpParamValue('echo')` is plain ASCII, so the sentinel is not + // applied; assert the explicit-sentinel case below instead. + expect(response.status).toBe(200); + const sentinel = await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': `=?base64?${Buffer.from('echo').toString('base64')}?=` } + ) + ); + expect(sentinel.status).toBe(200); + }); + + it('an invalid Mcp-Name Base64 sentinel is rejected 400/-32001', async () => { + const handler = createMcpHandler(makeFactory()); + await expectHeaderMismatch( + await handler.fetch( + modernRequest( + 'tools/call', + { name: 'echo', arguments: {} }, + { 'mcp-method': 'tools/call', 'mcp-name': '=?base64?SGVsbG8?=' } + ) + ) + ); + }); + + it('Mcp-Name is not required for methods outside its source map', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch(modernRequest('tools/list', {}, { 'mcp-method': 'tools/list' })); + expect(response.status).toBe(200); + }); +}); + +describe('SEP-2243 standard-header validation is era-gated', () => { + it('legacy traffic is byte-untouched: a 2025-era initialize without standard headers still serves', async () => { + const handler = createMcpHandler(makeFactory()); + const response = await handler.fetch( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 5, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'c', version: '1' }, capabilities: {} } + }) + }) + ); + // The default 'stateless' legacy posture answers initialize. + expect(response.status).toBe(200); + }); +}); diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index dc675aff4d..3a5194f209 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -73,10 +73,3 @@ server: # (WARNING-only; the expected-failures evaluator counts WARNINGs as # failures). Same failure as in the 2025 baseline. - sep-2164-resource-not-found - - # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- - # SEP-2243 (HTTP header standardization): the reject cells the SDK does - # answer now use -32001 (HeaderMismatch), but missing-header enforcement - # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, - # so those reject cells are still accepted with 200. - - http-header-validation diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index 3a178dcf9c..9fed2dee25 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -46,11 +46,6 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # SEP-2243 (HTTP header standardization): the reject cells the SDK does - # answer now use -32001 (HeaderMismatch), but missing-header enforcement - # (Mcp-Method, Mcp-Name) and the Mcp-Name cross-check are not implemented, - # so those reject cells are still accepted with 200. - - http-header-validation # WARNING-only entry: the scenario emits no FAILURE checks, only a SHOULD-level # WARNING, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 7f58a901da..5789be6c73 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2604,6 +2604,14 @@ export const REQUIREMENTS: Record = { addedInSpecVersion: '2026-07-28', note: 'Runs on the entryModern arm; the Mcp-Param-{Name} header is asserted on the arm-recorded HTTP request headers and the encoded value is checked against the SEP-2243 codec.' }, + 'sep-2243:std-header:mismatch-rejected': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers', + behavior: + 'A 2026-07-28 request whose Mcp-Method header disagrees with the JSON-RPC method in the body is rejected by the createMcpHandler entry with HTTP 400 carrying a JSON-RPC error with the SEP-2243 HeaderMismatch code.', + transports: ['entryModern'], + addedInSpecVersion: '2026-07-28', + note: 'Runs on the entryModern arm; the body POSTs a raw envelope-carrying tools/call with an Mcp-Method: tools/list header through wired.fetch and asserts the 400 status and the HeaderMismatch error code on the response bytes.' + }, // Multi round-trip requests (SEP-2322, protocol revision 2026-07-28) 'typescript:mrtr:tools-call:write-once-roundtrip': { source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr', diff --git a/test/e2e/scenarios/hosting-entry-session.test.ts b/test/e2e/scenarios/hosting-entry-session.test.ts index a41a1b8bbb..2a09b95d42 100644 --- a/test/e2e/scenarios/hosting-entry-session.test.ts +++ b/test/e2e/scenarios/hosting-entry-session.test.ts @@ -169,7 +169,12 @@ verifies('typescript:hosting:entry:byo-sessionful-legacy', async () => { const exchangesBeforeModernProbe = legacyExchanges.length; const modernProbe = await fetchViaRouter(url, { method: 'POST', - headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' }, + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/call', + 'mcp-name': 'greet' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 100, diff --git a/test/e2e/scenarios/sep2243.test.ts b/test/e2e/scenarios/sep2243.test.ts index 60beb935e5..8d3ab17f92 100644 --- a/test/e2e/scenarios/sep2243.test.ts +++ b/test/e2e/scenarios/sep2243.test.ts @@ -10,7 +10,7 @@ import { encodeMcpParamValue, MCP_PARAM_HEADER_PREFIX } from '@modelcontextproto import { fromJsonSchema, McpServer } from '@modelcontextprotocol/server'; import { expect } from 'vitest'; -import { wire } from '../helpers/index.js'; +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; @@ -58,3 +58,34 @@ verifies('sep-2243:param-header:roundtrip', async ({ transport }: TestArgs) => { expect(result.isError).toBeFalsy(); expect(result.content).toEqual([{ type: 'text', text: 'region=us-west1' }]); }); + +verifies('sep-2243:std-header:mismatch-rejected', async ({ transport }: TestArgs) => { + const makeServer = () => new McpServer({ name: 'e2e-sep2243-std', version: '1.0.0' }, { capabilities: { tools: {} } }); + const client = new Client({ name: 'sep2243-std-client', version: '1.0.0' }); + await using wired = await wire(transport, makeServer, client); + + // Raw POST through the harness-hosted entry: the body is a valid + // envelope-carrying tools/call, but the Mcp-Method header names + // tools/list. The era-classification rung answers the disagreement + // before any factory instance is constructed. + const response = await wired.fetch!(wired.url!, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-method': 'tools/list' + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'locate', arguments: {}, _meta: modernEnvelopeMeta({ name: 'sep2243-std-client', version: '1.0.0' }) } + }) + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: { code: number; message: string } }; + // -32001 is the SEP-2243 HeaderMismatch code at this branch's spec pin. + expect(body.error.code).toBe(-32_001); + expect(body.error.message).toMatch(/Mcp-Method/); +}); diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index bbca6105d5..cd9fea8981 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -42,7 +42,11 @@ verifies('subscriptions:listen:ack-first-stamped', async () => { const response = await handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1', @@ -140,7 +144,11 @@ verifies('subscriptions:listen:capacity-guard', async () => { handler.fetch( new Request('http://in-process/mcp', { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id, diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index ae2a30aac8..6ce066ed1e 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -169,7 +169,11 @@ describe('createMcpHandler over HTTP — subscriptions/listen honored filter', ( const response = await fetch(new URL('/mcp', baseUrl), { method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-method': 'subscriptions/listen' + }, body: JSON.stringify({ jsonrpc: '2.0', id: 'sub-1',