fix: mirror params.taskId into Mcp-Name for tasks requests (SEP-2663) (#2613)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
This commit is contained in:
parent
7b781ed4e2
commit
70de0c8b56
8 changed files with 184 additions and 45 deletions
9
.changeset/tasks-mcp-name-header.md
Normal file
9
.changeset/tasks-mcp-name-header.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
isJSONRPCResultResponse,
|
||||
isModernProtocolVersion,
|
||||
JSONRPCMessageSchema,
|
||||
mcpNameSource,
|
||||
mediaTypeEssence,
|
||||
normalizeHeaders,
|
||||
PROTOCOL_VERSION_META_KEY,
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ' +
|
||||
|
|
@ -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',
|
||||
'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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -367,7 +367,7 @@ export function modernEnvelopeMeta(clientInfo?: Implementation): Record<string,
|
|||
* HTTP requests on the wire. `MCP-Protocol-Version` and `Mcp-Method` are
|
||||
* required on every modern *request* POST (notification POSTs are exempt);
|
||||
* `Mcp-Name` is additionally required for the methods that mirror
|
||||
* `params.name` / `params.uri`, and carries the `=?base64?…?=` sentinel
|
||||
* `params.name` / `params.uri` / `params.taskId`, and carries the `=?base64?…?=` sentinel
|
||||
* encoding the shipped client applies to it, so a name that is not a safe
|
||||
* plain-ASCII field value reaches the server exactly as that client would
|
||||
* send it rather than throwing in `Headers` construction.
|
||||
|
|
|
|||
Loading…
Reference in a new issue