Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
9 changes: 9 additions & 0 deletions .changeset/tasks-mcp-name-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Emit and validate the `Mcp-Name` header for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrors `params.taskId` into `Mcp-Name` on `tasks/get` / `tasks/update` / `tasks/cancel` (previously omitted, causing conforming servers to reject every task poll with `-32020 HeaderMismatch`), and the server-side standard-header validation cross-checks it via the same shared `MCP_NAME_HEADER_SOURCE` table.

On the server, `createMcpHandler` now answers a modern (2026-07-28) `tasks/get` / `tasks/update` / `tasks/cancel` POST that omits `Mcp-Name`, or whose header disagrees with `params.taskId`, with `400` / `-32020` (`HeaderMismatch`) at the `standard-header-validation` rung, the same treatment `tools/call` / `prompts/get` / `resources/read` already get. Legacy-era (2025-11-25) tasks traffic is unaffected. Clients built with this SDK release send the header; hand-rolled clients that omitted it must add it.
4 changes: 3 additions & 1 deletion docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,9 @@ JSON-RPC `-32020` (`HeaderMismatch`). The Streamable HTTP transport also emits t
validates the SEP-2243 standard headers (`MCP-Protocol-Version`, `Mcp-Method`,
`Mcp-Name`) against the body on the modern path with the same rejection — both their
**presence** (all three are required on every modern **request** POST; `Mcp-Name` only
for the methods that mirror `params.name` / `params.uri`) and their agreement with the
for the methods that mirror `params.name` / `params.uri`, plus `params.taskId` on
`tasks/get` / `tasks/update` / `tasks/cancel` per SEP-2663's Streamable HTTP binding) and
their agreement with the
body. A modern-enveloped request POST that omits `MCP-Protocol-Version` is refused rather
than served, even though the body claim alone still determines the era — so a hand-rolled
client that relied on the body envelope without sending the header must add it. Clients
Expand Down
26 changes: 12 additions & 14 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isJSONRPCResultResponse,
isModernProtocolVersion,
JSONRPCMessageSchema,
mcpNameSource,
mediaTypeEssence,
normalizeHeaders,
PROTOCOL_VERSION_META_KEY,
Expand Down Expand Up @@ -481,26 +482,23 @@ export class StreamableHTTPClientTransport implements Transport {
headers.set('mcp-protocol-version', envelopeVersion);
headers.set('mcp-method', message.method);
// SEP-2243 standard headers, step 2 of the 5-step client algorithm:
// Mcp-Name mirrors `params.name` (tools/call, prompts/get) or
// `params.uri` (resources/read). The value is run through the same
// Mcp-Name mirrors `params.name` (tools/call, prompts/get),
// `params.uri` (resources/read), or — per SEP-2663's Streamable HTTP
// binding — `params.taskId` (tasks/get, tasks/update, tasks/cancel).
// `mcpNameSource` resolves the method → source-field mapping and the
// body value through the same `MCP_NAME_HEADER_SOURCE` table and
// extraction the SDK server validates with, so emission and
// validation cannot drift apart. The value is run through the same
// `=?base64?…?=` sentinel encoding the `Mcp-Param-*` codec uses so a
// non-ASCII name/URI (or one with leading/trailing whitespace,
// non-ASCII name/URI/taskId (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`; 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'
? typeof params?.uri === 'string'
? params.uri
: undefined
: typeof params?.name === 'string'
? params.name
: undefined;
if (nameHeader !== undefined) {
headers.set('mcp-name', encodeMcpParamValue(nameHeader));
const source = mcpNameSource(message.method, message.params);
if (source?.value !== undefined) {
headers.set('mcp-name', encodeMcpParamValue(source.value));
}
}

Expand Down
17 changes: 17 additions & 0 deletions packages/client/test/client/mcpParamMirroring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,23 @@ describe('SEP-2243 Streamable HTTP transport seams', () => {
expect(sent().get('mcp-name')).toBe('route');
});

it('Mcp-Name mirrors params.taskId on tasks/get, tasks/update, and tasks/cancel (SEP-2663)', async () => {
const { tx, sent } = transportWithCapture();
await tx.start();
for (const method of ['tasks/get', 'tasks/update', 'tasks/cancel']) {
await tx.send(modernRequest(method, { taskId: 'task-123' }));
expect(sent().get('mcp-method')).toBe(method);
expect(sent().get('mcp-name')).toBe('task-123');
}
// tasks/list carries no routing name — off the source table, no header.
await tx.send(modernRequest('tasks/list', {}));
expect(sent().get('mcp-name')).toBeNull();
// A non-string params.taskId has nothing to mirror — no header, so the
// server's later rungs (not -32020) answer the malformed body.
await tx.send(modernRequest('tasks/get', { taskId: 42 }));
expect(sent().get('mcp-name')).toBeNull();
});

it('per-request TransportSendOptions.headers cannot override reserved standard/auth headers', async () => {
const { tx, sent } = transportWithCapture();
await tx.start();
Expand Down
79 changes: 58 additions & 21 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,8 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
'SEP-2243 standard `MCP-Protocol-Version` / `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 spec requires `MCP-Protocol-Version` and `Mcp-Method` on every ' +
'modern *request* POST (`Mcp-Name` only for the methods that mirror `params.name` / `params.uri`) and names them ' +
'modern *request* POST (`Mcp-Name` only for the methods that mirror `params.name` / `params.uri` / `params.taskId`, ' +
'see `MCP_NAME_HEADER_SOURCE`) and names them ' +
'in that order, so a request missing several is answered by the earliest. Notification POSTs are exempt: the ' +
'presence half runs on requests only, so a modern-enveloped notification is dispatched even with no standard ' +
'headers at all. The classifier’s own header-mismatch cells ' +
Expand Down Expand Up @@ -466,17 +467,56 @@ function crossCheckMismatch(
);
}

/** The body field the `Mcp-Name` header mirrors for a method on {@linkcode MCP_NAME_HEADER_SOURCE}. */
export type McpNameSourceField = 'name' | 'uri' | 'taskId';

/**
* 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).
* The methods whose body carries a `params.name` / `params.uri` /
* `params.taskId` value the `Mcp-Name` header must mirror, and which body
* field supplies it. The core rows come from SEP-2243 § Standard Request
* Headers (`Required For` column); the `tasks/*` rows come from SEP-2663's
* Streamable HTTP binding ("the client MUST set the `Mcp-Name` header to the
* value of `params.taskId`" for `tasks/get` / `tasks/update` /
* `tasks/cancel`, so intermediaries can route every request for a task to the
* instance holding its state). Shared by the client transport (header
* emission) and the server ladder (validation) so both sides derive from one
* table.
*/
export const MCP_NAME_HEADER_SOURCE: Readonly<Record<string, 'name' | 'uri'>> = {
export const MCP_NAME_HEADER_SOURCE: Readonly<Record<string, McpNameSourceField>> = {
'tools/call': 'name',
'prompts/get': 'name',
'resources/read': 'uri'
'resources/read': 'uri',
'tasks/get': 'taskId',
Comment thread
felixweinberger marked this conversation as resolved.
'tasks/update': 'taskId',
'tasks/cancel': 'taskId'
};

/**
* Resolve the `Mcp-Name` source for one request against
* {@linkcode MCP_NAME_HEADER_SOURCE}: which body field the header mirrors for
* `method`, and the string value that field carries in `params`.
*
* Returns `undefined` when `method` is off-table (no `Mcp-Name` is emitted or
* required). Otherwise `value` is the field's string value, or `undefined`
* when `params` is not a plain object or the field is absent / not a string —
* the client emits no header then, and the server leaves that body to the
* rungs further down the ladder.
*
* `method` is peer-supplied on the server and caller-supplied on the client,
* so the table lookup is `Object.hasOwn`-guarded against `Object.prototype`
* collisions (`constructor`, `toString`, …). Shared by the client transport
* (emission) and {@linkcode validateStandardRequestHeaders} (validation) so
* the extraction — not just the table — cannot drift between the two sides.
*/
export function mcpNameSource(method: string, params: unknown): { field: McpNameSourceField; value: string | undefined } | undefined {
const field = Object.hasOwn(MCP_NAME_HEADER_SOURCE, method) ? MCP_NAME_HEADER_SOURCE[method] : undefined;
if (field === undefined) {
return undefined;
}
const raw = isPlainObject(params) ? params[field] : undefined;
return { field, value: typeof raw === 'string' ? raw : undefined };
}

/** Strip RFC 9110 optional whitespace (SP / HTAB) around a field value in linear time. */
function stripHttpOws(value: string): string {
let start = 0;
Expand Down Expand Up @@ -513,11 +553,13 @@ function stripHttpOws(value: string): string {
* by this cell);
* - 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;
* `prompts/get`, `resources/read`, or (per SEP-2663's Streamable HTTP
* binding) `tasks/get` / `tasks/update` / `tasks/cancel` request whose
* body carries the `params.name` / `params.uri` / `params.taskId` 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`.
* `params.name` / `params.uri` / `params.taskId`.
*
* Returns `undefined` (pass) for notifications (the spec table reads
* "All requests"), for methods that have no `Mcp-Name` source, and when the
Expand Down Expand Up @@ -566,23 +608,18 @@ export function validateStandardRequestHeaders(request: InboundHttpRequest, rout
);
}

// `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) {
const source = mcpNameSource(method, route.message.params);
if (source === undefined) {
return undefined;
}
const params = route.message.params as Record<string, unknown> | undefined;
const sourceValue = params?.[sourceField];
const bodyValue = typeof sourceValue === 'string' ? sourceValue : undefined;
const { field: sourceField, value: bodyValue } = source;

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.
// the source value. A body without the source field (`params.name` /
// `params.uri` / `params.taskId`) is answered by the rungs further
// down the ladder; this rung only answers the missing-header case it
// can observe.
if (bodyValue === undefined) {
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
* `-32020` (`HeaderMismatch`) when the required `MCP-Protocol-Version` header
* is absent, 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
* `prompts/get` / `resources/read` request or (per SEP-2663's Streamable
* HTTP binding) a `tasks/get` / `tasks/update` / `tasks/cancel` 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` / `params.taskId`. Never enforced on
* notifications or on methods without an `Mcp-Name` source.
*
* The classifier itself is left unchanged by these rungs (it stays a
Expand All @@ -21,7 +23,12 @@
import { describe, expect, test } from 'vitest';

import type { InboundHttpRequest, InboundLadderRejection, InboundModernRoute } from '../../src/shared/inboundClassification';
import { classifyInboundRequest, MCP_NAME_HEADER_SOURCE, validateStandardRequestHeaders } from '../../src/shared/inboundClassification';
import {
classifyInboundRequest,
MCP_NAME_HEADER_SOURCE,
mcpNameSource,
validateStandardRequestHeaders
} from '../../src/shared/inboundClassification';
import { encodeMcpParamValue } from '../../src/shared/mcpParamHeaders';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants';

Expand Down Expand Up @@ -269,8 +276,66 @@ describe('SEP-2243 standard-header validation (Mcp-Name presence and cross-check
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('the Mcp-Name source map covers exactly the spec table (SEP-2243 core + SEP-2663 tasks)', () => {
expect(MCP_NAME_HEADER_SOURCE).toEqual({
'tools/call': 'name',
'prompts/get': 'name',
'resources/read': 'uri',
'tasks/get': 'taskId',
'tasks/update': 'taskId',
'tasks/cancel': 'taskId'
});
});

test('a tasks/get without an Mcp-Name header is rejected and names params.taskId (SEP-2663)', () => {
const { request, route } = modernPost('tasks/get', { taskId: 'task-123' }, { mcpMethod: 'tasks/get' });
const result = validateStandardRequestHeaders(request, route);
expectRejection(result, 'name-header-missing');
expect(result?.message).toContain('params.taskId');
});

test('a matching Mcp-Name on tasks/get, tasks/update, and tasks/cancel compares against params.taskId', () => {
for (const method of ['tasks/get', 'tasks/update', 'tasks/cancel']) {
const { request, route } = modernPost(method, { taskId: 'task-123' }, { mcpMethod: method, mcpName: 'task-123' });
expect(validateStandardRequestHeaders(request, route)).toBeUndefined();
}
});

test('an Mcp-Name header disagreeing with params.taskId is rejected (name-header-mismatch)', () => {
const { request, route } = modernPost(
'tasks/update',
{ taskId: 'task-123', inputResponses: {} },
{ mcpMethod: 'tasks/update', mcpName: 'some-other-task' }
);
const result = validateStandardRequestHeaders(request, route);
expectRejection(result, 'name-header-mismatch');
expect(result?.message).toContain('"some-other-task"');
});

test('a tasks/list stays off-table: no Mcp-Name required', () => {
const { request, route } = modernPost('tasks/list', {}, { mcpMethod: 'tasks/list' });
expect(validateStandardRequestHeaders(request, route)).toBeUndefined();
});

test('an Mcp-Name header on a tasks/get whose params.taskId is not a string passes this rung (the body is answered further down)', () => {
// Nothing to cross-check against: the header is present and the body
// carries no string source value, so the mismatch branch must not fire.
const { request, route } = modernPost('tasks/get', { taskId: 42 }, { mcpMethod: 'tasks/get', mcpName: 'task-123' });
expect(validateStandardRequestHeaders(request, route)).toBeUndefined();
});

test('mcpNameSource resolves the shared table for both sides', () => {
expect(mcpNameSource('tools/call', { name: 'echo' })).toEqual({ field: 'name', value: 'echo' });
expect(mcpNameSource('resources/read', { uri: 'file:///x' })).toEqual({ field: 'uri', value: 'file:///x' });
expect(mcpNameSource('tasks/update', { taskId: 'task-123', inputResponses: {} })).toEqual({ field: 'taskId', value: 'task-123' });
// On-table, but no string value to mirror.
expect(mcpNameSource('tasks/get', { taskId: 42 })).toEqual({ field: 'taskId', value: undefined });
expect(mcpNameSource('tasks/get', undefined)).toEqual({ field: 'taskId', value: undefined });
expect(mcpNameSource('tasks/get', ['task-123'])).toEqual({ field: 'taskId', value: undefined });
// Off-table, including Object.prototype collisions.
expect(mcpNameSource('tasks/list', { taskId: 'task-123' })).toBeUndefined();
expect(mcpNameSource('constructor', { name: 'x' })).toBeUndefined();
expect(mcpNameSource('__proto__', { name: 'x' })).toBeUndefined();
});

test('a method colliding with Object.prototype members is treated as off-table (passes through to dispatch)', () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/server/test/server/stdHeaderValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
* body-primary classifier returns a modern route. A missing
* `MCP-Protocol-Version` header, a missing `Mcp-Method`
* header, a missing `Mcp-Name` header on a `tools/call` / `prompts/get` /
* `resources/read` request, an `Mcp-Name` value disagreeing with
* `params.name` / `params.uri`, and an invalid `Mcp-Name` Base64 sentinel are
* `resources/read` request or (SEP-2663) a `tasks/get` / `tasks/update` /
* `tasks/cancel` request, an `Mcp-Name` value disagreeing with
* `params.name` / `params.uri` / `params.taskId`, and an invalid `Mcp-Name`
* Base64 sentinel are
* all rejected `400` / `-32020` (`HeaderMismatch`) on the
* `standard-header-validation` rung — the same shape the classifier already
* emits for the `MCP-Protocol-Version` and `Mcp-Method` mismatch cells on the
Expand Down Expand Up @@ -234,6 +236,15 @@ describe('SEP-2243 standard-header validation (createMcpHandler, modern era)', (
const response = await handler.fetch(modernRequest('tools/list', {}, { 'mcp-method': 'tools/list' }));
expect(response.status).toBe(200);
});

it('a missing Mcp-Name header on tasks/get is rejected 400/-32020 at the entry (SEP-2663)', async () => {
const handler = createMcpHandler(makeFactory());
const error = await expectHeaderMismatch(
await handler.fetch(modernRequest('tasks/get', { taskId: 'task-123' }, { 'mcp-method': 'tasks/get' }))
);
expect(error.message).toContain('params.taskId="task-123"');
expect(error.message).toContain('Mcp-Name header is absent');
});
});

describe('SEP-2243 standard-header validation is era-gated', () => {
Expand Down
Loading
Loading