This is the documentation for the v2 beta — looking for the v1 documentation?
Skip to content

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 instead.

Schema artifact: until the revision is finalized, the spec repository publishes the 2026-07-28 schema under schema/draft/ — there is no schema/2026-07-28/ directory yet. Tooling that vendors per-revision schema artifacts should track draft/ and note the divergence.

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

These entry points are documented in full in Protocol versions; 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.

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 on the same connection against a 2025-only server (one extra round trip).
  • 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) — 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. 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 on the same stream (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.

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: persistedDiscoverResult }).

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.

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.

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 requestcreateMcpHandler 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 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 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. 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 patternMechanical fix
parsing wire bytes with EmptyResultSchema that may carry resultTypestrip resultType first (the schema now rejects it as an unknown key)
specTypeSchemas / SpecTypeName references to task message types or RequestMetaEnveloperemove — these validators left the public set (the types remain importable)
ClientRequest / ServerResult / … aggregate types expected to include task membersuse the individual deprecated Task* types — role aggregates are now the neutral (task-free) sets
relying on isCallToolResult to reject wire-only membersguards 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 (content required, custom-handler _meta passthrough, specTypeSchemas narrowing) is in upgrade-to-v2.md › Wire tightening.

If you were on a v2 alpha: the 2026-07-28 draft 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:

PatternMechanical fix
result.resultType (typed read)delete the read — the SDK consumes the field; results are complete when delivered
Result['resultType'] type referenceremove; 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.


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 requestsMechanical 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 erasno branch needed — write the inputRequired(...) form once; the legacy shim 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 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:

MemberDefaultMeaning
maxRounds8Handler 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
roundTimeoutMs600_000Per-leg timeout (with resetTimeoutOnProgress) — embedded requests are human-paced, so the 60s protocol default does not apply
legacyShimtruefalse 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.


Appendix: 2025-era vs 2026-era behavior matrix

Axis2025-era (2024-10-07 … 2025-11-25)2026-07-28
Server HTTP entry*StreamableHTTPServerTransportcreateMcpHandler (legacy: 'stateless' also serves 2025)
Server stdio entryserver.connect(new StdioServerTransport())serveStdio(factory) (also serves 2025 unless legacy: 'reject')
Client connectinitialize handshakeserver/discover probe (versionNegotiation)
Client identitygetClientCapabilities() / getClientVersion() (initialize-scoped)ctx.mcpReq.envelope (per request)
Server→client requestsctx.mcpReq.elicitInput / requestSampling, instance createMessage() etc.return inputRequired(...) from handler
Change notificationsunsolicited list_changed / resources/updatedsubscriptions/listen stream
Client cancellation (Streamable HTTP)POST notifications/cancelledclose the request's SSE response stream
ctx.mcpReq.log() level filtersession-scoped logging/setLevelper-request _meta.logLevel envelope key (absent = opt-out)
400 JSON-RPC error bodySdkHttpErrorProtocolError (in-band)
Era-mismatched spec method (outbound)n/aSdkError(MethodNotSupportedByProtocolVersion)