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.
9 changes: 5 additions & 4 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ The 2026-07-28 revision removes the server→client JSON-RPC request channel; se
| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` |
| handler shared across both eras | branch on the served era: keep the v1 push-style call toward 2025-era requests, return `inputRequired(...)` toward 2026-07-28 requests |

`inputRequired`/`acceptedContent`/`InputRequiredSpec` are exported from `@modelcontextprotocol/server`. `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. Client
`inputRequired`/`acceptedContent`/`InputRequiredSpec` are exported from `@modelcontextprotocol/server`. `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, or drop the SDK's `createRequestStateCodec({ key, ttlSeconds?, bind? })` into `ServerOptions.requestState.verify` (mint with `codec.mint`, decode on re-entry with `codec.verify`). Client
side: auto-fulfilment is on by default (`ClientOptions.inputRequired`, `maxRounds` cap default 10); manual mode is `inputRequired: { autoFulfill: false }` plus per-call `allowInputRequired: true` and `withInputRequired(schema)`.

## 13. Behavioral Changes
Expand Down Expand Up @@ -610,9 +610,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 thread
claude[bot] marked this conversation as resolved.

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

Expand Down
11 changes: 8 additions & 3 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1272,9 +1272,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/mrtr/server.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
4 changes: 4 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ export type { OriginValidationResult } from './server/middleware/originValidatio
export { localhostAllowedOrigins, originValidationResponse, validateOriginHeader } from './server/middleware/originValidation.js';
export type { PerRequestHTTPServerTransportOptions, PerRequestMessageExtra, PerRequestResponseMode } from './server/perRequestTransport.js';
export { PerRequestHTTPServerTransport } from './server/perRequestTransport.js';
// Opt-in HMAC sealing for the multi-round-trip requestState (SEP-2322): the
// convenience codec consumers drop into ServerOptions.requestState.verify.
export type { RequestStateCodec, RequestStateCodecOptions } from './server/requestStateCodec.js';
export { createRequestStateCodec } from './server/requestStateCodec.js';
export type { ServerOptions } from './server/server.js';
export { Server } from './server/server.js';
// subscriptions/listen change-event sourcing seam (protocol revision 2026-07-28).
Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ export interface CreateMcpHandlerOptions {

/**
* The handler returned by {@linkcode createMcpHandler}: a web-standard
* `{ fetch, close, notify }` object — the shape Workers/Bun/Deno expect from
* `export default`. `fetch` is an arrow-assigned bound property: it can be
* `{ fetch, close, notify, bus }` object — the shape Workers/Bun/Deno expect
* from `export default`. `fetch` is an arrow-assigned bound property: it can be
* detached and passed around (`const { fetch } = handler`) without losing its
* binding.
*
Expand Down
Loading
Loading