diff --git a/.changeset/tasks-mcp-name-header.md b/.changeset/tasks-mcp-name-header.md new file mode 100644 index 0000000000..a01f90716c --- /dev/null +++ b/.changeset/tasks-mcp-name-header.md @@ -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. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index ace0663158..8a5ba29b26 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -11,6 +11,7 @@ import { isJSONRPCResultResponse, isModernProtocolVersion, JSONRPCMessageSchema, + MCP_NAME_HEADER_SOURCE, mediaTypeEssence, normalizeHeaders, PROTOCOL_VERSION_META_KEY, @@ -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 | 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)); } diff --git a/packages/client/test/client/mcpParamMirroring.test.ts b/packages/client/test/client/mcpParamMirroring.test.ts index 42dd206071..782d930180 100644 --- a/packages/client/test/client/mcpParamMirroring.test.ts +++ b/packages/client/test/client/mcpParamMirroring.test.ts @@ -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(); diff --git a/packages/core-internal/src/shared/inboundClassification.ts b/packages/core-internal/src/shared/inboundClassification.ts index 22883cf488..ff0a9f00e7 100644 --- a/packages/core-internal/src/shared/inboundClassification.ts +++ b/packages/core-internal/src/shared/inboundClassification.ts @@ -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> = { +export const MCP_NAME_HEADER_SOURCE: Readonly> = { '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. */ @@ -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 diff --git a/packages/core-internal/test/shared/standardHeaderValidation.test.ts b/packages/core-internal/test/shared/standardHeaderValidation.test.ts index 99de31457b..d142caa12b 100644 --- a/packages/core-internal/test/shared/standardHeaderValidation.test.ts +++ b/packages/core-internal/test/shared/standardHeaderValidation.test.ts @@ -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)', () => {