From 045c2ac14541ea0f65f9fb894095f0165f0ce474 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 11:29:54 +0000 Subject: [PATCH 1/3] feat(client,server): Mcp-Method/Mcp-Name request headers (SEP-2243) --- packages/client/src/client/streamableHttp.ts | 11 +++ packages/core/src/index.ts | 1 + packages/core/src/shared/httpHeaders.ts | 71 +++++++++++++++++++ packages/core/test/shared/httpHeaders.test.ts | 45 ++++++++++++ packages/server/src/server/shttpHandler.ts | 14 +++- packages/server/src/server/streamableHttp.ts | 12 +++- 6 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/shared/httpHeaders.ts create mode 100644 packages/core/test/shared/httpHeaders.test.ts diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cd643c96dc..79b6f0f648 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -8,6 +8,8 @@ import { isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, + encodeMcpHeaderValue, + mcpNameForMethod, normalizeHeaders, SdkError, SdkErrorCode @@ -544,6 +546,15 @@ export class StreamableHTTPClientTransport implements Transport { const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; headers.set('accept', [...new Set(types)].join(', ')); + // SEP-2243: mirror method (and name/uri for tools/call, resources/read, prompts/get) + // into HTTP headers so intermediaries can route without body inspection. Only + // applied to single-message POSTs since batch bodies have no single method. + if (!Array.isArray(message) && 'method' in message) { + headers.set('mcp-method', encodeMcpHeaderValue(message.method)); + const name = mcpNameForMethod(message.method, message.params); + if (name !== undefined) headers.set('mcp-name', encodeMcpHeaderValue(name)); + } + const init = { ...this._requestInit, method: 'POST', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1abbd160bc..dedb448ee2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,6 +3,7 @@ export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; export * from './shared/dispatcher.js'; +export * from './shared/httpHeaders.js'; export * from './shared/metadataUtils.js'; export * from './shared/protocol.js'; export * from './shared/responseMessage.js'; diff --git a/packages/core/src/shared/httpHeaders.ts b/packages/core/src/shared/httpHeaders.ts new file mode 100644 index 0000000000..544a712ebe --- /dev/null +++ b/packages/core/src/shared/httpHeaders.ts @@ -0,0 +1,71 @@ +import type { JSONRPCRequest } from '../types/index.js'; + +/** + * SEP-2243: Methods whose `Mcp-Name` header mirrors a request body field, and which field. + * Exposed so the client transport (sets headers) and server transports (validate them) + * agree on the source field. + */ +const NAME_FIELD_FOR: Record = { + 'tools/call': 'name', + 'prompts/get': 'name', + 'resources/read': 'uri' +}; + +/** + * Returns the SEP-2243 `Mcp-Name` value for a request body, or `undefined` if the method + * has no name-level field. + */ +export function mcpNameForMethod(method: string, params: unknown): string | undefined { + const field = NAME_FIELD_FOR[method]; + if (!field || !params || typeof params !== 'object') return undefined; + const v = (params as Record)[field]; + return typeof v === 'string' ? v : undefined; +} + +// HTTP header values must be ISO-8859-1. SEP-2243 specifies RFC-2047-style encoding +// (`=?base64??=`) for values containing characters outside the safe-header range. +const HEADER_SAFE = /^[ -~]*$/; + +/** Encode a value for use as an `Mcp-*` HTTP header per SEP-2243 (RFC-2047 base64 for non-ASCII). */ +export function encodeMcpHeaderValue(value: string): string { + if (HEADER_SAFE.test(value)) return value; + // Byte-level mapping for btoa: each Uint8 byte must become one Latin-1 char. + // eslint-disable-next-line unicorn/prefer-code-point + const b64 = btoa(String.fromCharCode(...new TextEncoder().encode(value))); + return `=?base64?${b64}?=`; +} + +/** Decode an `Mcp-*` HTTP header value, reversing {@linkcode encodeMcpHeaderValue}. */ +export function decodeMcpHeaderValue(value: string): string { + const m = /^=\?base64\?(.+)\?=$/.exec(value); + if (!m) return value; + // atob output is one Latin-1 char per byte; charCodeAt gives the byte value back. + // eslint-disable-next-line unicorn/prefer-code-point + const bytes = Uint8Array.from(atob(m[1]!), c => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +/** + * SEP-2243 server-side enforcement: returns a header-mismatch error message if the supplied + * `Mcp-Method` / `Mcp-Name` headers do not match the body, or `undefined` if they match + * (or are absent). Per the spec, headers are required for compliance with the version they + * are introduced in; this validator only rejects on PRESENT-but-mismatched, since absence + * may indicate a pre-SEP-2243 client. Batch bodies are not validated (no single method). + */ +export function validateMcpHeaders(httpReq: Request, body: JSONRPCRequest | JSONRPCRequest[]): string | undefined { + if (Array.isArray(body)) return undefined; + const hMethodRaw = httpReq.headers.get('mcp-method'); + const hMethod = hMethodRaw === null ? null : decodeMcpHeaderValue(hMethodRaw); + if (hMethod !== null && hMethod !== body.method) { + return `Mcp-Method header '${hMethod}' does not match request body method '${body.method}'`; + } + const hNameRaw = httpReq.headers.get('mcp-name'); + if (hNameRaw !== null) { + const hName = decodeMcpHeaderValue(hNameRaw); + const bodyName = mcpNameForMethod(body.method, body.params); + if (hName !== bodyName) { + return `Mcp-Name header '${hName}' does not match request body ${NAME_FIELD_FOR[body.method] ?? 'name'} '${bodyName ?? '(absent)'}'`; + } + } + return undefined; +} diff --git a/packages/core/test/shared/httpHeaders.test.ts b/packages/core/test/shared/httpHeaders.test.ts new file mode 100644 index 0000000000..12b697952e --- /dev/null +++ b/packages/core/test/shared/httpHeaders.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'vitest'; + +import { mcpNameForMethod, validateMcpHeaders } from '../../src/shared/httpHeaders.js'; +import type { JSONRPCRequest } from '../../src/types/index.js'; + +function req(headers: Record): Request { + return new Request('http://x/mcp', { method: 'POST', headers }); +} +function body(method: string, params?: Record): JSONRPCRequest { + return { jsonrpc: '2.0', id: 1, method, params }; +} + +describe('mcpNameForMethod (SEP-2243)', () => { + test('returns name for tools/call and prompts/get', () => { + expect(mcpNameForMethod('tools/call', { name: 'get_weather' })).toBe('get_weather'); + expect(mcpNameForMethod('prompts/get', { name: 'summarize' })).toBe('summarize'); + }); + test('returns uri for resources/read', () => { + expect(mcpNameForMethod('resources/read', { uri: 'file:///a' })).toBe('file:///a'); + }); + test('undefined for other methods', () => { + expect(mcpNameForMethod('tools/list', {})).toBeUndefined(); + expect(mcpNameForMethod('initialize', {})).toBeUndefined(); + }); +}); + +describe('validateMcpHeaders (SEP-2243)', () => { + test('absent headers always pass', () => { + expect(validateMcpHeaders(req({}), body('tools/call', { name: 'x' }))).toBeUndefined(); + }); + test('matching headers pass', () => { + expect(validateMcpHeaders(req({ 'mcp-method': 'tools/call', 'mcp-name': 'x' }), body('tools/call', { name: 'x' }))).toBeUndefined(); + }); + test('mismatched mcp-method fails', () => { + expect(validateMcpHeaders(req({ 'mcp-method': 'tools/list' }), body('tools/call', { name: 'x' }))).toMatch(/Mcp-Method header/); + }); + test('mismatched mcp-name fails', () => { + expect(validateMcpHeaders(req({ 'mcp-method': 'tools/call', 'mcp-name': 'wrong' }), body('tools/call', { name: 'x' }))).toMatch( + /Mcp-Name header/ + ); + }); + test('batch bodies are not validated', () => { + expect(validateMcpHeaders(req({ 'mcp-method': 'anything' }), [body('tools/call'), body('ping')])).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index cf1ef09c52..692fb7db47 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -16,7 +16,8 @@ import { isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, - SUPPORTED_PROTOCOL_VERSIONS + SUPPORTED_PROTOCOL_VERSIONS, + validateMcpHeaders } from '@modelcontextprotocol/core'; import type { SessionCompat } from './sessionCompat.js'; @@ -257,6 +258,17 @@ export function shttpHandler( } const requests = messages.filter(m => isJSONRPCRequest(m)); + + // SEP-2243: reject if Mcp-Method/Mcp-Name headers (when present) don't match the body. + // Prevents header/body source-of-truth split between intermediaries and the handler. + if (!isBatch && requests.length === 1) { + const headerMismatch = validateMcpHeaders(req, requests[0]!); + if (headerMismatch) { + onerror?.(new Error(headerMismatch)); + return jsonError(400, -32_001, `Bad Request: ${headerMismatch}`); + } + } + const notifications = messages.filter(m => isJSONRPCNotification(m)); const responses = messages.filter( (m): m is JSONRPCResultResponse | JSONRPCErrorResponse => isJSONRPCResultResponse(m) || isJSONRPCErrorResponse(m) diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index fd3563a077..1e01646b4a 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -15,7 +15,8 @@ import { isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema, - SUPPORTED_PROTOCOL_VERSIONS + SUPPORTED_PROTOCOL_VERSIONS, + validateMcpHeaders } from '@modelcontextprotocol/core'; export type StreamId = string; @@ -661,6 +662,15 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON-RPC message'); } + // SEP-2243: Mcp-Method/Mcp-Name headers (when present) must match the body. + if (!Array.isArray(rawMessage) && messages.length === 1 && isJSONRPCRequest(messages[0]!)) { + const headerMismatch = validateMcpHeaders(req, messages[0]); + if (headerMismatch) { + this.onerror?.(new Error(headerMismatch)); + return this.createJsonErrorResponse(400, -32_001, `Bad Request: ${headerMismatch}`); + } + } + // Check if this is an initialization request // https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/ const isInitializationRequest = messages.some(element => isInitializeRequest(element)); From bac06cf253ebddc49cacdb5a41c841006f2feb2a Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 14:27:38 +0000 Subject: [PATCH 2/3] fix(httpHeaders): catch malformed base64 in decodeMcpHeaderValue (return raw so mismatch path applies, not 500) --- packages/core/src/shared/httpHeaders.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/core/src/shared/httpHeaders.ts b/packages/core/src/shared/httpHeaders.ts index 544a712ebe..9be065545b 100644 --- a/packages/core/src/shared/httpHeaders.ts +++ b/packages/core/src/shared/httpHeaders.ts @@ -39,10 +39,17 @@ export function encodeMcpHeaderValue(value: string): string { export function decodeMcpHeaderValue(value: string): string { const m = /^=\?base64\?(.+)\?=$/.exec(value); if (!m) return value; - // atob output is one Latin-1 char per byte; charCodeAt gives the byte value back. - // eslint-disable-next-line unicorn/prefer-code-point - const bytes = Uint8Array.from(atob(m[1]!), c => c.charCodeAt(0)); - return new TextDecoder().decode(bytes); + try { + // atob output is one Latin-1 char per byte; charCodeAt gives the byte value back. + // eslint-disable-next-line unicorn/prefer-code-point + const bytes = Uint8Array.from(atob(m[1]!), c => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } catch { + // Malformed base64 from a misbehaving client. Return the raw value so + // validateMcpHeaders falls through to the 400/-32001 mismatch path + // instead of bubbling a DOMException up to the transport's outer catch. + return value; + } } /** From d7884d6c89c3f50c52fce6625cc4481b240cc0fa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 12 May 2026 17:16:14 +0000 Subject: [PATCH 3/3] fix(shttpHandler): validate Mcp-Method/Mcp-Name headers before session.validate (mismatched initialize must not mint a session) --- packages/server/src/server/shttpHandler.ts | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/server/src/server/shttpHandler.ts b/packages/server/src/server/shttpHandler.ts index 692fb7db47..7e5ce7a86f 100644 --- a/packages/server/src/server/shttpHandler.ts +++ b/packages/server/src/server/shttpHandler.ts @@ -244,6 +244,19 @@ export function shttpHandler( return jsonError(400, -32_700, 'Parse error: Invalid JSON-RPC message'); } + const requests = messages.filter(m => isJSONRPCRequest(m)); + + // SEP-2243: reject if Mcp-Method/Mcp-Name headers (when present) don't match the body. + // Runs BEFORE session.validate so a mismatched initialize cannot mint a session + // (session.validate has side effects: it inserts the entry and fires onsessioninitialized). + if (!isBatch && requests.length === 1) { + const headerMismatch = validateMcpHeaders(req, requests[0]!); + if (headerMismatch) { + onerror?.(new Error(headerMismatch)); + return jsonError(400, -32_001, `Bad Request: ${headerMismatch}`); + } + } + let sessionId: string | undefined; let isInitialize = false; if (session) { @@ -257,18 +270,6 @@ export function shttpHandler( if (protoErr) return protoErr; } - const requests = messages.filter(m => isJSONRPCRequest(m)); - - // SEP-2243: reject if Mcp-Method/Mcp-Name headers (when present) don't match the body. - // Prevents header/body source-of-truth split between intermediaries and the handler. - if (!isBatch && requests.length === 1) { - const headerMismatch = validateMcpHeaders(req, requests[0]!); - if (headerMismatch) { - onerror?.(new Error(headerMismatch)); - return jsonError(400, -32_001, `Bad Request: ${headerMismatch}`); - } - } - const notifications = messages.filter(m => isJSONRPCNotification(m)); const responses = messages.filter( (m): m is JSONRPCResultResponse | JSONRPCErrorResponse => isJSONRPCResultResponse(m) || isJSONRPCErrorResponse(m)