--- title: Supporting protocol revision 2026-07-28 --- # Supporting protocol revision 2026-07-28 This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28 protocol revision — and for code written against an earlier **v2 alpha** that read wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with [upgrade-to-v2.md](./upgrade-to-v2.md) instead. Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed `Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries below. ## Contents - [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) - [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) - [Auth on 2026-07-28](#auth-on-2026-07-28) - [Per-era wire codecs](#per-era-wire-codecs) - [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) - [Server identity in result `_meta`; `clientInfo` demoted to SHOULD](#server-identity-in-result-_meta-clientinfo-demoted-to-should) - [Multi-round-trip requests](#multi-round-trip-requests) - [Legacy shim for `input_required`](#legacy-shim-for-input_required) - [`subscriptions/listen`](#subscriptionslisten) - [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243) - [Cache fields and cache hints](#cache-fields-and-cache-hints) - [Tasks: deprecated wire vocabulary](#tasks-deprecated-wire-vocabulary) - [Appendix: 2025-era vs 2026-era behavior matrix](#appendix-2025-era-vs-2026-era-behavior-matrix) --- ## Serving the 2026-07-28 revision These entry points are documented in full in [Protocol versions](../protocol-versions.md); this section contextualizes them as the migration path. ### Client side: `versionNegotiation` By default `Client.connect()` performs the same 2025 `initialize` handshake as v1.x, byte for byte. To negotiate the 2026-07-28 era, opt in via `ClientOptions.versionNegotiation` — see [Negotiate the era from the client](../protocol-versions.md#negotiate-the-era-from-the-client). ```typescript const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(transport); client.getProtocolEra(); // 'modern' | 'legacy' ``` - **absent / `mode: 'legacy'`** (default) — today's behavior, no probe. - **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake against a 2025-only server (one extra round trip; on the SDK's stdio transport the probe rides a disposable sibling process — see below). - **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with `SdkError(EraNegotiationFailed)` against a 2025-only server. `ProtocolOptions.supportedProtocolVersions` — the same option that pins what the legacy `initialize` handshake offers (see [upgrade-to-v2.md › Client connection & dispatch](./upgrade-to-v2.md#client-connection-dispatch)) — shapes `'auto'`: the modern candidates are the option's modern entries (when it lists any; otherwise the SDK's default modern set), and legacy fallback is available only if the list has a pre-2026 entry. A `{ pin }` is honored as given — it must name a modern revision but is not checked against the list. #### Probe policy Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems. Anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era revision; with a modern-only list `connect()` rejects with `SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect error. Auth statuses are another exception: an HTTP `401` or `403` rejecting the probe is never era evidence — `connect()` rejects with a typed authorization failure (an `SdkHttpError` with code `ClientHttpAuthentication`/`ClientHttpForbidden` naming the status, or the transport auth flow's own error propagated unchanged) instead of falling back — see [Protocol versions](../protocol-versions.md). This holds even for a front that answers the probe `403` but would pass `initialize`: relying on a legacy fallback there is a deliberate non-goal — fix the auth wall (or pass credentials), because auth status never selects an era. A `5xx` on the probe is a server failure, also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. A `2xx` probe answer that carries no usable reply (a body that is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) is not era evidence either: `connect()` rejects with `SdkError(EraNegotiationFailed)`; an empty SSE stream or a `202` surfaces as the probe timeout instead. Probe timeouts are **transport-aware**: on **stdio** a server that does not answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` (some legacy servers never respond to unknown pre-`initialize` requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — a dead HTTP server is never misreported as legacy. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers. On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses probe in place, like custom stdio-shaped transports) the probe runs on a short-lived **sibling process** spawned from the same parameters (its stderr is discarded, and it is reaped once the era is known): some stdio servers exit on any pre-`initialize` request — servers built on the official Rust SDK, rmcp, behave this way — so the probe must not spend the caller's one child process. A child that exits on the probe is simply a legacy server; the caller's transport spawns exactly once, after the era is known, and its wire never carries `server/discover`. Closing the caller's transport during the probe aborts `connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close rejects with the same typed error as any probe transport failure. ```typescript versionNegotiation: { mode: 'auto', probe: { timeoutMs: 10_000, // default: the standard request timeout maxRetries: 0 // default: no retries — governs timeout re-sends only } } ``` `maxRetries` governs timeout re-sends only (the spec-mandated `-32022` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). **Who should not default to `'auto'`:** spawn-per-invocation CLI and debugging tools. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back; and the probe round trip changes recorded transcripts/raw logs, which matters for tools whose value is byte-stable observation. Such tools should keep the default and expose `'auto'` / a pin as an explicit flag. The probe request itself already carries the per-request `_meta` envelope (`io.modelcontextprotocol/protocolVersion`, `clientInfo`, `clientCapabilities`) — **before** the era is known. Once a modern era is negotiated the client auto-attaches the envelope to every outgoing request and notification. Tooling that classifies traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback path also begins with one enveloped probe. A gateway/worker fleet can skip the probe entirely with `client.connect(transport, { prior: { kind: 'modern', discover } })` (wrapping a persisted `DiscoverResult`) — or, for a server known out-of-band to be legacy, with `{ prior: { kind: 'legacy' } }`, which goes straight to `initialize`. Freshness of a cached legacy verdict is the host's responsibility (a stale one succeeds silently against an upgraded server); stop supplying it and the configured mode decides again (an `'auto'` client re-probes). ### Server over HTTP: `createMcpHandler` `createMcpHandler(factory)` from `@modelcontextprotocol/server` is the v2 HTTP entry that serves 2026-07-28 per request — and, by default (`legacy: 'stateless'`), also serves 2025-era traffic per request through the established stateless idiom. One factory, one endpoint, both eras. ```typescript import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); // register tools/resources/prompts once — the same factory backs both eras return server; }); // Web-standard runtimes: export default handler; // Node frameworks: app.all('/mcp', toNodeHandler(handler)) from @modelcontextprotocol/node ``` A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. An existing **sessionful** v1 Streamable HTTP setup keeps serving 2025 clients by routing it in front of a strict (`legacy: 'reject'`) entry with `isLegacyRequest(request)`: ```typescript const modern = createMcpHandler(factory, { legacy: 'reject' }); export default { async fetch(request: Request) { if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return modern.fetch(request); } }; ``` `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim; route `false` traffic to the modern handler (a malformed modern claim is `false` and answered `-32602` / `-32020` by the modern path). The handler is web-standards-only (`{ fetch, close, notify, bus }`); on Node frameworks wrap once with `toNodeHandler(handler, { onerror? })` from `@modelcontextprotocol/node`. The exported `legacyStatelessFallback(factory)` is the same stateless 2025 serving as a standalone fetch-shaped handler. > **If you were on a v2 alpha:** `handler.node(req, res, body)` is gone — replace with > `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import. > `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from > `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`. > > Also: a `MissingRequiredClientCapabilityError` (`-32021`) produced **after** dispatch > — the `input_required` gate refusing an embedded request whose capability the caller > did not declare — now answers HTTP **400** (earlier alphas surfaced it in-band on > 200). The spec mandates 400 for this error wherever it arises; the JSON-RPC body is > unchanged. This applies to a handler-thrown `-32021` too: a proxy relaying a > downstream server's `-32021` should translate it (its `requiredCapabilities` > describes the downstream hop's envelope) rather than rethrow the bare error. Every > other handler-produced code (including a relayed `-32020`/`-32022`) > keeps the in-band 200, and an exchange whose response stream is already open — the > handler streamed first, or `responseMode: 'sse'` — keeps its committed 200 and > carries the error in-stream. ### Server over stdio / long-lived connections: `serveStdio` A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` serves only the 2025-era protocol — upgrading the SDK changes nothing about what it puts on the wire. Serving 2026-07-28 (or both eras) on stdio goes through the connection-pinned `serveStdio(() => buildServer())` entry from `@modelcontextprotocol/server/stdio`; the opening exchange selects the connection's era, and one factory instance is pinned per connection. See [Serve over stdio](../serving/stdio.md). To migrate an existing stdio server, replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`. Pass `{ legacy: 'reject' }` to refuse 2025-era openings. On 2026-pinned connections, `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision. A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. ### In-process testing There is no in-memory serving entry — `InMemoryTransport.createLinkedPair()` connects 2025-era instances only. To exercise 2026-07-28 behavior in tests without sockets, drive `createMcpHandler` directly through its fetch function: ```typescript const handler = createMcpHandler(buildServer); const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { fetch: (url, init) => handler.fetch(new Request(url, init)) }); ``` The URL is never dialed — `handler.fetch` serves the request in-process. For stdio-era coverage, spawn `serveStdio` as a child process. ### Client cancellation on Streamable HTTP On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (`signal` / timeout) closes that request's SSE response stream — the spec cancellation signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling code. 2025-era connections and stdio at any era still send `notifications/cancelled` (except for the `initialize` handshake, which the spec forbids cancelling — an aborted or timed-out `connect()` rejects locally and sends nothing). Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may opt in by declaring `readonly hasPerRequestStream = true`. ### `ctx.mcpReq.log()` and the per-request `logLevel` On a 2026-07-28 request, `ctx.mcpReq.log()` reads its level filter from the `io.modelcontextprotocol/logLevel` `_meta` envelope key (the modern replacement for the `logging/setLevel` RPC). When the key is **absent** the server emits no `notifications/message` for that request — absence is opt-out, not "no filter". The SDK `Client` does not auto-attach `logLevel`, so handler logs on a default 2026-era exchange are silently suppressed until the client opts in. --- ## Replacing per-session state: `requestState` The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh server per request and there is no `Mcp-Session-Id`. If your v1 server kept state keyed on the session id (`ctx.sessionId` / `extra.sessionId`), the 2026 answer is `requestState`: an opaque string the server returns with `inputRequired(...)` and the client echoes byte-for-byte on the retry. Read it back with the typed accessor `ctx.mcpReq.requestState()` — it returns the payload your configured verify hook decoded (see below), the raw wire string when no hook is configured, or `undefined` when the round carried no state. `requestState` round-trips through the client and is therefore **untrusted input** — integrity-protect it (HMAC / AEAD over the payload, bound to principal, originating method/parameters, and an expiry) and reject failed verification on re-entry. Configure `ServerOptions.requestState.verify` and the seam runs it before the handler whenever `requestState` is present (a thrown rejection answers `-32602` above the tool funnel). The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns `{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify` is exactly the function you assign to the hook. The codec is **signed, not encrypted** (the client can base64url-decode the payload). `mint` and `ctx.mcpReq.requestState()` are the typed encode/read pair: the seam captures what `verify` returns and the accessor hands it to the handler already decoded — no second `verify` call. See `examples/mrtr/server.ts` and [Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape. **Multi-step flows: the phase switch.** `inputResponses` are **per round** — each retry carries only that round's responses, never earlier rounds' (the modern client driver and the [legacy shim](#legacy-shim-for-input_required) both guarantee replace, not accumulate). A flow with more than one input round therefore threads everything it has learned through `requestState`, as a discriminated union of phases, and switches on the phase rather than probing which response keys arrived: ```typescript type BrainstormState = | { step: 'awaiting-count' } | { step: 'awaiting-custom-count'; topic: string } | { step: 'awaiting-ideas'; topic: string; count: number }; const stateCodec = createRequestStateCodec({ key: SECRET }); // ServerOptions: { requestState: { verify: stateCodec.verify } } async (args, ctx) => { const state = ctx.mcpReq.requestState(); switch (state?.step) { case undefined: // first call — ask for the count return inputRequired({ inputRequests: { count: inputRequired.elicit({ … }) }, requestState: await stateCodec.mint({ step: 'awaiting-count' }) }); case 'awaiting-count': { const accepted = acceptedContent(ctx.mcpReq.inputResponses, 'count', COUNT_SCHEMA); // …decide: follow-up question or the sampling round, carrying // everything learned so far inside the next minted state… } case 'awaiting-ideas': { const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); return finish(ideas.kind === 'sampling' ? ideas.result : undefined, state.count, state.topic); } } }; ``` Each `case` knows exactly which answer to read and which data is in scope — the state machine is explicit, and the same handler runs unchanged on 2025-era connections through the legacy shim. --- ## Auth on 2026-07-28 The 2026-07-28 specification's authorization requirements (RFC 9207 `iss` validation, SEP-2352 credential isolation, SEP-2350 scope step-up, SEP-837/SEP-2207 DCR + TLS) are implemented in v2 as **SDK-level opt-ins, not protocol-era gates** — they apply on every era once enabled. The migration steps live in [upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). To be **2026-07-28-conformant**, enable the spec-2026 opt-ins listed there: pass `iss` (or the callback `URLSearchParams`) to `finishAuth`; round-trip the `issuer` stamp on stored credentials; implement `discoveryState()`; and either keep `onInsufficientScope: 'reauthorize'` or handle `InsufficientScopeError` yourself. Nothing in this section is era-switched at the wire layer. --- ## Per-era wire codecs The wire layer is split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is **connection state** on the `Client`/`Server` instance (instances with no negotiated version default to the 2025 era). An edge classification (`MessageExtraInfo.classification`) no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol version` for requests; drop + `onerror` for notifications). Methods deleted by a protocol revision are **physically absent** from that era's registry: an inbound `ping` or `tasks/result` on a 2026-era connection gets `-32601` even if a handler is registered, and sending an era-mismatched spec method (e.g. `server/discover` toward a 2025-era peer, or `ping` toward a 2026-era peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. The one exception is the Tasks extension (SEP-2663), which keeps two names the 2025-11-25 revision had in core: `tasks/get` and `tasks/cancel`. A handler registered for them with an explicit schema (`setRequestHandler('tasks/get', { params, result }, handler)`) is served, and a request sent with an explicit result schema (`request({ method: 'tasks/get', params }, resultSchema)`) is sent, on every era. With no handler, or with a method-keyed handler, they still get `-32601`. If one factory serves both eras and such a handler is meant for 2025-era clients only, register it only when `ctx.era === 'legacy'`; otherwise it answers 2026-07-28 requests too. If you were on a v2 alpha and consumed wire schemas directly: | v2-alpha pattern | Mechanical fix | | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | | `specTypeSchemas` / `SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (the **types** remain importable) | | `ClientRequest` / `ServerResult` / … aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | | relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x impact** — these members did not exist before 2026-07-28. The neutral-model wire tightening that **does** affect v1 code (custom-handler `_meta` passthrough, `specTypeSchemas` narrowing) is in [upgrade-to-v2.md › Wire tightening](./upgrade-to-v2.md#wire-tightening-every-era); `CallToolResult.content` keeps its v1 default on the legacy era (2026-07-28 connections require it explicitly). > **If you were on a v2 alpha:** the 2026-07-28 error codes were renumbered: > `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`, > `UnsupportedProtocolVersion` `-32004`→`-32022`. No v1.x impact (these codes never > existed in v1); v2-alpha code that hard-coded the old literals must update — prefer > `ProtocolErrorCode.*` / `HEADER_MISMATCH_ERROR_CODE`. --- ## Wire-only members hidden from public types The 2026-07-28 wire-level bookkeeping is handled internally and never reaches application code: the `resultType` discrimination field, the reserved per-request `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). - **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. - **`DiscoverResult` hides its cache fields at the type level only.** `ttlMs` / `cacheScope` on `server/discover` are read by the client's response-cache layer and are absent from the public `DiscoverResult` type returned by `getDiscoverResult()` — but they are not removed at runtime: the returned object still carries both, readable via a cast. The wire parse defaults absent or malformed hints to `0` / `'private'`, so only tooling that must distinguish an omitted hint from an advertised default needs raw frames. - **High-level methods return the named public types** (`client.callTool()` → `Promise`, etc.). Handler return positions are unaffected. - **Reserved envelope keys and retry fields appear in no public params/result type.** The `RequestMetaEnvelope` type and the four envelope `*_META_KEY` constants stay exported. The protocol layer enforces the same boundary at runtime: - **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` keys are lifted out of `params._meta` before handlers run. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial`); for notifications there is no per-message context, so lifted envelope keys are dropped. On requests only, `inputResponses` / `requestState` are lifted from top-level params to `ctx.mcpReq.inputResponses` / the `ctx.mcpReq.requestState()` accessor; notification params are never touched. - **Collision note for 2025-era peers.** The `_meta` lift is invisible to conforming 2025 traffic (the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too). The retry-field lift is the one collision: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that uses them as ordinary top-level params has them lifted out of `request.params` (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`). - **Raw-first result discrimination.** On a 2026-era exchange, `'complete'` is consumed and stripped; `'input_required'` is fulfilled by the client's auto-fulfilment driver; any other kind rejects with `SdkError(UnsupportedResultType)` (kind in `error.data.resultType`). On a 2025-era connection a foreign `resultType` is stripped before validation. On a 2026-era exchange `resultType` is REQUIRED; an absent value is a spec violation surfaced as a typed error. **If you were on a v2 alpha** and read the wire shape directly: | Pattern | Mechanical fix | | -------------------------------------- | --------------------------------------------------------------------------------- | | `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | | `Result['resultType']` type reference | remove; the member is no longer declared | | return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | `MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge; dispatch validates it against the instance's negotiated era. --- ## Server identity in result `_meta`; `clientInfo` demoted to SHOULD The final 2026-07-28 revision (spec PR #3002) moved server identity out of the `DiscoverResult` body: servers identify themselves via `_meta['io.modelcontextprotocol/serverInfo']` (constant `SERVER_INFO_META_KEY`) on every response, and the per-request envelope's `clientInfo` is a SHOULD instead of a requirement. What the SDK does: - **Server.** Every 2026-era response gets the `_meta` serverInfo stamp (a handler-authored value wins; 2025-era responses are untouched) — including the entry-built `subscriptions/listen` graceful-close results, whose `_meta` carries the identity next to the subscription id. Requests without `clientInfo` are served; a present-but-malformed value is still rejected. - **Client.** `clientInfo` is still sent on every request (the spec SHOULD). `getServerVersion()` reads the discover result's `_meta`. A server that stamps no identity is simply anonymous: the connection works, `getServerVersion()` is `undefined`, and the response cache partitions under a per-connection surrogate. - **Types.** `DiscoverResult` has no `serverInfo` member, and `RequestMetaEnvelope`'s `clientInfo` is optional. Code that read `discover.serverInfo` moves to `client.getServerVersion()` (or the `_meta` key directly). `serverInfo`/`clientInfo` are self-reported and intended for display, logging, and debugging — do not use them for behavior or security decisions. A malformed `_meta` serverInfo value is treated as absent on receive, per the same clause. --- ## Multi-round-trip requests The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers obtain client input (elicitation, sampling, roots) **in-band** by returning `inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the client retries the original call with the responses. | Handler serving 2026-07-28 requests | Mechanical fix | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | | `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | | handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests | `inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from `@modelcontextprotocol/server`. On 2026-era requests the push-style APIs (`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`, instance-level `createMessage()`/`elicitInput()`/`listRoots()`/`ping()`) fail with a typed local error before anything reaches the wire; their behavior toward 2025-era requests is unchanged. The same split applies to `throw new UrlElicitationRequiredError(...)`: on 2025-era connections it is unchanged — the throw still produces the `-32042` protocol error, not an `isError` result; on 2026-07-28 requests it fails with a clear error steering to `inputRequired.elicitUrl(...)` rather than being converted silently. `requestState` round-trips as an opaque, **untrusted** string — see [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) for the sealing helper and verification hook. **Client side — auto-fulfilment by default.** When a 2026-07-28 call answers `input_required`, the client fulfils the embedded requests through the same handlers registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per call with `allowInputRequired: true` plus `withInputRequired()`. Expect `SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. **Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a structural read with an unvalidated cast), two typed readers ship from `@modelcontextprotocol/server`: - `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous Standard Schema, e.g. a zod object): validates the untrusted accepted content and returns it typed, or `undefined` on mismatch/decline/missing. - `inputResponse(responses, key)` — discriminated view (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) for decline/cancel detection and the non-elicitation kinds. Content conveniences stay in your code — e.g. the text of a sampling response is a one-liner over the discriminated view: ```typescript const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; const text = block?.type === 'text' ? block.text : undefined; ``` --- ## Legacy shim for `input_required` An `input_required` return on a **2025-era** connection is served by the SDK's legacy shim, on by default: each embedded request is sent as a real server→client request (`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — stamped with the originating request's id, so on sessionful Streamable HTTP the requests ride the originating POST's stream — and the handler is re-entered with the collected `inputResponses` until it returns a final result. Handlers are **written once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs remain available for code that still calls them directly. The handler cannot tell which era fulfilled it — the shim mirrors the modern client driver's semantics exactly: - `inputResponses` are **per round** (replaced on every re-entry, never accumulated); multi-step flows thread earlier answers through `requestState`. - `requestState` is echoed byte-exact, and the configured `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would on a modern wire retry (so TTL expiry behaves identically; a rejection answers the frozen `-32602`). - Responses arrive as the bare result objects, era-wire-shape-validated only: elicitation accepted content is NOT re-checked against `requestedSchema` — exactly as on the modern era — so the handler validates with the schema-aware `acceptedContent(responses, key, schema)` overload and can re-issue the request instead of the call dying on a mistyped form field. - Rounds with no embedded requests (requestState-only) are paced at 250ms. - URL-mode elicitation legs are sent with a synthesized `elicitationId` (the 2025-11-25 wire requires one; the 2026 in-band shape has none). Knobs live at `ServerOptions.inputRequired`: | Member | Default | Meaning | | ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow | | `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply | | `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern | Failures surface **per family**: `tools/call` failures (capability refusal, a failed leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC errors. Server bugs (malformed input-required results) fail loudly on both eras. The shim emits no progress of its own. The originating request's `progressToken` identifies a single must-increase stream that belongs to the handler — injecting synthetic ticks into it cannot compose with handler-emitted progress (one stream, one author), so the shim never writes to it: a 2025 client watching a multi-round flow sees exactly what a hand-written 2025 push-style handler would have produced. A handler that reports progress across rounds should derive its values from its phase state so they increase across re-entries — the token spans the whole flow. **Inherited limits** (the same ones hand-written push-style handlers have today): - The shim pre-checks each embedded request kind against the client capabilities declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration counts as form support — the pre-mode meaning, same as the modern `-32021` gate). Capability-less clients get a clean refusal, never a hang. - **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a fresh instance per request: no initialize handshake, no return path for server→client requests. The shim degrades to the clean capability refusal there — full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. - JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client requests mid-call: the transport drops them, so a shim leg waits out `roundTimeoutMs` before failing per family — the same undeliverable class as today's `elicitInput` in that configuration, which waits out its own 60s default. Interactive tools need a streaming-capable session. - The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation is not bridged: URL-mode legs complete like any other elicitation response. The sender API for that channel, `Server.createElicitationCompletionNotifier()`, is itself unchanged from v1 for 2025-era URL-mode elicitation — only the shim does not bridge it. --- ## `subscriptions/listen` The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and `resources/updated` only on a `subscriptions/listen` stream the client opened — the server never sends an un-requested notification type. **Server side.** Nothing to register: the serving entries handle `subscriptions/listen` themselves. `createMcpHandler` returns `.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed publish sugar over an in-process bus (supply your own `ServerEventBus` for multi-process deployments). On stdio, `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era unsolicited delivery model is unchanged on legacy connections. **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. `client.listen(filter)` opens a stream explicitly. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. **Graceful close.** When the server closes the listen stream deliberately (entry `close()`/shutdown), it sends the empty `subscriptions/listen` JSON-RPC result before closing the stream; `McpSubscription.closed` resolves `'graceful'`. A stream close without a result resolves `'remote'` and indicates an unexpected disconnect — re-listen if you still want events. --- ## `Mcp-Param-*` and standard headers (SEP-2243) On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` mirrors tool arguments designated with `x-mcp-header` in the tool's `inputSchema` into `Mcp-Param-{Name}` HTTP request headers (Base64-sentinel-encoded where needed), and `createMcpHandler` rejects a `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32020` (`HeaderMismatch`). The Streamable HTTP transport also emits the `Mcp-Name` standard header on every modern-enveloped request, and `createMcpHandler` 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`, 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 built with this SDK send all three already. A tool that puts `x-mcp-header` on a `number`-typed parameter (zod: `z.number()`) is no longer listed by `Client.listTools()` on a 2026-07-28 connection (stdio excepted), because the specification permits the annotation only on `string`, `integer` and `boolean` parameters and requires clients to exclude a tool that breaks this. A call to it by name is rejected by the server with `400` and `-32020`, since the client sends no `Mcp-Param-*` headers for it. Use an integer (`z.int()`) or remove the annotation. Notification POSTs are exempt from the presence half: this revision defines no client-to-server notifications over Streamable HTTP, and states that header requirements for notification POSTs are not defined by it — so a modern-enveloped notification is dispatched and answered `202` even with no standard headers at all. Do not rely on the entry to reject one. **Modern-era exception** to the `SdkHttpError` mapping: on a modern-enveloped request, an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id is delivered in-band as a `ProtocolError` (so the `-32020` recovery retry can fire). Legacy-era exchanges and generic HTTP failures still surface as `SdkHttpError`. Additive options: `CallToolRequestOptions.toolDefinition` (pass the tool definition directly so mirroring and output-schema validation run without a prior `tools/list`), `TransportSendOptions.headers` (per-request HTTP headers; reserved standard/auth header names are skipped). Browser clients skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS). --- ## Cache fields and cache hints The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results. When serving that revision, the SDK always emits both fields, defaulting to `ttlMs: 0` and `cacheScope: 'private'` (the most conservative policy). To advertise a real cache policy, set `ServerOptions.cacheHints` (per-operation) or `cacheHint` on a `registerResource` metadata object; resolution is per field, most-specific author first. 2025-era responses never carry these fields. --- ## Tasks: deprecated wire vocabulary The task **wire surface** defined by the 2025-11-25 protocol revision is still exported for interoperability with peers on that revision: the task Zod schemas and inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, and `RELATED_TASK_META_KEY`. All are now `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support). Task methods are excluded from the typed method maps: `RequestMethod` / `RequestTypeMap` / `ResultTypeMap` / `NotificationTypeMap` have no `tasks/*` or `notifications/tasks/status` entries, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. (Typings published before `2.0.0-alpha.4` predate this exclusion: there the typed maps still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with the `isCallToolResult` guard if you are pinned to one of those alphas. `2.0.0-alpha.4` and later include the exclusion.) Where task interop is genuinely required, use the explicit-schema custom-method form on both sides: `request({ method: 'tasks/get', params }, resultSchema)` to send and `setRequestHandler('tasks/get', { params, result }, handler)` to serve, with the schemas of the revision you are talking to (`GetTaskResultSchema` is the 2025-11-25 shape). On a 2026-era connection only `tasks/get` and `tasks/cancel`, the two names the Tasks extension keeps, are served and sent this way; `tasks/result` and `tasks/list` stay refused there. With no handler registered, inbound `tasks/*` requests get `-32601`. The experimental tasks **interception** layer is removed entirely — see [upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). --- ## Appendix: 2025-era vs 2026-era behavior matrix | Axis | 2025-era (2024-10-07 … 2025-11-25) | 2026-07-28 | | ------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ | | Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | | Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | | Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | | Client identity | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | | Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` etc. | `return inputRequired(...)` from handler | | Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | | Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | | `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `_meta.logLevel` envelope key (absent = opt-out) | | `400` JSON-RPC error body | `SdkHttpError` | `ProtocolError` (in-band) | | Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` |