Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/stateless-2026-06.md
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

View check run for this annotation

Claude / Claude Code Review

Changeset references docs/migration.md content not in this PR

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/migrati
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.

578 changes: 528 additions & 50 deletions packages/client/src/client/client.ts

Large diffs are not rendered by default.

96 changes: 95 additions & 1 deletion packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReadableWritablePair } from 'node:stream/web';

import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
import type { FetchLike, JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core';
import {
createFetchWithInit,
isInitializedNotification,
Expand Down Expand Up @@ -189,6 +189,100 @@ export class StreamableHTTPClientTransport implements Transport {
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;

/**
* Sends one stateless request and yields the messages the server emits for
* it (zero or more notifications then one response, or for
* `subscriptions/listen` the indefinite stream). Backed directly by
* `fetch`; does not go through `Protocol.request()`/`_responseHandlers`.
*
* Used by `Client` for 2026-06 stateless calls. Auth handling mirrors
* {@linkcode send}: token attached via `_commonHeaders`, one 401 retry via
* `authProvider.onUnauthorized`, and one 403 `insufficient_scope` upscoping
* retry for OAuth providers. The ladder is duplicated here (not shared with
* `send()`) so `send()` stays byte-identical to the pre-2026 code path.
*/
async *sendAndReceive(
request: Omit<JSONRPCRequest, 'jsonrpc' | 'id'>,
opts?: { signal?: AbortSignal }
): AsyncGenerator<JSONRPCMessage, void, void> {
const body = JSON.stringify({ jsonrpc: '2.0', id: 0, ...request });
const signal =
opts?.signal && this._abortController
? AbortSignal.any([opts.signal, this._abortController.signal])
: (opts?.signal ?? this._abortController?.signal);
const post = async (): Promise<Response> => {
const headers = await this._commonHeaders();
headers.set('content-type', 'application/json');
headers.set('accept', 'application/json, text/event-stream');
return (this._fetch ?? fetch)(this._url, { ...this._requestInit, method: 'POST', headers, body, signal });
};
let response = await post();
if (response.status === 401 && this._authProvider) {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
}
if (this._authProvider.onUnauthorized) {
await this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit });
await response.text?.().catch(() => {});
response = await post();
}
}
if (response.status === 403 && this._oauthProvider) {
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
if (error === 'insufficient_scope') {
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
if (this._lastUpscopingHeader !== wwwAuthHeader) {
if (scope) this._scope = scope;
if (resourceMetadataUrl) this._resourceMetadataUrl = resourceMetadataUrl;
this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
const result = await auth(this._oauthProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
});
if (result === 'AUTHORIZED') {
await response.text?.().catch(() => {});
response = await post();
}
}
}
}
if (!response.ok) {
const text = await response.text().catch(() => '');
if (response.status === 401) {
throw new SdkError(SdkErrorCode.ClientHttpAuthentication, text || 'Unauthorized', { status: 401 });
}
throw new SdkError(SdkErrorCode.SendFailed, `HTTP ${response.status}: ${text || response.statusText}`, {
status: response.status
});
}
const ct = response.headers.get('content-type') ?? '';
if (ct.includes('text/event-stream') && response.body) {
const reader = response.body
.pipeThrough(new TextDecoderStream() as ReadableWritablePair<string, Uint8Array>)
.pipeThrough(new EventSourceParserStream())
.getReader();
try {
for (;;) {
const { value: event, done } = await reader.read();
if (done) break;
if (!event.data) continue;
yield JSONRPCMessageSchema.parse(JSON.parse(event.data));
}
} finally {
await reader.cancel().catch(() => {});
}
} else {
const json = (await response.json()) as unknown;
for (const m of Array.isArray(json) ? json : [json]) {
yield JSONRPCMessageSchema.parse(m);
}
}
}

constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) {
this._url = url;
this._resourceMetadataUrl = undefined;
Expand Down
257 changes: 257 additions & 0 deletions packages/client/test/client/clientSend.test.ts
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');
});
});
Loading
Loading