Skip to content

Commit 7a63191

Browse files
fix(server): restore v1 transport lifecycle parity — single-use stateless transports, fail-fast on double connect
1 parent ddf6cc1 commit 7a63191

29 files changed

Lines changed: 1050 additions & 229 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
'@modelcontextprotocol/core-internal': patch
4+
---
5+
6+
Restores two v1 transport lifecycle invariants dropped in the v2 rewrite:
7+
8+
- 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.
9+
- `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()` rejects with `SdkError(ConnectionClosed)`, consistent with the above.
10+
11+
Docs, middleware READMEs, and examples that showed a shared stateless transport now show the per-request pattern.

docs/migration/upgrade-to-v2.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,43 @@ rewrite required unless noted.
15761576
unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code
15771577
should key off the HTTP `404` status, not `-32001`.
15781578
1579+
#### Transport / connection lifecycle (v1 parity)
1580+
1581+
Two v1 transport lifecycle invariants (in effect since sdk@1.26.0) apply in v2:
1582+
1583+
- **A stateless `WebStandardStreamableHTTPServerTransport` serves exactly one request.**
1584+
With `sessionIdGenerator: undefined`, the second `handleRequest()` call throws
1585+
`"Stateless transport cannot be reused across requests. Create a new transport per
1586+
request."` — matching v1 behavior since 1.26.0. Stateful transports
1587+
(`sessionIdGenerator` set) accept multiple requests as before;
1588+
`NodeStreamableHTTPServerTransport` inherits the behavior. Migrate shared-transport
1589+
hosting to the per-request pattern:
1590+
1591+
```typescript
1592+
app.all('/mcp', async c => {
1593+
// Fresh transport + server pair per request.
1594+
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
1595+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
1596+
await server.connect(transport);
1597+
return transport.handleRequest(c.req.raw);
1598+
});
1599+
```
1600+
1601+
Or use `createMcpHandler`, which constructs the per-request pair for you (its
1602+
`legacy: 'stateless'` fallback and the modern per-request transport are both
1603+
one-exchange by construction).
1604+
1605+
- **`Protocol.connect()` throws when already connected** (`"Already connected to a
1606+
transport. Call close() before connecting to a new transport, or use a separate
1607+
Protocol instance per connection."`) instead of silently rebinding `this._transport`.
1608+
Sequential `close()` then `connect()` keeps working. `Client.connect()` performs the
1609+
same check up front, before any per-connection state is reset.
1610+
1611+
- **Aborted request handlers cannot write to a replacement transport.** After the
1612+
handler's `ctx.mcpReq.signal` aborts (connection closed or request cancelled),
1613+
`ctx.mcpReq.notify()` resolves as a no-op and `ctx.mcpReq.send()` rejects with
1614+
`SdkError(ConnectionClosed)`.
1615+
15791616
#### Server (deprecated accessors and app-factory Origin validation)
15801617
15811618
- `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()`

docs/serving/sessions-state-scaling.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ shape: how-to
77

88
## Pin a client to a session
99

10-
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.
10+
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.
1111

1212
```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#sessions_stateful"
1313
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';

examples/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ To enable stateless mode, configure the `NodeStreamableHTTPServerTransport` with
8989
sessionIdGenerator: undefined;
9090
```
9191

92+
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).
93+
9294
```
9395
┌─────────────────────────────────────────────┐
9496
│ Client │

packages/client/src/client/client.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,17 @@ export class Client extends Protocol<ClientContext> {
927927
* ```
928928
*/
929929
override async connect(transport: Transport, options?: ConnectOptions): Promise<void> {
930+
// Fail fast while still connected: the negotiated paths below reset
931+
// per-connection state and run the discover probe BEFORE the base
932+
// Protocol.connect() reuse guard would fire — without this early
933+
// check, a connect() on a connected instance would corrupt the live
934+
// connection's state before throwing. Same guard + message as
935+
// Protocol.connect().
936+
if (this.transport) {
937+
throw new Error(
938+
'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.'
939+
);
940+
}
930941
if (options?.prior !== undefined) {
931942
// Zero-round-trip reconnect from a previously-obtained
932943
// DiscoverResult: bypasses versionNegotiation resolution entirely.

packages/client/test/client/listen.test.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -837,26 +837,41 @@ describe('Client.listen()', () => {
837837
await client.close();
838838
});
839839

840-
it('a fresh connect without an intervening close settles in-flight listen() from the prior connection', async () => {
841-
// Edge: prior transport never fires onclose; consumer calls connect()
842-
// again. The in-flight listen() promise from the old connection must
843-
// reject with a clear "client reconnected/closed" error rather than
844-
// hang on the (now-discarded) ack timer.
840+
it('connect() without an intervening close throws; close() settles in-flight listen() from the prior connection', async () => {
841+
// Edge: prior transport never spontaneously fires onclose; consumer
842+
// calls connect() again. The transport-reuse guard rejects the second
843+
// connect outright WITHOUT touching the live connection's state (the
844+
// in-flight listen stays pending), and the close() now required in
845+
// between settles the in-flight listen() promise with a clear
846+
// "client reconnected/closed" error rather than leaving it hanging on
847+
// the (now-discarded) ack timer.
845848
const { clientTx } = await scriptedModernNoAck();
846849
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
847850
await client.connect(clientTx);
848851
const pending = client.listen({ toolsListChanged: true });
849852
await flush();
850853
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
851-
// Fresh connect on a new transport — _resetConnectionState runs.
854+
// Fresh connect on a new transport without close() — rejected by the
855+
// reuse guard, and the prior connection's listen state is untouched.
852856
const { clientTx: clientTx2 } = await scriptedModern();
853-
await client.connect(clientTx2);
857+
await expect(client.connect(clientTx2)).rejects.toThrow(
858+
'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.'
859+
);
860+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(1);
861+
// close() settles the in-flight listen even though the prior server
862+
// never acked or closed.
863+
await client.close();
854864
const error = await pending.catch(e => e as Error);
855865
expect(error).toBeInstanceOf(SdkError);
856866
expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
857-
expect((error as SdkError).message).toContain('reconnected or closed');
867+
// Settled via the transport onclose path ('Connection closed') or the
868+
// reset path ('reconnected or closed') — both are clean
869+
// ConnectionClosed settlements, never a hang.
870+
expect((error as SdkError).message).toMatch(/Connection closed|reconnected or closed/);
858871
// No leaked per-listen state from the old connection.
859872
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
873+
// The instance is reusable after close(): a fresh connect succeeds.
874+
await client.connect(clientTx2);
860875
await client.close();
861876
});
862877

packages/core-internal/src/shared/protocol.ts

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,12 @@ export abstract class Protocol<ContextT extends BaseContext> {
780780
* The caller assumes ownership of the {@linkcode Transport}, replacing any callbacks that have already been set, and expects that it is the only user of the {@linkcode Transport} instance going forward.
781781
*/
782782
async connect(transport: Transport): Promise<void> {
783+
if (this._transport) {
784+
throw new Error(
785+
'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.'
786+
);
787+
}
788+
783789
this._transport = transport;
784790
const _onclose = this.transport?.onclose;
785791
this._transport.onclose = () => {
@@ -813,7 +819,16 @@ export abstract class Protocol<ContextT extends BaseContext> {
813819
// Pass supported protocol versions to transport for header validation
814820
transport.setSupportedProtocolVersions?.(this._supportedProtocolVersions);
815821

816-
await this._transport.start();
822+
try {
823+
await this._transport.start();
824+
} catch (error) {
825+
// A transport that failed to start was never connected: unwind so
826+
// the instance can retry connect() — the reuse guard above would
827+
// otherwise lock the instance onto a transport that never carried
828+
// traffic.
829+
this._transport = undefined;
830+
throw error;
831+
}
817832
}
818833

819834
/**
@@ -1024,22 +1039,41 @@ export abstract class Protocol<ContextT extends BaseContext> {
10241039
return;
10251040
}
10261041

1042+
const abortController = new AbortController();
1043+
this._requestHandlerAbortControllers.set(request.id, abortController);
1044+
10271045
// Related sends resolve through the SAME instance era as every other
10281046
// sender (the per-request/instance asymmetry is deliberately gone):
10291047
// the codec is resolved at send time from the connection state.
1030-
const sendNotification = (notification: Notification, options?: NotificationOptions) =>
1031-
this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, {
1048+
//
1049+
// Once this request's handler has been aborted (connection closed or
1050+
// request cancelled), its related sends must not reach the transport:
1051+
// `this._transport` is read live at send time, so a handler still
1052+
// running after close() could otherwise write onto a transport
1053+
// connected later. Notifications no-op; requests reject with
1054+
// ConnectionClosed.
1055+
const sendNotification = (notification: Notification, options?: NotificationOptions): Promise<void> => {
1056+
if (abortController.signal.aborted) {
1057+
return Promise.resolve();
1058+
}
1059+
return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, {
10321060
...options,
10331061
relatedRequestId: request.id
10341062
});
1035-
const sendRequest = <U extends StandardSchemaV1>(r: Request, resultSchema: U, options?: RequestOptions) =>
1036-
this._requestWithSchemaViaCodec(this._resolveOutboundCodec(r.method), r, resultSchema, {
1063+
};
1064+
const sendRequest = <U extends StandardSchemaV1>(
1065+
r: Request,
1066+
resultSchema: U,
1067+
options?: RequestOptions
1068+
): Promise<StandardSchemaV1.InferOutput<U>> => {
1069+
if (abortController.signal.aborted) {
1070+
return Promise.reject(new SdkError(SdkErrorCode.ConnectionClosed, 'Request was cancelled'));
1071+
}
1072+
return this._requestWithSchemaViaCodec(this._resolveOutboundCodec(r.method), r, resultSchema, {
10371073
...options,
10381074
relatedRequestId: request.id
10391075
});
1040-
1041-
const abortController = new AbortController();
1042-
this._requestHandlerAbortControllers.set(request.id, abortController);
1076+
};
10431077

10441078
// Multi-round-trip retry material: only BARE response objects are
10451079
// surfaced to the handler; entries that look like a wrapped
@@ -1071,6 +1105,14 @@ export abstract class Protocol<ContextT extends BaseContext> {
10711105
// that overloaded property type. The cast is sound: this impl dispatches both overload paths via the
10721106
// isStandardSchema guard, and sendRequest validates the result against the resolved schema either way.
10731107
send: ((r: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions) => {
1108+
// The abort guard comes FIRST — before era/codec
1109+
// resolution — so an aborted handler always gets the
1110+
// documented ConnectionClosed rejection, never a
1111+
// synchronous era throw evaluated against whatever
1112+
// connection replaced this request's.
1113+
if (abortController.signal.aborted) {
1114+
return Promise.reject(new SdkError(SdkErrorCode.ConnectionClosed, 'Request was cancelled'));
1115+
}
10741116
// Related requests resolve through the instance era at
10751117
// send time, exactly like direct sends: era-gate first,
10761118
// then method-keyed schema resolution.

0 commit comments

Comments
 (0)