Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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 @@ import {
isJSONRPCResultResponse,
isModernProtocolVersion,
JSONRPCMessageSchema,
MCP_NAME_HEADER_SOURCE,
mediaTypeEssence,
normalizeHeaders,
PROTOCOL_VERSION_META_KEY,
Expand Down Expand Up @@ -481,24 +482,26 @@ 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
// `=?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;
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 @@ function crossCheckMismatch(
}

/**
* 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',
'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 @@ function stripHttpOws(value: string): string {
*
* - 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