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
5 changes: 5 additions & 0 deletions .changeset/add-request-state-codec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

Add `createRequestStateCodec({ key, ttlSeconds?, bind? })`, an opt-in HMAC-SHA256 sealing helper for the multi-round-trip `requestState`: `mint` seals a JSON-serializable payload (with TTL and optional context binding) and `verify` drops directly into `ServerOptions.requestState.verify`. WebCrypto-based and runtime-neutral; verification is fail-closed and constant-time. The `ServerOptions.requestState.verify` hook's return type is widened to `unknown | Promise<unknown>` (the seam already discarded the return value) so the codec's `verify` is directly assignable.
2 changes: 1 addition & 1 deletion .changeset/create-mcp-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Add `createMcpHandler(factory, { legacy?, onerror?, responseMode? })`, an HTTP entry point that serves the 2026-07-28 draft revision per request: each envelope-carrying request is classified once, served on a fresh instance from the factory bound to the claimed revision,
and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is selected with the `legacy` option (`'stateless'` — the default — for per-request stateless serving via the existing streamable HTTP transport, `'reject'` for a modern-only strict endpoint
that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler is a web-standard `{ fetch, close, notify }` object: `fetch(request, { authInfo?, parsedBody? })` is the only request face (Node frameworks wrap it with
that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler is a web-standard `{ fetch, close, notify, bus }` object: `fetch(request, { authInfo?, parsedBody? })` is the only request face (Node frameworks wrap it with
`toNodeHandler(handler)` from `@modelcontextprotocol/node`), and `close()` tears down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the same stateless legacy serving as a standalone fetch-shaped handler), the `PerRequestHTTPServerTransport` single-exchange transport and the
`classifyInboundRequest` classifier for hand-wired compositions, and the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are
always served over SSE. The entry performs no Origin/Host validation (use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers.
5 changes: 3 additions & 2 deletions .changeset/handler-drop-node-face.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
---

`createMcpHandler` now returns a web-standards-only `{ fetch, close, notify, bus }` handler — the shape Workers/Bun/Deno expect from `export default`. The duck-typed `.node(req, res, parsedBody?)` face is removed; Node frameworks (Express, Fastify, plain `node:http`) wrap the
handler once with the new `toNodeHandler(handler)` exported from `@modelcontextprotocol/node`, which converts the Node request to a web-standard `Request`, calls `handler.fetch`, and writes the `Response` back honoring write backpressure. `NodeIncomingMessageLike` and
`NodeServerResponseLike` move from `@modelcontextprotocol/server` to `@modelcontextprotocol/node`.
handler once with the new `toNodeHandler(handler, { onerror? })` exported from `@modelcontextprotocol/node`, which converts the Node request to a web-standard `Request`, calls `handler.fetch`, and writes the `Response` back honoring write backpressure. The optional `onerror`
receives the adapter-level error fallback (request conversion / `handler.fetch` throw) before the `500` response is written, restoring observability parity with the removed `.node` face. `NodeIncomingMessageLike` and `NodeServerResponseLike` move from
`@modelcontextprotocol/server` to `@modelcontextprotocol/node`.
2 changes: 1 addition & 1 deletion .changeset/server-ctx-log-request-related.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
'@modelcontextprotocol/server': patch
---

`ctx.mcpReq.log()` now emits its `notifications/message` notification request-related (like progress and `ctx.mcpReq.notify`), so handler-emitted log messages are delivered when the server is hosted per request via `createMcpHandler` instead of being silently dropped. On a 2026-07-28 request the level filter consults the per-request `_meta` `io.modelcontextprotocol/logLevel` key (the modern equivalent of `logging/setLevel`). The session-scoped `Server.sendLoggingMessage()` API is unchanged.
`ctx.mcpReq.log()` now emits its `notifications/message` notification request-related (like progress and `ctx.mcpReq.notify`), so handler-emitted log messages are delivered when the server is hosted per request via `createMcpHandler` instead of being silently dropped. On a 2026-07-28 request the level filter consults the per-request `_meta` `io.modelcontextprotocol/logLevel` key (the modern equivalent of `logging/setLevel`); per the spec, an absent key means no `notifications/message` is sent for that request. Because the message now rides the in-flight exchange instead of the session-wide channel, a 2025-era stateful Streamable HTTP deployment that answers POSTs in JSON mode and delivers server notifications over the standalone GET stream will no longer receive handler-emitted log messages on that GET stream. The session-scoped `Server.sendLoggingMessage()` API is unchanged.
7 changes: 4 additions & 3 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -596,9 +596,10 @@ New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps ont
GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler,
never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed.
- `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default.
- The handler is web-standards-only (`{ fetch, close, notify }`). On Workers / Bun / Deno, `export default handler` works directly. On Node frameworks (Express, Fastify, plain `node:http`), wrap once with `toNodeHandler(handler)` from `@modelcontextprotocol/node`:
`app.all('/mcp', toNodeHandler(handler))`, or `const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body))` when a body parser already consumed the stream. Earlier 2.x alphas exposed this as `handler.node(req, res, req.body)` — replace with
the `toNodeHandler` wrap and add the `@modelcontextprotocol/node` import. `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`.
- The handler is web-standards-only (`{ fetch, close, notify, bus }`). On Workers / Bun / Deno, `export default handler` works directly. On Node frameworks (Express, Fastify, plain `node:http`), wrap once with `toNodeHandler(handler, { onerror? })` from
`@modelcontextprotocol/node`: `app.all('/mcp', toNodeHandler(handler))`, or `const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body))` when a body parser already consumed the stream. The optional `onerror` receives the adapter-level
error fallback (request conversion / `handler.fetch` throw) before the `500` response is written. Earlier 2.x alphas exposed this as `handler.node(req, res, req.body)` — replace with the `toNodeHandler` wrap and add the `@modelcontextprotocol/node` import.
`NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`.
Comment on lines 596 to +602

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 docs/migration-SKILL.md's multi-round-trip section (line 554) still tells readers to integrity-protect requestState themselves but never mentions the createRequestStateCodec helper or the ServerOptions.requestState.verify hook that this PR adds and documents in the sibling docs/migration.md. Consider adding a one-sentence pointer so the LLM-applied SKILL doc steers migrations to the new helper instead of hand-rolled HMAC.

Extended reasoning...

What's missing. This PR introduces createRequestStateCodec({ key, ttlSeconds?, bind? }) in @modelcontextprotocol/server and rewrites the requestState paragraph in docs/migration.md (~line 1238) to present the codec plus ServerOptions.requestState.verify as the intended drop-in. It also converts the SDK's own worked examples (examples/mrtr/server.ts, test/conformance/src/everythingServer.ts) from hand-rolled node:crypto HMAC to the codec. However docs/migration-SKILL.md — which describes itself as the LLM-optimized mirror of migration.md and IS touched by this PR (the toNodeHandler { onerror } lines at 596–602) — still carries only the old guidance in its multi-round-trip section.\n\nThe exact gap. Line 554 of migration-SKILL.md reads: "requestState round-trips as an opaque string and comes back as attacker-controlled input — integrity-protect (HMAC/AEAD) and verify it yourself when relying on it." A grep of the whole file finds zero occurrences of createRequestStateCodec or requestState.verify (the only other requestState mentions, lines 514/525/528, are about the wire-lift, not integrity protection).\n\nWhy it matters. The SKILL file exists specifically so tools like Claude Code can mechanically apply the migration. With the sentence as-is, an automated migration of a multi-round-trip server will conclude the consumer must hand-roll HMAC — exactly the boilerplate this PR's helper was added to eliminate, and exactly what this PR removed from its own examples. The two migration docs are otherwise kept in step by this PR (the { fetch, close, notify, bus } and toNodeHandler onerror updates land in both), so the requestState section is the one place they now diverge.\n\nStep-by-step proof.\n1. grep -n createRequestStateCodec docs/migration-SKILL.md → no matches; grep -n requestState docs/migration-SKILL.md → lines 514, 525, 528 (wire-lift) and 554 (the verify-it-yourself sentence).\n2. docs/migration.md in this PR's diff now says: "…and an opt-in helper to drop into it: createRequestStateCodec({ key, ttlSeconds?, bind? }) returns { mint, verify }verify is exactly the function you assign to the hook."\n3. An LLM following migration-SKILL.md §12d for a server using requestState therefore writes createHmac/timingSafeEqual boilerplate, while the same migration applied from migration.md (or by reading the updated examples) would use the codec.\n\nOn the refutation. It is true that the hard "document in both files" rule in CLAUDE.md is scoped to breaking changes, that the existing sentence at line 554 remains technically accurate (the SDK still applies no sealing by default), and that the codec has no v1 API to map from — so this is not a correctness error and should not block the PR. But the SKILL doc is not purely a v1→v2 mapping table: its §12d already carries this exact security-guidance prose, which is the SKILL-side counterpart of the migration.md paragraph this PR rewrote, and the PR edits the SKILL file anyway. Leaving the two docs divergent on the one point where the SDK now ships a first-class answer is an avoidable inconsistency, hence filed as a non-blocking nit rather than a normal finding.\n\nFix. Append one sentence to the line-554 paragraph along the lines of: "…or drop the SDK's createRequestStateCodec({ key, ttlSeconds?, bind? }) into ServerOptions.requestState.verify (mint with codec.mint, decode on re-entry with codec.verify)."


### Server (stdio / long-lived connections)

Expand Down
15 changes: 11 additions & 4 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1058,10 +1058,12 @@ const handler = createMcpHandler(ctx => {
// export default handler; // or handler.fetch(request)
// Node frameworks (Express, Fastify, plain node:http) — wrap once:
// import { toNodeHandler } from '@modelcontextprotocol/node';
// const node = toNodeHandler(handler);
// const node = toNodeHandler(handler, { onerror: console.error });
// app.all('/mcp', (req, res) => void node(req, res, req.body));
```

`toNodeHandler` accepts an optional `{ onerror }` callback that receives the adapter-level error fallback (request conversion / `handler.fetch` throw) before the `500` response is written — entry-internal failures continue to surface through the entry's own `onerror` option.

How the `legacy` option behaves:

- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`.
Expand Down Expand Up @@ -1234,9 +1236,14 @@ has: only `tools/call` has a catch-all that wraps handler failures into `isError

**`requestState` is untrusted input — protect it yourself.** `inputRequired({ requestState })` lets a server round-trip opaque state through the client instead of holding it in memory. The SDK treats it as an opaque string end to end: the client echoes it back byte-exact and
never parses it, and the server sees the echoed value raw at `ctx.mcpReq.requestState`. The specification's requirement is the consumer's obligation: the value comes back as **attacker-controlled input**, so if it influences authorization, resource access, or business logic you
MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, but
it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a real
JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example.
MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not apply any sealing of its own, but it does
provide the place to put your verification — configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present; a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a real JSON-RPC
error rather than an `isError` result) — and an opt-in helper to drop into it: `createRequestStateCodec({ key, ttlSeconds?, bind? })` returns `{ mint, verify }` where `mint` HMAC-SHA256-seals a JSON-serializable payload (with a TTL, default 600 s, and optional context binding)
and `verify` is exactly the function you assign to the hook. The handler reads its payload back with the same `verify` (`await codec.verify(ctx.mcpReq.requestState, ctx)`) — re-calling `verify` from the handler is the intended pattern (the seam already proved integrity; the
second call is the decode). The codec is **signed, not encrypted**: the body is integrity-protected but the client can base64url-decode it and read the payload in clear, so do not put secrets in the payload — use an AEAD construction if confidentiality is required (the optional
`bind` value is stored as a keyed HMAC tag, not raw, so a principal identifier in the binding does not leak). The codec is WebCrypto-based and runtime-neutral; the key must be at least 32 bytes and shared across every instance that may receive an echoed value. Verification is
fail-closed: any failure (bad MAC, expired, bind mismatch, malformed) throws with a fixed opaque reason code — the seam relays that code to `onerror` only (never the wire), and the code never carries decoded payload or binding values, so operator logs do not pick up principal
identifiers from rejections. See `examples/server/src/multiRoundTrip.ts` for a worked end-to-end example.

**Client side — auto-fulfilment by default.** When a call to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection answers `input_required`, the client fulfils the embedded requests through the same handlers registered with
`setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries the original request (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). `client.callTool()` and its siblings
Expand Down
61 changes: 22 additions & 39 deletions examples/mrtr/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,57 +11,38 @@
*
* `requestState` round-trips through the client and is therefore
* attacker-controlled input on re-entry. A real server MUST integrity-protect
* it (e.g. HMAC or AEAD): this example mints `body.hmac` with a per-process
* key and rejects tampered state via the {@linkcode ServerOptions.requestState}
* `verify` hook, which answers a wire-level `-32602` Invalid Params error.
* it (e.g. HMAC or AEAD): this example uses the SDK-provided
* {@linkcode createRequestStateCodec} helper — `mint` HMAC-seals the payload
* with a per-process key and a TTL, and `verify` is dropped directly into the
* {@linkcode ServerOptions.requestState} hook so the seam rejects tampered or
* expired state with a wire-level `-32602` Invalid Params error before the
* handler runs.
*
* One binary, either transport (selected by the shared scaffold from argv).
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';

import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server';
import { acceptedContent, inputRequired, McpServer } from '@modelcontextprotocol/server';
import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

import { runServerFromArgs } from '../harness.js';

const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] };

// Per-process integrity key for requestState. The 2026-07-28 path serves every
// request from a fresh server instance — the state itself is the only thing
// that survives between rounds — so the key is process-local.
const STATE_KEY = randomBytes(32);

type DeployState = { step: 'confirm' | 'signed-in'; env: string };

function mintState(payload: DeployState): string {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${body}.${createHmac('sha256', STATE_KEY).update(body).digest('base64url')}`;
}

function verifyState(state: string): void {
const dot = state.lastIndexOf('.');
const body = dot > 0 ? state.slice(0, dot) : '';
const expected = createHmac('sha256', STATE_KEY).update(body).digest();
const provided = Buffer.from(state.slice(dot + 1), 'base64url');
if (dot <= 0 || provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
throw new Error('requestState failed integrity verification');
}
}

function readState(ctx: { mcpReq: { requestState?: string } }): DeployState | undefined {
// The seam-level verify hook has already proven integrity by the time the
// handler runs; this only re-reads the body.
const state = ctx.mcpReq.requestState;
return state === undefined
? undefined
: (JSON.parse(Buffer.from(state.slice(0, state.lastIndexOf('.')), 'base64url').toString()) as DeployState);
}
// Per-process integrity key for requestState. The 2026-07-28 path serves every
// request from a fresh server instance — the state itself is the only thing
// that survives between rounds — so the key is process-local. A multi-instance
// deployment would load a shared secret here instead.
const stateCodec = createRequestStateCodec<DeployState>({
key: crypto.getRandomValues(new Uint8Array(32)),
ttlSeconds: 600
});

function buildServer(): McpServer {
const server = new McpServer(
{ name: 'mrtr-example-server', version: '1.0.0' },
{ capabilities: { tools: {} }, requestState: { verify: verifyState } }
{ capabilities: { tools: {} }, requestState: { verify: stateCodec.verify } }
);

server.registerTool(
Expand All @@ -74,8 +55,10 @@ function buildServer(): McpServer {
async ({ env }, ctx): Promise<CallToolResult | InputRequiredResult> => {
// The handler reads the SAME context fields on every entry; what
// changes between rounds is which input responses have arrived and
// what (verified) `requestState` was echoed back.
const state = readState(ctx);
// what (verified) `requestState` was echoed back. The seam-level
// verify hook has already proven integrity by the time the handler
// runs; calling `verify` again here just yields the payload.
const state = ctx.mcpReq.requestState === undefined ? undefined : await stateCodec.verify(ctx.mcpReq.requestState, ctx);
const step = state?.step ?? 'confirm';
console.error(`[server] tools/call deploy(${env}) step=${step}`);

Expand All @@ -88,7 +71,7 @@ function buildServer(): McpServer {
},
// The next entry stays at the 'confirm' step until the
// user actually accepts.
requestState: mintState({ step: 'confirm', env })
requestState: await stateCodec.mint({ step: 'confirm', env })
});
}
// Move to the URL-mode sign-in step. URL elicitation rides
Expand All @@ -102,7 +85,7 @@ function buildServer(): McpServer {
url: `https://example.com/auth?env=${env}`
})
},
requestState: mintState({ step: 'signed-in', env })
requestState: await stateCodec.mint({ step: 'signed-in', env })
});
}

Expand Down
8 changes: 7 additions & 1 deletion packages/middleware/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export * from './middleware/hostHeaderValidation.js';
export * from './middleware/originValidation.js';
export * from './streamableHttp.js';
export type { FetchLikeMcpHandler, NodeIncomingMessageLike, NodeMcpRequestHandler, NodeServerResponseLike } from './toNodeHandler.js';
export type {
FetchLikeMcpHandler,
NodeIncomingMessageLike,
NodeMcpRequestHandler,
NodeServerResponseLike,
ToNodeHandlerOptions
} from './toNodeHandler.js';
export { toNodeHandler } from './toNodeHandler.js';
Loading
Loading