# 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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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](https://ts.sdk.modelcontextprotocol.io/v2/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](https://ts.sdk.modelcontextprotocol.io/v2/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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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](https://ts.sdk.modelcontextprotocol.io/v2/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)`. 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](https://ts.sdk.modelcontextprotocol.io/v2/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`.
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<T>()` — 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<T>` and
`ctx.mcpReq.requestState<T>()` 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<BrainstormState>({ key: SECRET });
// ServerOptions: { requestState: { verify: stateCodec.verify } }

async (args, ctx) => {
    const state = ctx.mcpReq.requestState<BrainstormState>();
    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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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 `tasks/get` 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 any `tasks/*` method toward a 2026-era peer) throws
`SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport.

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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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<CallToolResult>`, 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<RequestMetaEnvelope>`); 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.

**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
(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*`
requests → `-32601`.

The experimental tasks **interception** layer is removed entirely — see
[upgrade-to-v2.md › Experimental tasks interception removed](https://ts.sdk.modelcontextprotocol.io/v2/migration/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)`                    |
