Skip to content

Commit 5ea86f8

Browse files
feat(client,server): Mcp-Method/Mcp-Name request headers (SEP-2243)
1 parent b0d286d commit 5ea86f8

6 files changed

Lines changed: 152 additions & 2 deletions

File tree

packages/client/src/client/streamableHttp.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
isJSONRPCRequest,
99
isJSONRPCResultResponse,
1010
JSONRPCMessageSchema,
11+
encodeMcpHeaderValue,
12+
mcpNameForMethod,
1113
normalizeHeaders,
1214
SdkError,
1315
SdkErrorCode
@@ -544,6 +546,15 @@ export class StreamableHTTPClientTransport implements Transport {
544546
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream'];
545547
headers.set('accept', [...new Set(types)].join(', '));
546548

549+
// SEP-2243: mirror method (and name/uri for tools/call, resources/read, prompts/get)
550+
// into HTTP headers so intermediaries can route without body inspection. Only
551+
// applied to single-message POSTs since batch bodies have no single method.
552+
if (!Array.isArray(message) && 'method' in message) {
553+
headers.set('mcp-method', encodeMcpHeaderValue(message.method));
554+
const name = mcpNameForMethod(message.method, message.params);
555+
if (name !== undefined) headers.set('mcp-name', encodeMcpHeaderValue(name));
556+
}
557+
547558
const init = {
548559
...this._requestInit,
549560
method: 'POST',

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './errors/sdkErrors.js';
33
export * from './shared/auth.js';
44
export * from './shared/authUtils.js';
55
export * from './shared/dispatcher.js';
6+
export * from './shared/httpHeaders.js';
67
export * from './shared/metadataUtils.js';
78
export * from './shared/protocol.js';
89
export * from './shared/responseMessage.js';
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { JSONRPCRequest } from '../types/index.js';
2+
3+
/**
4+
* SEP-2243: Methods whose `Mcp-Name` header mirrors a request body field, and which field.
5+
* Exposed so the client transport (sets headers) and server transports (validate them)
6+
* agree on the source field.
7+
*/
8+
const NAME_FIELD_FOR: Record<string, 'name' | 'uri'> = {
9+
'tools/call': 'name',
10+
'prompts/get': 'name',
11+
'resources/read': 'uri'
12+
};
13+
14+
/**
15+
* Returns the SEP-2243 `Mcp-Name` value for a request body, or `undefined` if the method
16+
* has no name-level field.
17+
*/
18+
export function mcpNameForMethod(method: string, params: unknown): string | undefined {
19+
const field = NAME_FIELD_FOR[method];
20+
if (!field || !params || typeof params !== 'object') return undefined;
21+
const v = (params as Record<string, unknown>)[field];
22+
return typeof v === 'string' ? v : undefined;
23+
}
24+
25+
// HTTP header values must be ISO-8859-1. SEP-2243 specifies RFC-2047-style encoding
26+
// (`=?base64?<b64>?=`) for values containing characters outside the safe-header range.
27+
const HEADER_SAFE = /^[ -~]*$/;
28+
29+
/** Encode a value for use as an `Mcp-*` HTTP header per SEP-2243 (RFC-2047 base64 for non-ASCII). */
30+
export function encodeMcpHeaderValue(value: string): string {
31+
if (HEADER_SAFE.test(value)) return value;
32+
// Byte-level mapping for btoa: each Uint8 byte must become one Latin-1 char.
33+
// eslint-disable-next-line unicorn/prefer-code-point
34+
const b64 = btoa(String.fromCharCode(...new TextEncoder().encode(value)));
35+
return `=?base64?${b64}?=`;
36+
}
37+
38+
/** Decode an `Mcp-*` HTTP header value, reversing {@linkcode encodeMcpHeaderValue}. */
39+
export function decodeMcpHeaderValue(value: string): string {
40+
const m = /^=\?base64\?(.+)\?=$/.exec(value);
41+
if (!m) return value;
42+
// atob output is one Latin-1 char per byte; charCodeAt gives the byte value back.
43+
// eslint-disable-next-line unicorn/prefer-code-point
44+
const bytes = Uint8Array.from(atob(m[1]!), c => c.charCodeAt(0));
45+
return new TextDecoder().decode(bytes);
46+
}
47+
48+
/**
49+
* SEP-2243 server-side enforcement: returns a header-mismatch error message if the supplied
50+
* `Mcp-Method` / `Mcp-Name` headers do not match the body, or `undefined` if they match
51+
* (or are absent). Per the spec, headers are required for compliance with the version they
52+
* are introduced in; this validator only rejects on PRESENT-but-mismatched, since absence
53+
* may indicate a pre-SEP-2243 client. Batch bodies are not validated (no single method).
54+
*/
55+
export function validateMcpHeaders(httpReq: Request, body: JSONRPCRequest | JSONRPCRequest[]): string | undefined {
56+
if (Array.isArray(body)) return undefined;
57+
const hMethodRaw = httpReq.headers.get('mcp-method');
58+
const hMethod = hMethodRaw === null ? null : decodeMcpHeaderValue(hMethodRaw);
59+
if (hMethod !== null && hMethod !== body.method) {
60+
return `Mcp-Method header '${hMethod}' does not match request body method '${body.method}'`;
61+
}
62+
const hNameRaw = httpReq.headers.get('mcp-name');
63+
if (hNameRaw !== null) {
64+
const hName = decodeMcpHeaderValue(hNameRaw);
65+
const bodyName = mcpNameForMethod(body.method, body.params);
66+
if (hName !== bodyName) {
67+
return `Mcp-Name header '${hName}' does not match request body ${NAME_FIELD_FOR[body.method] ?? 'name'} '${bodyName ?? '(absent)'}'`;
68+
}
69+
}
70+
return undefined;
71+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import { mcpNameForMethod, validateMcpHeaders } from '../../src/shared/httpHeaders.js';
4+
import type { JSONRPCRequest } from '../../src/types/index.js';
5+
6+
function req(headers: Record<string, string>): Request {
7+
return new Request('http://x/mcp', { method: 'POST', headers });
8+
}
9+
function body(method: string, params?: Record<string, unknown>): JSONRPCRequest {
10+
return { jsonrpc: '2.0', id: 1, method, params };
11+
}
12+
13+
describe('mcpNameForMethod (SEP-2243)', () => {
14+
test('returns name for tools/call and prompts/get', () => {
15+
expect(mcpNameForMethod('tools/call', { name: 'get_weather' })).toBe('get_weather');
16+
expect(mcpNameForMethod('prompts/get', { name: 'summarize' })).toBe('summarize');
17+
});
18+
test('returns uri for resources/read', () => {
19+
expect(mcpNameForMethod('resources/read', { uri: 'file:///a' })).toBe('file:///a');
20+
});
21+
test('undefined for other methods', () => {
22+
expect(mcpNameForMethod('tools/list', {})).toBeUndefined();
23+
expect(mcpNameForMethod('initialize', {})).toBeUndefined();
24+
});
25+
});
26+
27+
describe('validateMcpHeaders (SEP-2243)', () => {
28+
test('absent headers always pass', () => {
29+
expect(validateMcpHeaders(req({}), body('tools/call', { name: 'x' }))).toBeUndefined();
30+
});
31+
test('matching headers pass', () => {
32+
expect(validateMcpHeaders(req({ 'mcp-method': 'tools/call', 'mcp-name': 'x' }), body('tools/call', { name: 'x' }))).toBeUndefined();
33+
});
34+
test('mismatched mcp-method fails', () => {
35+
expect(validateMcpHeaders(req({ 'mcp-method': 'tools/list' }), body('tools/call', { name: 'x' }))).toMatch(/Mcp-Method header/);
36+
});
37+
test('mismatched mcp-name fails', () => {
38+
expect(validateMcpHeaders(req({ 'mcp-method': 'tools/call', 'mcp-name': 'wrong' }), body('tools/call', { name: 'x' }))).toMatch(
39+
/Mcp-Name header/
40+
);
41+
});
42+
test('batch bodies are not validated', () => {
43+
expect(validateMcpHeaders(req({ 'mcp-method': 'anything' }), [body('tools/call'), body('ping')])).toBeUndefined();
44+
});
45+
});

packages/server/src/server/shttpHandler.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
isJSONRPCRequest,
1717
isJSONRPCResultResponse,
1818
JSONRPCMessageSchema,
19-
SUPPORTED_PROTOCOL_VERSIONS
19+
SUPPORTED_PROTOCOL_VERSIONS,
20+
validateMcpHeaders
2021
} from '@modelcontextprotocol/core';
2122

2223
import type { SessionCompat } from './sessionCompat.js';
@@ -257,6 +258,17 @@ export function shttpHandler(
257258
}
258259

259260
const requests = messages.filter(m => isJSONRPCRequest(m));
261+
262+
// SEP-2243: reject if Mcp-Method/Mcp-Name headers (when present) don't match the body.
263+
// Prevents header/body source-of-truth split between intermediaries and the handler.
264+
if (!isBatch && requests.length === 1) {
265+
const headerMismatch = validateMcpHeaders(req, requests[0]!);
266+
if (headerMismatch) {
267+
onerror?.(new Error(headerMismatch));
268+
return jsonError(400, -32_001, `Bad Request: ${headerMismatch}`);
269+
}
270+
}
271+
260272
const notifications = messages.filter(m => isJSONRPCNotification(m));
261273
const responses = messages.filter(
262274
(m): m is JSONRPCResultResponse | JSONRPCErrorResponse => isJSONRPCResultResponse(m) || isJSONRPCErrorResponse(m)

packages/server/src/server/streamableHttp.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
isJSONRPCRequest,
1616
isJSONRPCResultResponse,
1717
JSONRPCMessageSchema,
18-
SUPPORTED_PROTOCOL_VERSIONS
18+
SUPPORTED_PROTOCOL_VERSIONS,
19+
validateMcpHeaders
1920
} from '@modelcontextprotocol/core';
2021

2122
export type StreamId = string;
@@ -661,6 +662,15 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
661662
return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON-RPC message');
662663
}
663664

665+
// SEP-2243: Mcp-Method/Mcp-Name headers (when present) must match the body.
666+
if (!Array.isArray(rawMessage) && messages.length === 1 && isJSONRPCRequest(messages[0]!)) {
667+
const headerMismatch = validateMcpHeaders(req, messages[0]);
668+
if (headerMismatch) {
669+
this.onerror?.(new Error(headerMismatch));
670+
return this.createJsonErrorResponse(400, -32_001, `Bad Request: ${headerMismatch}`);
671+
}
672+
}
673+
664674
// Check if this is an initialization request
665675
// https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/lifecycle/
666676
const isInitializationRequest = messages.some(element => isInitializeRequest(element));

0 commit comments

Comments
 (0)