Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .changeset/tasks-mcp-name-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': 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.
39 changes: 21 additions & 18 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
isJSONRPCResultResponse,
isModernProtocolVersion,
JSONRPCMessageSchema,
MCP_NAME_HEADER_SOURCE,
mediaTypeEssence,
normalizeHeaders,
PROTOCOL_VERSION_META_KEY,
Expand Down Expand Up @@ -481,24 +482,26 @@
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
// `=?base64?…?=` sentinel encoding the `Mcp-Param-*` codec uses so a
// 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`; 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;
// 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).
// The method → source-field mapping is the same
// `MCP_NAME_HEADER_SOURCE` table the SDK server validates against, 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, 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. `message.method` is caller-supplied,
// so the lookup is `Object.hasOwn`-guarded against
// `Object.prototype` collisions, mirroring the server-side lookup.
const params = message.params as Record<string, unknown> | undefined;
const sourceField = Object.hasOwn(MCP_NAME_HEADER_SOURCE, message.method) ? MCP_NAME_HEADER_SOURCE[message.method] : undefined;
const sourceValue = sourceField === undefined ? undefined : params?.[sourceField];
const nameHeader = typeof sourceValue === 'string' ? sourceValue : undefined;

Check warning on line 504 in packages/client/src/client/streamableHttp.ts

View check run for this annotation

Claude / Claude Code Review

[quality] The Mcp-Name source-value extraction (Object.hasOwn-guarded table lookup + params?.[sourceField] + typeof-string filter) is now duplicated verbatim between the client transport and the server validator instead of being a shared helper next to [a

[quality] The Mcp-Name source-value extraction (Object.hasOwn-guarded table lookup + params?.[sourceField] + typeof-string filter) is now duplicated verbatim between the client transport and the server validator instead of being a shared helper next to [additional confirmed claim at this location: Refactor silently drops Mcp-Name emission for off-table request methods that carry a string params.name: the removed ternary's else-branch mirrored params.name for EVERY method except resources/read, ]
Comment thread
felixweinberger marked this conversation as resolved.
Outdated
if (nameHeader !== undefined) {
headers.set('mcp-name', encodeMcpParamValue(nameHeader));
}
Expand Down
13 changes: 13 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,19 @@ 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();
});

it('per-request TransportSendOptions.headers cannot override reserved standard/auth headers', async () => {
const { tx, sent } = transportWithCapture();
await tx.start();
Expand Down
28 changes: 20 additions & 8 deletions packages/core-internal/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,14 +454,24 @@
}

/**
* 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, 'name' | 'uri' | 'taskId'>> = {
'tools/call': 'name',
'prompts/get': 'name',
'resources/read': 'uri'
'resources/read': 'uri',
'tasks/get': 'taskId',

Check failure on line 472 in packages/core-internal/src/shared/inboundClassification.ts

View check run for this annotation

Claude / Claude Code Review

Tasks methods exist only in the legacy 2025-11-25 wire era, but the Mcp-Name emission/validation machinery this PR extends runs only on modern-enveloped (2026-07-28+) exchanges, so the new rows are unreachable via the SDK's own Client and only change whic

Tasks methods exist only in the legacy 2025-11-25 wire era, but the Mcp-Name emission/validation machinery this PR extends runs only on modern-enveloped (2026-07-28+) exchanges, so the new rows are unreachable via the SDK's own Client and only change which error a modern-era-nonexistent method gets. [also at: packages/core-internal/src/shared/inboundClassification.ts:474 - MCP_NAME_HEADER_SOURCE uses the wrong tasks method set: it adds a row for nonexistent 'tasks/update' and omits 'tasks/re]
Comment thread
felixweinberger marked this conversation as resolved.
'tasks/update': 'taskId',
'tasks/cancel': 'taskId'
};

/** Strip RFC 9110 optional whitespace (SP / HTAB) around a field value in linear time. */
Expand Down Expand Up @@ -496,11 +506,13 @@
*
* - 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
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,45 @@ 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('a method colliding with Object.prototype members is treated as off-table (passes through to dispatch)', () => {
Expand Down
Loading