Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP by felixweinberger · Pull Request #2131 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP by felixweinberger · Pull Request #2131 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP by felixweinberger · Pull Request #2131 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP by felixweinberger · Pull Request #2131 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP by felixweinberger · Pull Request #2131 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP by felixweinberger · Pull Request #2131 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP by felixweinberger · Pull Request #2131 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP - #2131

Closed
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless
Closed

[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP#2131
felixweinberger wants to merge 10 commits into
fweinberger/v2-dispatcherfrom
fweinberger/v2-http-stateless

Conversation

@felixweinberger

@felixweinbergerfelixweinberger commented May 20, 2026

Copy link
Copy Markdown
Contributor

2026-06 stateless stack (v2-stateless label):

#PR
1#2128tasks-delete (mechanical)
2#2129schema-sync (mechanical)
3#2130Dispatcher extraction (zero-Δ refactor)
4#2131HTTP-stateless (the substantive review)
5#2132stdio/InMemory transports (additive)
6#2133docs + changeset
7#2134LegacyServer/LegacyClient extraction

Implements SEP-2575 (stateless connection model), SEP-2322 (MRTR), and SEP-2567 (per-message routing) over StreamableHTTP.

Server/Client remain the same classes; each gains a // 2026 stateless section. New: stateless.ts, subscriptions.ts, statelessHttp.ts, handleHttp.ts, asyncQueue.ts. Transport interface gains optional setStatelessHandlers/sendAndReceive.

Motivation and Context

2026-06 spec release.

How Has This Been Tested?

pnpm test:all (1367). Conformance vs modelcontextprotocol/conformance@main: 32 scenarios / 60 checks / 0 failed; server-stateless 17/17.

Breaking Changes

None to existing API; additive. @deprecated JSDoc added to session-dependent top-level methods (still work; ctx.mcpReq.* is the both-protocols path).

Types of changes

  • New feature

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@changeset-bot

changeset-botBot commented May 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ddfc2b3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/coreMajor
@modelcontextprotocol/serverMajor
@modelcontextprotocol/clientMajor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2131

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2131

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2131

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2131

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2131

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2131

commit: ddfc2b3

@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 09fc142 to 315684dCompareMay 21, 2026 10:42
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from af76de4 to b837b6cCompareMay 21, 2026 10:42
Comment threadpackages/client/src/client/client.ts Outdated
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
Comment threadpackages/client/src/client/client.ts
NEW core/shared/stateless.ts: parseClientMeta, ClientMeta, META_KEYS,
STATEFUL_PROTOCOL_VERSIONS, isStatelessProtocolVersion, isStatelessRequest,
STATELESS_REMOVED_METHODS, InputRequiredError/isInputRequiredError,
DispatchContext, StatelessHandlers, ListenContext, ListenStream.
Transport interface gains optional setStatelessHandlers (server-side) and
sendAndReceive (client-side). Both unimplemented at this commit; transport
routers and Client wiring come in P3.
Exported from core internal barrel and core/public.
Satisfies: 2575-R1 (per-request _meta keys), 2575-R3 (version classification)
`subscriptions/listen` is the one 2026-06 method that is request->stream
rather than request->response, so it lives outside the dispatcher.
InMemorySubscriptions: in-memory backend keyed by server-minted UUID
(clients on a shared instance cannot collide); wire `_meta.subscriptionId`
is `String(request.id)`. Queue cap 256 (evict slow consumers).
`resourceSubscriptions` capped at 256 + Set lookup; fail-closed without
`onAuthorizeResourceSubscription`.
Satisfies: 2575-R6 (subscriptions/listen + ack)
…iptions + statelessHandlers (sectioned)
Server (existing class, extends Protocol) gains a clearly-divided
`// 2026 stateless` section with:
- `subscriptions: SubscriptionBackend` (defaults to InMemorySubscriptions)
- `statelessHandlers(): {dispatch, listen}`
- `_dispatchStateless(req, dctx)` — version check, removed-methods reject,
build ctx, dispatcher.dispatch, default resultType (not for discover)
- `_buildDispatchServerContext` — MRTR throw-then-cache for elicit/sampling/
listRoots, notify stamps subscriptionId (server wins), log severity-gated,
send throws
- `_ondiscover()` — returns supportedVersions/capabilities/serverInfo
- `inputRequiredMiddleware` (module-level) — InputRequiredError → cap-gate
→ InputRequiredResult
`// dual-mode` section: `connect()` override wires setStatelessHandlers;
`send*ListChanged` get `subscriptions.notify(...)` prepended.
`ServerContext` gains `clientCapabilities?` + `mcpReq.listRoots`. Existing
`buildContext` populates them from session state.
Existing session-dependent methods (`createMessage`, `elicitInput`,
`listRoots`, `sendLoggingMessage`, `_oninitialize`, `ping`, etc.) are
unchanged; comment divider documents the per-method 2026 equivalents.
`ProtocolErrorCode.MissingRequiredClientCapability = -32003` added (spec
`MISSING_REQUIRED_CLIENT_CAPABILITY`).
Satisfies: 2575-R2 (discover), 2575-R4 (removed methods), 2575-R5 (per-request
ctx), 2322-R1 (InputRequiredError), 2322-R2 (cap-gate -32003)
Tool wrapper catch checks isInputRequiredError(e) and re-throws so
inputRequiredMiddleware translates to InputRequiredResult (otherwise the
error would be swallowed into an isError:true CallToolResult and MRTR
would not work for McpServer-registered tools).
McpServer.sendLoggingMessage JSDoc points to ctx.mcpReq.log() for the
both-protocols path.
Satisfies: 2322-R2
`statelessHttpHandler(handlers, req, opts)`: POST-only, CT exact-match → 415,
bounded streaming reader (never trust Content-Length), batch cap 64, per-request
`_meta` validation (presence, stateless-ness, header agreement),
`subscriptions/listen` → SSE, dispatch → JSON or SSE per Accept. Explicit 400
for non-request/non-notification messages. `sseResponse` releases listener
registration in `finally` (not only via abort).
`handleHttp(server, opts)`: host/origin allowlist BEFORE auth callback,
IPv6-safe `stripPort` (brackets removed), then `statelessHttpHandler`.
`SUPPORTED_PROTOCOL_VERSIONS` gains `DRAFT_PROTOCOL_VERSION` (at the end so
`[0]` stays latest-released; 2026 is opted into via discover auto-probe).
`ProtocolErrorCode.HeaderMismatch = -32001`.
Satisfies: 2575-R7 (HTTP entry), 2575-R8 (per-request _meta validation),
2567-R1 (header/meta agreement)
StreamableHTTPClientTransport.sendAndReceive: async generator over fetch
(SSE-parse or JSON body). Auth via _commonHeaders; 401/403 retry left to
caller. Self-contained; does not go through Protocol.request().
Satisfies: 2575-R12 (client sendAndReceive contract, HTTP)
streamableHttp server: handleRequest routes by MCP-Protocol-Version
header (falls back to body _meta) to statelessHttpHandler; pre-2026 or
absent header falls through to handleStatefulRequest (body unchanged,
GHSA-345p guard stays inside).
Node middleware: setStatelessHandlers forwards to wrapped web-standard
transport.
Server.connect() already calls transport.setStatelessHandlers?.() (C7).
StreamableHTTPClientTransport.sendAndReceive gains opts?.signal
(AbortSignal.any with transport-wide controller).
Satisfies: 2567-R1 (HTTP), 2575-R7
…ss/subscribe; typed methods route via _send (sectioned)
Client (existing class, extends Protocol) gains a `// 2026 stateless` section:
- `_isStateless`, `_logLevel`
- `_buildMeta()` / `_withMeta()` — namespaced `_meta` from client identity
- `_collect(it, opts)` — drain sendAndReceive: progress→onprogress by token,
return raw result, throw on JSON-RPC error
- `_send(req, schema, opts)` — route via sendAndReceive when stateless, else
fall back to `Protocol.request()`. MRTR loop ≤16: on input_required,
dispatch each input request via `this.dispatcher.dispatch` (so
`_validationMiddleware` runs), accumulate inputResponses + thread
requestState, propagate signal
- `_negotiate(transport)` — probe server/discover, set `_isStateless` on
success, fall through on isFallbackable error (wired to connect() in C13)
- `subscribe(filter)` — async generator over subscriptions/listen
- `_listChangedLoop` — stateless backing for options.listChanged with debounce
`// dual-mode`: typed methods (callTool/listTools/getPrompt/listPrompts/
readResource/listResources/listResourceTemplates/complete) route via `_send`.
`setLoggingLevel` stores level for `_buildMeta`, sends legacy RPC when not
stateless.
`// session-dependent` divider above existing `connect()`/`ping()`/
`subscribeResource()`/`_setupListChangedHandler*` (bodies unchanged).
`applyElicitationDefaults` unchanged.
Satisfies: 2575-R10 (per-request _meta), 2575-R11 (discover probe),
2575-R12 (sendAndReceive routing), 2322-R3 (MRTR resume loop),
2322-R4 (requestState round-trip)
connect() now probes server/discover via transport.sendAndReceive
before the legacy initialize handshake. On success the client enters
stateless mode (server identity/capabilities from DiscoverResult,
initialize skipped). On MethodNotFound / HTTP 4xx / parse failure it
falls through to the legacy initialize (extracted verbatim into
_initialize()).
_setupListChanged() routes options.listChanged to _listChangedLoop
(subscriptions/listen) when stateless, else to the existing
notification-handler path.
Existing tests that exercise pre-2026 connection-model behavior now
need LegacyTestClient (C14).
…atchV2 target
NEW test/integration/__fixtures__/testClient.ts: LegacyTestClient —
advertises only pre-2026 versions so connect() skips discover probe.
NEW statelessAcceptance.test.ts (HTTP scenarios): Server stateless
dispatch, SubscriptionBackend, handleHttp, StreamableHTTP zero-change
consumer, audit invariants.
conformance: extract everythingServerSetup.ts; add
everythingServerDispatchV2.ts target wired to run-server-conformance.sh.
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-dispatcher branch from 315684d to 628f0e1CompareMay 21, 2026 11:15
@felixweinberger
felixweinbergerforce-pushed the fweinberger/v2-http-stateless branch from b837b6c to ddfc2b3CompareMay 21, 2026 11:15
@felixweinberger

Copy link
Copy Markdown
ContributorAuthor

@claude review

Comment on lines +604 to +617
try {
for await (const n of this.subscribe(filter, { signal })) {
debounced[n.method]?.();
}
// Stream ended without error and without our abort: surface so the
// caller knows list-changed delivery has stopped.
if (!signal.aborted) {
throw new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen stream ended');
}
} finally {
for (const t of timers.values()) clearTimeout(t);
timers.clear();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 When close() calls _listChangedAbort?.abort(), the abort propagates into sendAndReceive's reader.read() which rejects with an AbortError; the for await in _listChangedLoop then throws rather than ending, so the post-loop if (!signal.aborted) guard is dead code on the abort path and the rejection lands in _setupListChanged()'s .catch, firing onerror (or console.error) with a spurious AbortError on every clean close() of a stateless client with listChanged configured. Wrap the for await in a try/catch and swallow the error when signal.aborted is true.

Extended reasoning...

What happens

Client.close() does:

overrideasyncclose(): Promise<void>{this._isStateless=false;this._listChangedAbort?.abort();// <-- aborts the listen stream
...
}

That signal is passed through _listChangedLoop()subscribe()StreamableHTTPClientTransport.sendAndReceive(), where it is composed into the fetch signal (via AbortSignal.any). When the abort fires while the SSE body is being read, reader.read() rejects with an AbortError per the Fetch spec.

Why it surfaces as an error

The rejection then propagates upward through three frames, none of which catch it:

  1. sendAndReceive()'s SSE loop has only a try/finally (the finally calls reader.cancel()), so the async generator rethrows.
  2. subscribe()'s for await (const m of sar(...)) has no try/catch, so the generator rethrows.
  3. _listChangedLoop()'s for await (const n of this.subscribe(...)) has only a try/finally (the finally clears debounce timers), so the loop rethrows.

The rejection therefore reaches the call site in _setupListChanged():

this._listChangedLoop(kinds).catch(error=>(this.onerror??console.error)(errorinstanceofError ? error : newError(String(error))));

which fires the caller's onerror (or dumps to console.error) with the AbortError — on every clean close().

Why this is unintended

The post-loop guard makes the author's intent explicit:

forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}// Stream ended without error and without our abort: surface so the// caller knows list-changed delivery has stopped.if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}

The if (!signal.aborted) check (and its comment) only makes sense if the abort path falls through to that line — i.e. if the loop ends on abort rather than throws. Because it throws, this check is unreachable on the abort path: it is dead code.

Step-by-step proof

  1. new Client(info, { listChanged: { tools: { onChanged } } }).
  2. client.connect(streamableHttpTransport)server/discover succeeds → _isStateless = true_setupListChanged() calls _listChangedLoop(kinds) (fire-and-forget with a .catch).
  3. _listChangedLoop() creates _listChangedAbort, calls subscribe(filter, { signal }), the SSE subscriptions/listen stream connects and the loop blocks in reader.read().
  4. Caller does await client.close().
  5. close() calls _listChangedAbort.abort() → fetch signal aborts → reader.read() rejects with AbortError → propagates through all three frames → .catch in _setupListChanged() calls (this.onerror ?? console.error)(AbortError).

The user observes an error on a normal shutdown with no actual problem.

Impact

Not a correctness/data-loss bug, but:

  • Pollutes error telemetry / logs with a spurious abort on every stateless close().
  • Users who wire client.onerror to alerting/retry logic get spurious triggers.
  • console.error spam if no onerror is set.

Fix

Catch the abort inside _listChangedLoop so the clean-shutdown path is clean:

try{forawait(constnofthis.subscribe(filter,{ signal })){debounced[n.method]?.();}if(!signal.aborted){thrownewSdkError(SdkErrorCode.ConnectionClosed,'subscriptions/listen stream ended');}}catch(error){if(!signal.aborted)throwerror;// close() aborted the stream — clean shutdown, swallow.}finally{for(consttoftimers.values())clearTimeout(t);timers.clear();}

This preserves the existing "stream ended unexpectedly → surface ConnectionClosed" behavior and the existing "stream errored → surface the error" behavior, while making the abort path silent.

- Prefer `ctx.mcpReq.{elicitInput, requestSampling, listRoots, log}` inside
handlers; works under both protocols (MRTR under 2026-06).

See `docs/migration.md` for the full guide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The changeset added in this PR closes with "See docs/migration.md for the full guide." but docs/migration.md is not modified here and currently has no content covering the 2026-06 stateless model (server/discover, subscriptions/listen, handleHttp, MRTR, etc.). If a release is cut after this PR merges but before the docs PR (#2133) lands, the published changelog will point readers at a guide that doesn't exist — soften the sentence (e.g. "A migration guide will be added to docs/migration.md") or move it to #2133.

Extended reasoning...

What the issue is

.changeset/stateless-2026-06.md is added in this PR and ends with:

See docs/migration.md for the full guide.

This is a forward reference to documentation that this PR does not add. Grepping docs/migration.md (and docs/migration-SKILL.md) for the new surface — stateless, server/discover, subscriptions/listen, 2026-06, DRAFT-2026, handleHttp, MRTR — returns zero matches. Nothing in this diff touches docs/.

Why it matters

Changeset files are the source of release notes: when changesets cuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes to docs/migration.md will not find a 2026-06 section. The PR description does list #2133 ("docs + changeset") later in the stack, so the docs are clearly planned — but the changeset itself ships here in #2131, not in #2133. The risk window is concrete: if #2131 merges and a release is cut before #2133 lands, the published changelog points at content that doesn't exist.

Step-by-step

  1. PR [SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131 merges with .changeset/stateless-2026-06.md containing the "See docs/migration.md" sentence.
  2. A maintainer (or CI) runs changeset version + changeset publish before docs: 2026-06 migration guide + examples + changeset #2133 merges.
  3. The CHANGELOG entries for @modelcontextprotocol/{core,server,client} now contain a literal "See docs/migration.md for the full guide." line.
  4. A consumer reading the npm changelog or GitHub release follows the reference and finds no stateless/2026-06 content in docs/migration.md at the published tag.

Why this isn't already prevented

There is nothing in CI that cross-checks changeset prose against the diff — this is exactly the class of issue the repo's "Documentation & Changesets" review convention exists to catch (read .changeset/*.md text against the implementation in the same diff and flag claims the diff doesn't back).

How to fix

Pick one of:

  • Soften the sentence so it doesn't promise content that doesn't exist yet: "A migration guide will be added to docs/migration.md."
  • Move the changeset (or just this sentence) to docs: 2026-06 migration guide + examples + changeset #2133 where the migration guide actually lands.
  • Land the migration guide section in this PR.

This is purely a release-notes hygiene / stacked-PR sequencing item — no code impact, and it self-resolves if #2133 lands before any release. Worth a one-line tweak so a release between the two PRs doesn't ship a dangling reference.

Comment on lines +856 to +860
}

this._instructions = result.instructions;
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The legacy _initialize() validation !this._supportedProtocolVersions.includes(result.protocolVersion) now accepts DRAFT-2026-v1 (because this PR added it to SUPPORTED_PROTOCOL_VERSIONS), so a misbehaving server that returns a stateless version from initialize is accepted while _isStateless stays false — leaving the client with a stateless protocol header but legacy request routing. The server side already mirrors the right behavior in _oninitialize() (legacySupported = ...filter(v => !isStatelessProtocolVersion(v))); apply the same filter here.

Extended reasoning...

What the bug is

This PR adds DRAFT_PROTOCOL_VERSION ('DRAFT-2026-v1') to SUPPORTED_PROTOCOL_VERSIONS in packages/core/src/types/constants.ts. That widens the membership check in the client-side legacy handshake _initialize():

if(!this._supportedProtocolVersions.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

Before this PR, a server that responded to initialize with a stateless version (e.g. DRAFT-2026-v1) would be rejected. After this PR, that response is accepted — and the client then sets _negotiatedProtocolVersion = 'DRAFT-2026-v1' and transport.setProtocolVersion('DRAFT-2026-v1') while _isStateless remains false.

The asymmetry

The server side of this PR explicitly handles the equivalent case. Server._oninitialize() filters out stateless versions before negotiating, with a comment stating the design intent:

// The legacy initialize handshake never agrees on a stateless (2026+)// version: a client that wants 2026 sends server/discover, not this.constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));constprotocolVersion=legacySupported.includes(requestedVersion)
? requestedVersion
: (legacySupported[0]??LATEST_PROTOCOL_VERSION);

The client-side mirror — the _initialize() membership check — was not given the same filter. The PR did half of the migration.

What goes wrong

After accepting the stateless version over the legacy handshake, the client is in a self-contradictory state:

  • _negotiatedProtocolVersion === 'DRAFT-2026-v1' — a stateless version
  • transport.setProtocolVersion('DRAFT-2026-v1') — every subsequent HTTP request carries MCP-Protocol-Version: DRAFT-2026-v1
  • _isStateless === false — so _send() falls through to Protocol.request(), which puts no _meta.protocolVersion (or any of the other 2026 _meta keys) on outgoing requests

A 2026-06 server that routes by header (WebStandardStreamableHTTPServerTransport.handleRequest) sees MCP-Protocol-Version: DRAFT-2026-v1, sends the request to statelessHttpHandler, and gets a 400 (Missing required _meta.io.modelcontextprotocol/protocolVersion).

Step-by-step proof

  1. Client connects to a transport with sendAndReceive. _negotiate() sends server/discover; the (misbehaving) server returns -32601, so the client falls back.
  2. _initialize() sends legacy initialize with protocolVersion: '2025-11-25'.
  3. The server (non-SDK, misbehaving) replies with { protocolVersion: 'DRAFT-2026-v1', ... }.
  4. Pre-PR: SUPPORTED_PROTOCOL_VERSIONS.includes('DRAFT-2026-v1') is false → throws Server's protocol version is not supported. ✅
  5. Post-PR: the membership check passes → _negotiatedProtocolVersion = 'DRAFT-2026-v1', transport header set to DRAFT-2026-v1, _isStateless still false. ❌
  6. Subsequent client.listTools() goes through Protocol.request() → POST with MCP-Protocol-Version: DRAFT-2026-v1 and no _meta.protocolVersion → server-side router sends it to the stateless handler → 400.

Why this is a nit, not blocking

The trigger requires a non-SDK server that doesn't respond to server/discover but does respond to initialize with a stateless version — a combination the spec doesn't allow and the SDK's own server side prevents (because of the filter quoted above). It's a defensive-consistency gap in a partial migration rather than a bug reachable through any conforming peer.

Fix

Mirror the server-side filter in _initialize():

constlegacySupported=this._supportedProtocolVersions.filter(v=>!isStatelessProtocolVersion(v));if(!legacySupported.includes(result.protocolVersion)){thrownewError(`Server's protocol version is not supported: ${result.protocolVersion}`);}

(Or check against STATEFUL_PROTOCOL_VERSIONS directly.) Two lines, brings the client in line with the server's stated invariant that the legacy handshake never agrees on a stateless version.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2-stateless2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@felixweinberger