Skip to content

Commit 26e8ed0

Browse files
address review: shared connect guard, unwind callback restore, Node adapter single-use propagation, client changeset entry
1 parent 7a63191 commit 26e8ed0

7 files changed

Lines changed: 111 additions & 19 deletions

File tree

.changeset/stateless-transport-reuse-guard.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@modelcontextprotocol/server': patch
33
'@modelcontextprotocol/core-internal': patch
4+
'@modelcontextprotocol/client': patch
45
---
56

67
Restores two v1 transport lifecycle invariants dropped in the v2 rewrite:

docs/migration/upgrade-to-v2.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1578,7 +1578,7 @@ rewrite required unless noted.
15781578
15791579
#### Transport / connection lifecycle (v1 parity)
15801580
1581-
Two v1 transport lifecycle invariants (in effect since sdk@1.26.0) apply in v2:
1581+
The following v1 transport lifecycle invariants (in effect since sdk@1.26.0) apply in v2:
15821582
15831583
- **A stateless `WebStandardStreamableHTTPServerTransport` serves exactly one request.**
15841584
With `sessionIdGenerator: undefined`, the second `handleRequest()` call throws

packages/client/src/client/client.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -929,15 +929,10 @@ export class Client extends Protocol<ClientContext> {
929929
override async connect(transport: Transport, options?: ConnectOptions): Promise<void> {
930930
// Fail fast while still connected: the negotiated paths below reset
931931
// 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-
}
932+
// Protocol.connect() guard would fire — without this early check, a
933+
// connect() on a connected instance would corrupt the live
934+
// connection's state before throwing.
935+
this.assertNotConnected();
941936
if (options?.prior !== undefined) {
942937
// Zero-round-trip reconnect from a previously-obtained
943938
// DiscoverResult: bypasses versionNegotiation resolution entirely.

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -775,16 +775,26 @@ export abstract class Protocol<ContextT extends BaseContext> {
775775
}
776776

777777
/**
778-
* Attaches to the given transport, starts it, and starts listening for messages.
779-
*
780-
* 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.
778+
* Throws if this instance is already bound to a transport. Called by
779+
* {@linkcode Protocol.connect} and, earlier, by subclass connect
780+
* overrides whose pre-connect work mutates per-connection state (see
781+
* `Client.connect()`): the check must run before any of that work.
781782
*/
782-
async connect(transport: Transport): Promise<void> {
783+
protected assertNotConnected(): void {
783784
if (this._transport) {
784785
throw new Error(
785786
'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.'
786787
);
787788
}
789+
}
790+
791+
/**
792+
* Attaches to the given transport, starts it, and starts listening for messages.
793+
*
794+
* 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.
795+
*/
796+
async connect(transport: Transport): Promise<void> {
797+
this.assertNotConnected();
788798

789799
this._transport = transport;
790800
const _onclose = this.transport?.onclose;
@@ -825,7 +835,14 @@ export abstract class Protocol<ContextT extends BaseContext> {
825835
// A transport that failed to start was never connected: unwind so
826836
// the instance can retry connect() — the reuse guard above would
827837
// otherwise lock the instance onto a transport that never carried
828-
// traffic.
838+
// traffic. Restore the transport's own callbacks too: leaving the
839+
// wrapped closures installed would let a late event from the
840+
// failed transport (e.g. a child process 'close' after a failed
841+
// spawn) tear down whatever connection a successful retry
842+
// established.
843+
transport.onclose = _onclose;
844+
transport.onerror = _onerror;
845+
transport.onmessage = _onmessage;
829846
this._transport = undefined;
830847
throw error;
831848
}

packages/core-internal/test/shared/protocolConnectGuard.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,36 @@ function createProtocol(): Protocol<BaseContext> {
5757
}
5858

5959
describe('Protocol.connect() reuse guard', () => {
60+
test('a transport that failed to start cannot disturb a later connection', async () => {
61+
const protocol = createProtocol();
62+
const failing = new MockTransport('failing');
63+
const userOnClose = (): void => {};
64+
failing.onclose = userOnClose;
65+
failing.start = async (): Promise<void> => {
66+
throw new Error('boot failure');
67+
};
68+
69+
await expect(protocol.connect(failing)).rejects.toThrow('boot failure');
70+
expect(protocol.transport).toBeUndefined();
71+
// The unwind restored the transport's own callbacks.
72+
expect(failing.onclose).toBe(userOnClose);
73+
74+
// A later connection is undisturbed by events from the failed
75+
// transport (e.g. a child process 'close' arriving after a failed
76+
// spawn already rejected start()).
77+
const transportB = new MockTransport('B');
78+
await protocol.connect(transportB);
79+
let sawClose = false;
80+
protocol.onclose = () => {
81+
sawClose = true;
82+
};
83+
84+
failing.onclose?.();
85+
86+
expect(protocol.transport).toBe(transportB);
87+
expect(sawClose).toBe(false);
88+
});
89+
6090
test('connect() while already connected throws', async () => {
6191
const protocol = createProtocol();
6292
const transportA = new MockTransport('A');

packages/middleware/node/src/streamableHttp.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -203,19 +203,35 @@ export class NodeStreamableHTTPServerTransport implements Transport {
203203
// Create a custom handler that includes our context
204204
// overrideGlobalObjects: false prevents Hono from overwriting global Response, which would
205205
// break frameworks like Next.js whose response classes extend the native Response
206+
let dispatchError: unknown;
206207
const handler = getRequestListener(
207208
async (webRequest: Request) => {
208-
return this._webStandardTransport.handleRequest(webRequest, {
209-
authInfo,
210-
parsedBody
211-
});
209+
try {
210+
return await this._webStandardTransport.handleRequest(webRequest, {
211+
authInfo,
212+
parsedBody
213+
});
214+
} catch (error) {
215+
dispatchError = error;
216+
throw error;
217+
}
212218
},
213219
{ overrideGlobalObjects: false }
214220
);
215221

216222
// Delegate to the request listener which handles all the Node.js <-> Web Standard conversion
217223
// including proper SSE streaming support
218224
await handler(req, res);
225+
226+
// getRequestListener absorbs a rejected dispatch into a generic 500
227+
// response, which would swallow the wrapped transport's single-use
228+
// throw. Re-raise it so the documented behavior (reusing a stateless
229+
// transport across requests throws at the call site) holds for the
230+
// Node adapter too. Other dispatch errors keep the existing
231+
// 500-response behavior.
232+
if (dispatchError instanceof Error && dispatchError.message.includes('Stateless transport cannot be reused across requests')) {
233+
throw dispatchError;
234+
}
219235
}
220236

221237
/**

packages/middleware/node/test/streamableHttp.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,6 +1657,39 @@ describe('Zod v4', () => {
16571657
return { transport, mcpServer };
16581658
}
16591659

1660+
it('reusing one stateless transport rejects at the handleRequest call site (single-use invariant)', async () => {
1661+
// Deliberately share ONE stateless transport across two requests:
1662+
// the second handleRequest call must reject with the single-use
1663+
// message at the Node call site (not be absorbed into a bare 500).
1664+
const mcpServer = new McpServer({ name: 'single-use-test', version: '1.0.0' }, { capabilities: { logging: {} } });
1665+
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
1666+
await mcpServer.connect(transport);
1667+
perRequestServers.push(mcpServer);
1668+
1669+
const callSiteErrors: Error[] = [];
1670+
const sharedServer = createServer(async (req, res) => {
1671+
try {
1672+
await transport.handleRequest(req, res);
1673+
} catch (error) {
1674+
callSiteErrors.push(error as Error);
1675+
if (!res.headersSent) res.writeHead(500);
1676+
res.end();
1677+
}
1678+
});
1679+
const sharedUrl = await listenOnRandomPort(sharedServer);
1680+
try {
1681+
const first = await sendPostRequest(sharedUrl, TEST_MESSAGES.initialize);
1682+
expect(first.status).toBe(200);
1683+
expect(callSiteErrors).toHaveLength(0);
1684+
1685+
await sendPostRequest(sharedUrl, TEST_MESSAGES.toolsList);
1686+
expect(callSiteErrors).toHaveLength(1);
1687+
expect(callSiteErrors[0]!.message).toContain('Stateless transport cannot be reused across requests');
1688+
} finally {
1689+
sharedServer.close();
1690+
}
1691+
});
1692+
16601693
it('should operate without session ID validation', async () => {
16611694
// Initialize the server first
16621695
const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize);

0 commit comments

Comments
 (0)