-
Notifications
You must be signed in to change notification settings - Fork 1.9k
[SEP-2575][SEP-2322][SEP-2567] 2026-06 stateless support over StreamableHTTP #2131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
felixweinberger
wants to merge
10
commits into
fweinberger/v2-dispatcher
Choose a base branch
from
fweinberger/v2-http-stateless
base: fweinberger/v2-dispatcher
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
cf9ea22
[SEP-2575] core: stateless.ts + Transport interface optionals
felixweinberger b0a8710
[SEP-2575] server: SubscriptionBackend + InMemorySubscriptions
felixweinberger 78a309a
[SEP-2575][SEP-2322] server: Server gains _dispatchStateless + subscr…
felixweinberger 462a570
[SEP-2322] server: McpServer re-throws InputRequiredError
felixweinberger f723f99
[SEP-2575] server: statelessHttp.ts + handleHttp.ts (Fetch entry)
felixweinberger 6ee08ab
[SEP-2575] client: StreamableHTTP transport sendAndReceive
felixweinberger 53648c2
[SEP-2567] server: StreamableHTTP per-message router
felixweinberger 5c00ecc
[SEP-2575][SEP-2322] client: Client gains _send/_negotiate/_isStatele…
felixweinberger 704dc88
[SEP-2575] client: connect() auto-probe activates _isStateless
felixweinberger ddfc2b3
test: LegacyTestClient + statelessAcceptance(HTTP) + conformance disp…
felixweinberger File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| --- | ||
| '@modelcontextprotocol/core': major | ||
| '@modelcontextprotocol/server': major | ||
| '@modelcontextprotocol/client': major | ||
| --- | ||
|
|
||
| 2026-06 stateless protocol support (SEP-2575, SEP-2567, SEP-2322). | ||
|
|
||
| `Server` and `Client` now support the 2026-06 stateless connection model | ||
| alongside the existing pre-2026 model. They remain the same classes (still | ||
| extending `Protocol`); the new behavior is additive. | ||
|
|
||
| - `Client.connect()` auto-probes `server/discover` and falls back to the | ||
| legacy `initialize` handshake. | ||
| - `Server` gained `subscriptions` and `statelessHandlers()`; transports route | ||
| per-message via the `MCP-Protocol-Version` header / `_meta` key. | ||
| - `handleHttp(server, opts)` is a new Fetch-API entry point: one shared | ||
| `Server` instance, no `Transport`, no `connect()`. | ||
| - `client.subscribe(filter)` opens a `subscriptions/listen` stream. | ||
| - `Transport` interface gained optional `setStatelessHandlers?` and | ||
| `sendAndReceive?` for custom transports. | ||
| - 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. | ||
|
Check warning on line 25 in .changeset/stateless-2026-06.md
|
||
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; | ||
| import { DRAFT_PROTOCOL_VERSION, JSONRPC_VERSION, META_KEYS, SdkError } from '@modelcontextprotocol/core'; | ||
| import { describe, expect, it } from 'vitest'; | ||
|
|
||
| import { Client } from '../../src/client/client.js'; | ||
|
|
||
| /** Minimal transport with a scriptable sendAndReceive. */ | ||
| function mockTransport(handler: (req: JSONRPCRequest) => AsyncIterable<JSONRPCMessage>): Transport { | ||
| return { | ||
| start: async () => {}, | ||
| close: async () => {}, | ||
| send: async () => {}, | ||
| sendAndReceive: req => handler({ jsonrpc: JSONRPC_VERSION, id: 0, ...req } as JSONRPCRequest) | ||
| }; | ||
| } | ||
|
|
||
| /** Forces the client into stateless mode without going through connect(). */ | ||
| function statelessClient(transport: Transport): Client { | ||
| const c = new Client({ name: 'c', version: '1' }, { capabilities: { elicitation: {} } }); | ||
| Object.assign(c as object, { | ||
| _isStateless: true, | ||
| _negotiatedProtocolVersion: DRAFT_PROTOCOL_VERSION, | ||
| _serverCapabilities: { tools: {}, prompts: {} }, | ||
| _transport: transport | ||
| }); | ||
| return c; | ||
| } | ||
|
|
||
| async function* once(m: JSONRPCMessage): AsyncIterable<JSONRPCMessage> { | ||
| yield m; | ||
| } | ||
|
|
||
| describe('Client._send (stateless)', () => { | ||
| it('routes via sendAndReceive when stateless', async () => { | ||
| let seenMeta: unknown; | ||
| const t = mockTransport(req => { | ||
| seenMeta = req.params?._meta; | ||
| return once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { tools: [] } }); | ||
| }); | ||
| const c = statelessClient(t); | ||
| const r = await c.listTools(); | ||
| expect(r.tools).toEqual([]); | ||
| expect((seenMeta as Record<string, unknown>)[META_KEYS.protocolVersion]).toBe(DRAFT_PROTOCOL_VERSION); | ||
| expect((seenMeta as Record<string, unknown>)[META_KEYS.clientInfo]).toEqual({ name: 'c', version: '1' }); | ||
| }); | ||
|
|
||
| it('falls back to Protocol.request when not stateless', async () => { | ||
| const c = new Client({ name: 'c', version: '1' }); | ||
| // Not stateless, no transport — request() will throw NotConnected. | ||
| await expect(c.getPrompt({ name: 'x' })).rejects.toThrow(); | ||
| }); | ||
|
|
||
| it('MRTR: dispatches input requests via dispatcher.dispatch (middleware runs)', async () => { | ||
| let round = 0; | ||
| const t = mockTransport(req => { | ||
| round++; | ||
| if (round === 1) { | ||
| return once({ | ||
| jsonrpc: JSONRPC_VERSION, | ||
| id: req.id, | ||
| result: { | ||
| resultType: 'input_required', | ||
| inputRequests: { | ||
| e0: { | ||
| method: 'elicitation/create', | ||
| params: { message: 'q', mode: 'form', requestedSchema: { type: 'object', properties: {} } } | ||
| } | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| const ir = (req.params as Record<string, unknown>).inputResponses as Record<string, unknown>; | ||
| return once({ | ||
| jsonrpc: JSONRPC_VERSION, | ||
| id: req.id, | ||
| result: { tools: [{ name: JSON.stringify(ir.e0), inputSchema: { type: 'object' } }] } | ||
| }); | ||
| }); | ||
| const c = statelessClient(t); | ||
| c.setRequestHandler('elicitation/create', async () => ({ action: 'accept' as const })); | ||
| const r = await c.listTools(); | ||
| expect(round).toBe(2); | ||
| expect(JSON.parse((r.tools[0] as { name: string }).name)).toEqual({ action: 'accept' }); | ||
| }); | ||
|
|
||
| it('MRTR: threads requestState into next round', async () => { | ||
| let round = 0; | ||
| let seenState: unknown; | ||
| const t = mockTransport(req => { | ||
| round++; | ||
| if (round === 1) { | ||
| return once({ | ||
| jsonrpc: JSONRPC_VERSION, | ||
| id: req.id, | ||
| result: { resultType: 'input_required', inputRequests: {}, requestState: 'opaque-state' } | ||
| }); | ||
| } | ||
| seenState = (req.params as Record<string, unknown>).requestState; | ||
| return once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { tools: [] } }); | ||
| }); | ||
| const c = statelessClient(t); | ||
| await c.listTools(); | ||
| expect(seenState).toBe('opaque-state'); | ||
| }); | ||
|
|
||
| it('MRTR: throws after MRTR_MAX_ROUNDS', async () => { | ||
| const t = mockTransport(req => | ||
| once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { resultType: 'input_required', inputRequests: {} } }) | ||
| ); | ||
| const c = statelessClient(t); | ||
| await expect(c.listTools()).rejects.toThrow(SdkError); | ||
| }); | ||
|
|
||
| it('propagates abort signal', async () => { | ||
| const ac = new AbortController(); | ||
| const t = mockTransport(req => { | ||
| ac.abort(); | ||
| return once({ jsonrpc: JSONRPC_VERSION, id: req.id, result: { resultType: 'input_required', inputRequests: {} } }); | ||
| }); | ||
| const c = statelessClient(t); | ||
| await expect(c.listTools(undefined, { signal: ac.signal })).rejects.toThrow(); | ||
| }); | ||
|
|
||
| it('routes notifications/progress with matching token to onprogress', async () => { | ||
| const t = mockTransport(req => { | ||
| const token = (req.params?._meta as Record<string, unknown>).progressToken; | ||
| return (async function* () { | ||
| yield { | ||
| jsonrpc: JSONRPC_VERSION, | ||
| method: 'notifications/progress', | ||
| params: { progressToken: token, progress: 50, total: 100 } | ||
| } as JSONRPCMessage; | ||
| yield { jsonrpc: JSONRPC_VERSION, id: req.id, result: { tools: [] } }; | ||
| })(); | ||
| }); | ||
| const c = statelessClient(t); | ||
| const seen: number[] = []; | ||
| await c.listTools(undefined, { onprogress: p => seen.push(p.progress) }); | ||
| expect(seen).toEqual([50]); | ||
| }); | ||
|
|
||
| it('throws ProtocolError on JSON-RPC error response', async () => { | ||
| const t = mockTransport(req => once({ jsonrpc: JSONRPC_VERSION, id: req.id, error: { code: -32_601, message: 'nope' } })); | ||
| const c = statelessClient(t); | ||
| await expect(c.listTools()).rejects.toMatchObject({ code: -32_601 }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Client.subscribe', () => { | ||
| it('throws when not stateless', async () => { | ||
| const c = new Client({ name: 'c', version: '1' }); | ||
| const it = c.subscribe({ toolsListChanged: true }); | ||
| await expect(it.next()).rejects.toThrow(SdkError); | ||
| }); | ||
|
|
||
| it('yields notifications from listen stream', async () => { | ||
| const t = mockTransport(() => | ||
| (async function* () { | ||
| yield { | ||
| jsonrpc: JSONRPC_VERSION, | ||
| method: 'notifications/subscriptions/acknowledged', | ||
| params: { notifications: { toolsListChanged: true } } | ||
| } as JSONRPCMessage; | ||
| yield { jsonrpc: JSONRPC_VERSION, method: 'notifications/tools/list_changed', params: {} } as JSONRPCMessage; | ||
| })() | ||
| ); | ||
| const c = statelessClient(t); | ||
| const seen: string[] = []; | ||
| for await (const n of c.subscribe({ toolsListChanged: true })) { | ||
| seen.push(n.method); | ||
| } | ||
| expect(seen).toEqual(['notifications/subscriptions/acknowledged', 'notifications/tools/list_changed']); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Client.connect auto-probe (SEP-2575)', () => { | ||
| function discoverable(handler: (req: JSONRPCRequest) => AsyncIterable<JSONRPCMessage>): Transport { | ||
| const t = mockTransport(handler); | ||
| // Route legacy `initialize` (sent via Protocol.request → transport.send) | ||
| // back through onmessage so the fallback path can complete in-process. | ||
| t.send = async m => { | ||
| if ('method' in m && m.method === 'initialize') { | ||
| queueMicrotask(() => | ||
| t.onmessage?.({ | ||
| jsonrpc: JSONRPC_VERSION, | ||
| id: (m as JSONRPCRequest).id, | ||
| result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 's', version: '1' } } | ||
| }) | ||
| ); | ||
| } else if ('method' in m && m.method === 'notifications/initialized') { | ||
| // ignore | ||
| } | ||
| }; | ||
| return t; | ||
| } | ||
|
|
||
| it('discover success → stateless mode, skips initialize', async () => { | ||
| const seen: string[] = []; | ||
| const t = discoverable(req => { | ||
| seen.push(req.method); | ||
| return once({ | ||
| jsonrpc: JSONRPC_VERSION, | ||
| id: req.id, | ||
| result: { | ||
| supportedVersions: [DRAFT_PROTOCOL_VERSION], | ||
| capabilities: { tools: {} }, | ||
| serverInfo: { name: 's', version: '2' } | ||
| } | ||
| }); | ||
| }); | ||
| const c = new Client({ name: 'c', version: '1' }); | ||
| await c.connect(t); | ||
| expect(seen).toEqual(['server/discover']); | ||
| expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(true); | ||
| expect(c.getServerCapabilities()).toEqual({ tools: {} }); | ||
| expect(c.getServerVersion()).toEqual({ name: 's', version: '2' }); | ||
| }); | ||
|
|
||
| it('discover MethodNotFound → falls back to legacy initialize', async () => { | ||
| const seen: string[] = []; | ||
| const t = discoverable(req => { | ||
| seen.push(req.method); | ||
| return once({ jsonrpc: JSONRPC_VERSION, id: req.id, error: { code: -32_601, message: 'unknown method' } }); | ||
| }); | ||
| const c = new Client({ name: 'c', version: '1' }); | ||
| await c.connect(t); | ||
| expect(seen).toEqual(['server/discover']); | ||
| expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(false); | ||
| expect(c.getServerVersion()).toEqual({ name: 's', version: '1' }); | ||
| }); | ||
|
|
||
| it('no sendAndReceive → goes straight to legacy initialize', async () => { | ||
| const t: Transport = { | ||
| start: async () => {}, | ||
| close: async () => {}, | ||
| send: async m => { | ||
| if ('method' in m && m.method === 'initialize') { | ||
| queueMicrotask(() => | ||
| t.onmessage?.({ | ||
| jsonrpc: JSONRPC_VERSION, | ||
| id: (m as JSONRPCRequest).id, | ||
| result: { | ||
| protocolVersion: '2025-11-25', | ||
| capabilities: {}, | ||
| serverInfo: { name: 's', version: '1' } | ||
| } | ||
| }) | ||
| ); | ||
| } | ||
| } | ||
| }; | ||
| const c = new Client({ name: 'c', version: '1' }); | ||
| await c.connect(t); | ||
| expect((c as unknown as { _isStateless: boolean })._isStateless).toBe(false); | ||
| expect(c.getServerVersion()?.name).toBe('s'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.mdfor the full guide." butdocs/migration.mdis 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 todocs/migration.md") or move it to #2133.Extended reasoning...
What the issue is
.changeset/stateless-2026-06.mdis added in this PR and ends with:This is a forward reference to documentation that this PR does not add. Grepping
docs/migration.md(anddocs/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 touchesdocs/.Why it matters
Changeset files are the source of release notes: when
changesetscuts a release, this prose is published verbatim to the changelog and npm. A reader who follows the link from the published release notes todocs/migration.mdwill 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
.changeset/stateless-2026-06.mdcontaining the "Seedocs/migration.md" sentence.changeset version+changeset publishbefore docs: 2026-06 migration guide + examples + changeset #2133 merges.@modelcontextprotocol/{core,server,client}now contain a literal "Seedocs/migration.mdfor the full guide." line.docs/migration.mdat 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/*.mdtext against the implementation in the same diff and flag claims the diff doesn't back).How to fix
Pick one of:
docs/migration.md."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.