Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/stateless-transport-reuse-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@modelcontextprotocol/server': patch
'@modelcontextprotocol/core-internal': minor
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/node': patch
---
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.

Restores two v1 transport lifecycle invariants dropped in the v2 rewrite. Both throw typed errors — `SdkError` with `SdkErrorCode.StatelessTransportReuse` (new) and `SdkErrorCode.AlreadyConnected` — while keeping the message strings byte-identical to v1.x, so structured handling can use the code and existing message matching keeps working:

- A stateless `WebStandardStreamableHTTPServerTransport` (`sessionIdGenerator: undefined`) handles a single request exchange and throws on reuse (matching v1 behavior since 1.26.0): `"Stateless transport cannot be reused across requests. Create a new transport per request."` Construct a fresh transport (and server instance) per request, or use `createMcpHandler`, which already serves per-request pairs. Stateful transports (`sessionIdGenerator` set) are unaffected; `NodeStreamableHTTPServerTransport` inherits the behavior.
- `Protocol.connect()` (and `Client.connect()`) throw when the instance is already connected, instead of silently rebinding the transport: `"Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection."` Sequential `close()` then `connect()` keeps working. After `close()` aborts an in-flight request handler, `ctx.mcpReq.notify()` resolves as a no-op and `ctx.mcpReq.send()`, `ctx.mcpReq.elicitInput()`, and `ctx.mcpReq.requestSampling()` reject with `SdkError(ConnectionClosed)`, consistent with the above.

Docs, middleware READMEs, and examples that showed a shared stateless transport now show the per-request pattern.
51 changes: 51 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented,
| ------------------------------------- | -------------------------------------------------------------------------- |
| `NotConnected` | Transport is not connected |
| `AlreadyConnected` | Transport is already connected |
| `StatelessTransportReuse` | Stateless transport reused across requests |
| `NotInitialized` | Protocol is not initialized |
| `CapabilityNotSupported` | Required capability is not supported |
| `RequestTimeout` | Request timed out waiting for response |
Expand Down Expand Up @@ -1616,6 +1617,56 @@ rewrite required unless noted.
unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code
should key off the HTTP `404` status, not `-32001`.

#### Transport / connection lifecycle (v1 parity)

The following v1 transport lifecycle invariants (in effect since sdk@1.26.0) apply in v2:

- **A stateless `WebStandardStreamableHTTPServerTransport` serves exactly one request.**
Comment thread
claude[bot] marked this conversation as resolved.
With `sessionIdGenerator: undefined`, the second `handleRequest()` call throws
`"Stateless transport cannot be reused across requests. Create a new transport per
Comment thread
claude[bot] marked this conversation as resolved.
request."` (an `SdkError` with code `StatelessTransportReuse`) — matching v1
message behavior since 1.26.0. Stateful transports
(`sessionIdGenerator` set) accept multiple requests as before;
`NodeStreamableHTTPServerTransport` inherits the behavior. Migrate shared-transport
hosting to the per-request pattern:

```typescript
app.all('/mcp', async c => {
// Fresh transport + server pair per request.
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
return transport.handleRequest(c.req.raw);
});
```

Or use `createMcpHandler`, which constructs the per-request pair for you (its
`legacy: 'stateless'` fallback and the modern per-request transport are both
one-exchange by construction).

- **`Protocol.connect()` throws when already connected** (an `SdkError` with code
`AlreadyConnected`: `"Already connected to a transport. Call close() before connecting
to a new transport, or use a separate Protocol instance per connection."`) instead of
silently rebinding `this._transport`.
Sequential `close()` then `connect()` keeps working. `Client.connect()` performs the
same check up front, before any per-connection state is reset.
To resume a Streamable HTTP session across that sequence, carry both the session ID
and the negotiated protocol version onto the new transport — `close()` wipes the
client's copy, and the resuming connect reads it back from the transport:

```typescript
const sessionId = transport.sessionId;
const protocolVersion = client.getNegotiatedProtocolVersion();
await client.close();
await client.connect(new StreamableHTTPClientTransport(url, { sessionId, protocolVersion }));
```

- **Aborted request handlers cannot write to a replacement transport.** After the
handler's `ctx.mcpReq.signal` aborts (connection closed or request cancelled),
`ctx.mcpReq.notify()` (and `ctx.mcpReq.log()`) resolves as a no-op, and
`ctx.mcpReq.send()`, `ctx.mcpReq.elicitInput()`, and `ctx.mcpReq.requestSampling()`
reject with `SdkError(ConnectionClosed)`.

#### Server (deprecated accessors and app-factory Origin validation)

- `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()`
Expand Down
2 changes: 1 addition & 1 deletion docs/serving/sessions-state-scaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ shape: how-to

## Pin a client to a session

A **session** pins a client to one long-lived transport instance; sessions belong to the hand-wired 2025-era transport — the 2026-07-28 revision is per-request and has no `Mcp-Session-Id` ([Protocol versions](../protocol-versions.md)). On `NodeStreamableHTTPServerTransport`, `sessionIdGenerator` turns sessions on; leaving it `undefined` is stateless mode.
A **session** pins a client to one long-lived transport instance; sessions belong to the hand-wired 2025-era transport — the 2026-07-28 revision is per-request and has no `Mcp-Session-Id` ([Protocol versions](../protocol-versions.md)). On `NodeStreamableHTTPServerTransport`, `sessionIdGenerator` turns sessions on; leaving it `undefined` is stateless mode. A stateless transport serves exactly one request exchange; construct a fresh transport + server pair per request — reuse throws. For stateless HTTP hosting prefer `createMcpHandler`, which builds the per-request pair for you.

```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#sessions_stateful"
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with
sessionIdGenerator: undefined;
```

A stateless transport serves exactly one request: construct a fresh transport + server pair per request (reuse throws). `createMcpHandler` does this for you — see [`stateless-legacy/`](./stateless-legacy/README.md).

```
┌─────────────────────────────────────────────┐
│ Client │
Expand Down
41 changes: 19 additions & 22 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,9 @@
* ```
*/
override async connect(transport: Transport, options?: ConnectOptions): Promise<void> {
// Guard before the paths below mutate per-connection state — the base
// Protocol.connect() check would fire only after that corruption.
this.assertNotConnected();
Comment thread
claude[bot] marked this conversation as resolved.
if (options?.prior !== undefined) {
// Zero-round-trip reconnect from a previously-obtained
// DiscoverResult: bypasses versionNegotiation resolution entirely.
Expand All @@ -938,27 +941,20 @@
}
// Plain legacy connect — the pinned 2025 sequence, byte-untouched.
await super.connect(transport);
// When transport sessionId is already set this means we are trying to reconnect.
// Restore the protocol version negotiated during the original initialize handshake
// so HTTP transports include the required mcp-protocol-version header, but skip re-init.
// Session resume (transport carries a sessionId): skip re-init, restore
// the originally negotiated version — from this instance, or from the
// transport itself after close() wiped the instance's copy.
if (transport.sessionId !== undefined) {
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
if (negotiatedProtocolVersion !== undefined) {
// Resuming keeps the original negotiation: the instance still
// holds the negotiated version (and with it the wire era) —
// only the new transport needs the header pushed again.
transport.setProtocolVersion?.(negotiatedProtocolVersion);
const version = this._negotiatedProtocolVersion ?? transport.protocolVersion;
if (version !== undefined) {
this._negotiatedProtocolVersion = version;
transport.setProtocolVersion?.(version);
}
return;
}

Check failure on line 954 in packages/client/src/client/client.ts

View check run for this annotation

Claude / Claude Code Review

Resume-after-close restores only the protocol version — server capabilities/identity and the response-cache partition stay wiped

Both session-resume branches (here and in `_connectNegotiated` at lines 1047–1055) restore only `_negotiatedProtocolVersion` after `close()` wiped the connection state — `_serverCapabilities`/`_serverVersion`/`_instructions` stay `undefined`, so a client with `enforceStrictCapabilities` locally rejects every capability-gated request on the migration guide's prescribed close()-then-resume sequence, and the response cache's server identity is never re-set, so the entire resumed session reads/write
Comment on lines 947 to 954

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Both session-resume branches (here and in _connectNegotiated at lines 1047–1055) restore only _negotiatedProtocolVersion after close() wiped the connection state — _serverCapabilities/_serverVersion/_instructions stay undefined, so a client with enforceStrictCapabilities locally rejects every capability-gated request on the migration guide's prescribed close()-then-resume sequence, and the response cache's server identity is never re-set, so the entire resumed session reads/writes the pre-connect '' sentinel partition, violating the cache's documented cross-server isolation on shared responseCacheStores. Fix by restoring the server-derived state the way _connectFromPrior (lines 1205–1213) does — at minimum call this._cache.setServerIdentity(this._deriveServerIdentity(transport)) in both resume branches (its sessionId fallback makes this well-defined) and either carry/restore capabilities or caveat the migration-guide resume bullet.

Extended reasoning...

What the bug is. Client.close() unconditionally runs _resetConnectionState() (client.ts:528–563, invoked from the finally at 565–574), which wipes _negotiatedProtocolVersion, _serverCapabilities, _serverVersion, _instructions, _discoverResult, and calls this._cache.resetForReconnect() — resetting the response cache's server identity to the pre-connect '' sentinel (responseCache.ts:575) while deliberately preserving a user-supplied store. The fix in 652694c taught both session-resume branches (legacy path here at 947–954, negotiated path at 1047–1055) to recover the protocol version from transport.protocolVersion, but nothing else is restored: the branches early-return without re-initializing, and _serverCapabilities is only ever assigned by the three fresh-handshake completions (initialize at ~1001, discover at ~1086, connect({ prior }) at ~1208), and _cache.setServerIdentity(...) is likewise called only at those three sites (1003, 1088, 1210).\n\nConsequence 1 — strict-capability clients reject everything. With enforceStrictCapabilities: true, Protocol.request() calls assertCapabilityForMethod (protocol.ts:1446–1453), and Client's implementation (client.ts:1291+) throws SdkError(CapabilityNotSupported) for tools/*, prompts/*, resources/*, logging/setLevel, and completion/complete whenever _serverCapabilities is undefined. Even without strict mode, getServerCapabilities()/getServerVersion()/getInstructions() return undefined for the whole resumed session, and listChanged handler wiring that gates on _serverCapabilities never re-arms.\n\nConsequence 2 — the response cache runs under the '' sentinel. Every cache read/write/evict derives its partition live: _partitionFor = JSON.stringify([serverIdentity, principal]) (responseCache.ts:368). Since neither resume branch calls setServerIdentity, the entire resumed session reads and writes the ['', ...] partitions. That violates the invariant the class doc states verbatim (responseCache.ts:300–307: two clients sharing one store but connected to different servers never collide; a server cannot read another server's public entries) and the setServerIdentity doc that sentinel entries are 'no longer reachable' after connect.\n\nStep-by-step proof (cache half).\n1. Two Client instances share one persistent responseCacheStore (the documented reason the option exists). Client A resumed to server A, client B resumed to server B, each via the migration guide's exact recipe: save sessionId + getNegotiatedProtocolVersion(), await client.close(), connect(new StreamableHTTPClientTransport(url, { sessionId, protocolVersion })).\n2. Both resumed sessions now operate in the SAME sentinel partitions. A 'public'-scoped tools/list or resources/read entry written on server A's session is served verbatim to server B's session by _serveFromCache (client.ts:~1688) whenever it is fresh.\n3. Independent of TTL, callTool reads _cache.toolDefinition (client.ts:1760, via _probe, which has no freshness gate) for SEP-2243 mirroring and output-schema validation — so tool calls on server B can be validated against server A's tool definitions.\n4. Milder same-server case: on any single-client close()-then-resume, all entries written pre-close under the real identity become unreachable (silent misses), and new writes pollute the sentinel partition. The other resume shape this PR adds and tests — a brand-new Client adopting the transport-carried version — runs under '' from construction for the same reason.\n\nStrict-capabilities proof. (1) Client connects with enforceStrictCapabilities: true, negotiates, gets a session. (2) It follows the migration guide bullet added by this PR: close(), then connect() with { sessionId, protocolVersion }. (3) The resume branch restores only the version and returns; _serverCapabilities is undefined. (4) client.listTools()Protocol.request()assertCapabilityForMethodSdkError(CapabilityNotSupported), locally, before anything reaches the wire — for every capability-gated method, for the life of the resumed session.\n\nWhy this is PR-introduced and why the earlier fix didn't cover it. Pre-PR, same-instance resumption rode the silent rebind without close(), so both _serverCapabilities and the cache identity persisted. The new AlreadyConnected guard forecloses that path, and the error message, changeset, and migration guide all prescribe close()-then-connect. The earlier review thread on this (client.ts:932) was resolved by 652694c, which restored only the protocol-version half; the capabilities/identity halves remain, while the docs present resume as fully working ('Sequential close() then connect() keeps working') with no caveat. The intended complete shape is already in this file: _connectFromPrior (client.ts:1205–1213) restores _serverCapabilities/_serverVersion/_instructions and calls setServerIdentity alongside the version.\n\nHow to fix. In both resume branches: (a) call this._cache.setServerIdentity(this._deriveServerIdentity(transport))_deriveServerIdentity (client.ts:1248–1251) already falls back to transport.sessionId when _serverVersion is unavailable, giving a stable per-session surrogate exactly as the responseCache doc describes; and (b) either carry/restore capabilities the same way the version is now carried (e.g. adopt a transport-carried DiscoverResult, or recommend connect({ prior }) for modern-era resumption) or add a migration-guide caveat that strict-capability enforcement and the server-derived getters do not survive close()-then-resume. The cache-identity call is the non-negotiable half: a capabilities-only or docs-only fix would leave the documented cross-server isolation invariant broken on the exact path this PR prescribes and tests.

// Fresh connect: per-connection state left over from a previous
// connection must not survive into a new handshake. Clearing it puts
// the instance back in the pre-negotiation phase, so the initialize
// exchange below rides the bootstrap method pins (legacy era) instead
// of a dead session's era. Without this, an instance that once
// negotiated a modern era could never re-run a fresh handshake:
// `initialize` is physically absent from the modern registry. (The
// resume branch above keeps it instead.)
// Fresh connect: clear leftover connection state so `initialize` rides
// the legacy bootstrap pins — a stale modern era would make it
// unsendable (absent from the modern registry).
this._resetConnectionState();
await this._legacyHandshake(transport, options);
}
Expand Down Expand Up @@ -1046,13 +1042,14 @@
negotiation: Extract<ResolvedVersionNegotiation, { kind: 'auto' | 'pin' }>,
options?: RequestOptions
): Promise<void> {
// Session-resuming reconnect: restore the previously negotiated version,
// never re-probe mid-session.
// Session resume: never re-probe; restore the negotiated version from
// this instance or, after close() wiped it, from the transport itself.
if (transport.sessionId !== undefined) {
await super.connect(transport);
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
if (negotiatedProtocolVersion !== undefined && transport.setProtocolVersion) {
transport.setProtocolVersion(negotiatedProtocolVersion);
const version = this._negotiatedProtocolVersion ?? transport.protocolVersion;
if (version !== undefined) {
this._negotiatedProtocolVersion = version;
transport.setProtocolVersion?.(version);
}
return;
}
Expand Down
31 changes: 23 additions & 8 deletions packages/client/test/client/listen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,26 +837,41 @@ describe('Client.listen()', () => {
await client.close();
});

it('a fresh connect without an intervening close settles in-flight listen() from the prior connection', async () => {
// Edge: prior transport never fires onclose; consumer calls connect()
// again. The in-flight listen() promise from the old connection must
// reject with a clear "client reconnected/closed" error rather than
// hang on the (now-discarded) ack timer.
it('connect() without an intervening close throws; close() settles in-flight listen() from the prior connection', async () => {
// Edge: prior transport never spontaneously fires onclose; consumer
// calls connect() again. The transport-reuse guard rejects the second
// connect outright WITHOUT touching the live connection's state (the
// in-flight listen stays pending), and the close() now required in
// between settles the in-flight listen() promise with a clear
// "client reconnected/closed" error rather than leaving it hanging on
// the (now-discarded) ack timer.
const { clientTx } = await scriptedModernNoAck();
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(clientTx);
const pending = client.listen({ toolsListChanged: true });
await flush();
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
// Fresh connect on a new transport — _resetConnectionState runs.
// Fresh connect on a new transport without close() — rejected by the
// reuse guard, and the prior connection's listen state is untouched.
const { clientTx: clientTx2 } = await scriptedModern();
await client.connect(clientTx2);
await expect(client.connect(clientTx2)).rejects.toThrow(
'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.'
);
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
// close() settles the in-flight listen even though the prior server
// never acked or closed.
await client.close();
const error = await pending.catch(e => e as Error);
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
expect((error as SdkError).message).toContain('reconnected or closed');
// Settled via the transport onclose path ('Connection closed') or the
// reset path ('reconnected or closed') — both are clean
// ConnectionClosed settlements, never a hang.
expect((error as SdkError).message).toMatch(/Connection closed|reconnected or closed/);
// No leaked per-listen state from the old connection.
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
// The instance is reusable after close(): a fresh connect succeeds.
await client.connect(clientTx2);
await client.close();
});

Expand Down
18 changes: 18 additions & 0 deletions packages/client/test/client/versionNegotiation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class ScriptedTransport implements Transport {
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
sessionId?: string;
protocolVersion?: string;

startCalls = 0;
sent: JSONRPCMessage[] = [];
Expand Down Expand Up @@ -698,6 +699,23 @@ describe('era scope discipline', () => {
await client.close();
});

test('session resume on a fresh auto client adopts the transport-carried version: no probe, no initialize', async () => {
// After close() (or on a brand-new client) the instance holds no
// negotiated version — a resuming transport constructed with
// { sessionId, protocolVersion } supplies it instead.
const transport = new ScriptedTransport(() => {});
transport.sessionId = 'sess-1';
transport.protocolVersion = MODERN;
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });

await client.connect(transport);

expect(requests(transport.sent)).toHaveLength(0);
expect(transport.setProtocolVersionCalls).toEqual([MODERN]);
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
await client.close();
});

test('no era state exists before the first connect, and none is persisted anywhere', () => {
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
Expand Down
2 changes: 2 additions & 0 deletions packages/core-internal/src/errors/sdkErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export enum SdkErrorCode {
NotConnected = 'NOT_CONNECTED',
/** Transport is already connected */
AlreadyConnected = 'ALREADY_CONNECTED',
/** A stateless (sessionless) transport was asked to handle a second request exchange */
StatelessTransportReuse = 'STATELESS_TRANSPORT_REUSE',
/** Protocol is not initialized */
NotInitialized = 'NOT_INITIALIZED',

Expand Down
Loading
Loading