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
10 changes: 10 additions & 0 deletions .changeset/sep-2243-std-header-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/server': minor
---

SEP-2243 standard-header server-side validation (protocol revision 2026-07-28). On the modern (2026-07-28) serving path, `createMcpHandler` now enforces the required `Mcp-Method` and `Mcp-Name` standard request headers in addition to the existing `MCP-Protocol-Version` and `Mcp-Method` cross-checks: a modern request without an `Mcp-Method` header, a `tools/call` / `prompts/get` / `resources/read` request without an `Mcp-Name` header, an `Mcp-Name` header carrying an invalid `=?base64?…?=` sentinel, and an `Mcp-Name` header whose (decoded) value disagrees with the body's `params.name` / `params.uri` are all rejected with `400 Bad Request` and JSON-RPC `-32001` (`HeaderMismatch`). The 2025-era serving paths are unchanged.
Comment thread
claude[bot] marked this conversation as resolved.

New public surface:

- `@modelcontextprotocol/core`: `validateStandardRequestHeaders` (function), `MCP_NAME_HEADER_SOURCE` (const), the `mcpNameHeader` field on `InboundHttpRequest`, and the `'standard-header-validation'` member of `InboundValidationRung` (with `client-capabilities` / `param-header-validation` renumbered).
26 changes: 16 additions & 10 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1052,16 +1052,16 @@ versionNegotiation: {
}
```

`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an
already-constructed instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting).
`maxRetries` governs timeout re-sends only (the spec-mandated `-32004` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). Negotiation can also be configured pre-connect on an already-constructed
instance via `client.setVersionNegotiation(options)` (equivalent to the constructor option; throws after connecting).

Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass
in a request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision.
Once a modern era is negotiated, the client **automatically attaches the per-request `_meta` envelope** (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification — you never set it by hand. Any `_meta` keys you pass in a
request are preserved over the auto-attached ones. After connect, `client.getProtocolEra()` returns `'legacy'` or `'modern'` and `client.getNegotiatedProtocolVersion()` the exact revision.

On the server side, `server/discover` (advertising only the modern revisions) is served by instances hosted through one of the 2026-era serving entries; a hand-constructed `Server`/`McpServer` is byte-identical to before (it keeps answering `-32601`, and the `initialize`
handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and
other long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that
protocol revision.
handshake only ever negotiates 2025-era versions — a 2026-era revision is never accepted or counter-offered there). Serving the 2026 revision to ordinary HTTP traffic is done with the `createMcpHandler` entry point described in the next section; serving it on stdio (and other
long-lived connections) is the `serveStdio` entry point described after that. The client can also issue `client.discover()` directly on a 2026-era connection; on a 2025-era connection the method is rejected locally with a typed error, since it does not exist on that protocol
revision.

### Serving the 2026-07-28 draft revision over HTTP: `createMcpHandler`

Expand Down Expand Up @@ -1140,6 +1140,10 @@ statically allow-listed for credentialed CORS); calling an `x-mcp-header` tool w
and validation is skipped for that tool only. In v1, `listTools()` threw on an uncompilable `outputSchema`; now it succeeds, and a pluggable `jsonSchemaValidator` provider observes compilation at `callTool` time, not `listTools` time. The legacy-era `listTools()` path is
unchanged at the wire level but is observably different at the validator-lifecycle level.

On the modern path, `createMcpHandler` also validates the SEP-2243 **standard** request-metadata headers against the body and rejects with the same `400` / `-32001` (`HeaderMismatch`) when the `MCP-Protocol-Version` or `Mcp-Method` header disagrees with the body, 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, and when the (Base64-sentinel-decoded) `Mcp-Name` value disagrees with `params.name` / `params.uri`. These checks only fire on the
modern (2026-07-28) serving path — 2025-era traffic is unchanged — and a hand-built modern HTTP request must carry the `Mcp-Method` (and where applicable `Mcp-Name`) header; the SDK client already sends them.

### Serving the 2026-07-28 draft revision on stdio: `serveStdio`

The server package ships a stdio entry point that mirrors `createMcpHandler` for long-lived connections: the entry owns the transport and the era decision, the client's opening exchange selects the era for the connection, and ONE instance from your factory is pinned to that
Expand Down Expand Up @@ -1213,9 +1217,11 @@ const handler = createMcpHandler(() => buildServer());
handler.notify.toolsChanged();
```

**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers
fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler`
registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead.
**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers fire
on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves
once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if
you still want events); change notifications dispatch to the existing `setNotificationHandler` registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter
instead.

### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver

Expand Down
3 changes: 2 additions & 1 deletion examples/json-response/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ runClient('json-response', async () => {
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': '2026-07-28'
'mcp-protocol-version': '2026-07-28',
'mcp-method': 'tools/list'
},
body: JSON.stringify({
jsonrpc: '2.0',
Expand Down
7 changes: 3 additions & 4 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,10 +332,9 @@ export class StreamableHTTPClientTransport implements Transport {
// 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`; this
// SDK's server does not yet cross-check `Mcp-Name` against the body
// (tracked in expected-failures.yaml) — when it does it will decode
// the sentinel before comparison.
// 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'
Expand Down
142 changes: 138 additions & 4 deletions packages/core/src/shared/inboundClassification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ import { ProtocolError, UnsupportedProtocolVersionError } from '../types/errors.
import { isJSONRPCErrorResponse, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse } from '../types/guards.js';
import type { JSONRPCNotification, JSONRPCRequest, MessageClassification } from '../types/types.js';
import { envelopeClaimVersion, hasEnvelopeClaim, requestMetaOf, validateEnvelopeMeta } from './envelope.js';
// Value encoding is shared between the standard `Mcp-Name` header and the
// custom `Mcp-Param-*` headers; the codec module already imports the
// `HeaderMismatch` constant and rejection type from here, so this is a benign
// two-module cycle (both sides only consume the other's exports inside
// function bodies, never at module-evaluation time).
import { decodeMcpParamValue } from './mcpParamHeaders.js';
import { isModernProtocolVersion } from './protocolEras.js';

/* ------------------------------------------------------------------------ *
Expand All @@ -88,6 +94,8 @@ export interface InboundHttpRequest {
protocolVersionHeader?: string;
/** The value of the `Mcp-Method` header, when present. */
mcpMethodHeader?: string;
/** The value of the `Mcp-Name` header, when present. */
mcpNameHeader?: string;
/** The parsed JSON request body (`undefined` for body-less methods). */
body?: unknown;
}
Expand Down Expand Up @@ -161,6 +169,7 @@ export type InboundValidationRung =
| 'envelope'
| 'method-registry'
| 'request-params'
| 'standard-header-validation'
| 'client-capabilities'
| 'param-header-validation';

Expand Down Expand Up @@ -305,9 +314,25 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
rationale: 'Per-method params validation; emitted in-band by the dispatch layer (HTTP 200), never via the ladder status table.'
},
{
rung: 'client-capabilities',
rung: 'standard-header-validation',
order: 7,
evaluatedAt: 'pre-dispatch',
codes: [HEADER_MISMATCH_ERROR_CODE],
conformance: ['http-header-validation'],
rationale:
'SEP-2243 standard `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 classifier’s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge ' +
'`era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. Evaluated before the ' +
'capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by ' +
'the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the ' +
'observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request ' +
'that also fails a dispatch rung is answered here before the dispatch rungs (5–6) are consulted.'
},
Comment thread
claude[bot] marked this conversation as resolved.
{
rung: 'client-capabilities',
order: 8,
evaluatedAt: 'pre-dispatch',
codes: [ProtocolErrorCode.MissingRequiredClientCapability],
conformance: ['server-stateless'],
rationale:
Expand All @@ -320,7 +345,7 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor
},
{
rung: 'param-header-validation',
order: 8,
order: 9,
evaluatedAt: 'pre-dispatch',
codes: [HEADER_MISMATCH_ERROR_CODE],
conformance: ['http-custom-header-server-validation'],
Expand Down Expand Up @@ -392,9 +417,14 @@ function rejection(
};
}

function crossCheckMismatch(cell: string, header: string, body: string): InboundLadderRejection {
function crossCheckMismatch(
cell: string,
header: string,
body: string,
rung: InboundValidationRung = 'era-classification'
): InboundLadderRejection {
return rejection(
'era-classification',
rung,
cell,
400,
new ProtocolError(HEADER_MISMATCH_ERROR_CODE, `Bad Request: the request headers and body disagree: ${body}`, {
Expand All @@ -404,6 +434,110 @@ function crossCheckMismatch(cell: string, header: string, body: string): Inbound
);
}

/**
* 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).
*/
export const MCP_NAME_HEADER_SOURCE: Readonly<Record<string, 'name' | 'uri'>> = {
'tools/call': 'name',
'prompts/get': 'name',
'resources/read': 'uri'
};

/**
* SEP-2243 standard-header server-side validation, evaluated by the HTTP
* entry on a modern-classified request immediately after
* {@linkcode classifyInboundRequest} returns a modern route.
*
* Returns the `-32001` (`HeaderMismatch`) ladder rejection (HTTP `400`,
* `standard-header-validation` rung — the same shape
* {@linkcode classifyInboundRequest} already emits on the edge
* `era-classification` rung for the `MCP-Protocol-Version` and
* `Mcp-Method` *mismatch* cells) when:
*
* - 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;
* - the `Mcp-Name` header carries an invalid `=?base64?…?=` sentinel; or
* - the (decoded) `Mcp-Name` value disagrees with the body's
* `params.name` / `params.uri`.
*
* Returns `undefined` (pass) for notifications (the spec table reads
* "All requests"), for methods that have no `Mcp-Name` source, and when the
* headers agree with the body. Never enforced on legacy traffic — the entry
* only calls this on a modern route.
*
* Kept separate from {@linkcode classifyInboundRequest} so that a body-only
* call to the classifier (no headers passed) keeps routing a modern request
* unchanged: the classifier remains a pure body-primary router, and this
* function is the presence/`Mcp-Name` half of the standard-header rung the
* entry layers on top.
*/
export function validateStandardRequestHeaders(request: InboundHttpRequest, route: InboundModernRoute): InboundLadderRejection | undefined {
if (route.messageKind !== 'request') {
return undefined;
}
Comment thread
claude[bot] marked this conversation as resolved.
const method = route.message.method;

if (request.mcpMethodHeader === undefined) {
return crossCheckMismatch(
'method-header-missing',
'(missing)',
`the body names method ${method} but the required Mcp-Method header is absent`,
'standard-header-validation'
);
}

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

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.
if (bodyValue === undefined) {
return undefined;
}
return crossCheckMismatch(
'name-header-missing',
'(missing)',
`the body carries params.${sourceField}="${bodyValue}" but the required Mcp-Name header is absent`,
'standard-header-validation'
);
}

const decoded = decodeMcpParamValue(request.mcpNameHeader);
if (decoded === undefined) {
return crossCheckMismatch(
'name-header-invalid-encoding',
request.mcpNameHeader,
'the Mcp-Name header carries an invalid Base64 sentinel value',
'standard-header-validation'
);
}
if (bodyValue !== undefined && decoded !== bodyValue) {
return crossCheckMismatch(
'name-header-mismatch',
request.mcpNameHeader,
`the body carries params.${sourceField}="${bodyValue}" but the Mcp-Name header names "${decoded}"`,
'standard-header-validation'
);
}
return undefined;
}

Comment thread
claude[bot] marked this conversation as resolved.
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
Expand Down
Loading
Loading