Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 2.1k
[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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
cf9ea22b0a871078a309a462a570f723f996ee08ab53648c25c00ecc704dc88ddfc2b3File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| 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.
Uh oh!
There was an error while loading. Please reload this page.
| 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'); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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.