Skip to content

Commit 6a3a4c4

Browse files
review: type the lifecycle guard errors (SdkErrorCode.AlreadyConnected, new SdkErrorCode.StatelessTransportReuse) instead of raw Error
1 parent 26e8ed0 commit 6a3a4c4

10 files changed

Lines changed: 56 additions & 29 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
---
22
'@modelcontextprotocol/server': patch
3-
'@modelcontextprotocol/core-internal': patch
3+
'@modelcontextprotocol/core-internal': minor
44
'@modelcontextprotocol/client': patch
55
---
66

7-
Restores two v1 transport lifecycle invariants dropped in the v2 rewrite:
7+
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:
88

99
- 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.
1010
- `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.

docs/migration/upgrade-to-v2.md

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ The codemod ([`@modelcontextprotocol/codemod`](https://github.com/modelcontextpr
6464
mechanically applies every rename whose mapping is fixed. The mappings are the
6565
**source of truth** — they live in the codemod package and are not reproduced here:
6666

67-
| Mapping | Source file |
68-
| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
67+
| Mapping | Source file |
68+
| ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6969
| `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) |
7070
| Symbol renames (`McpError``ProtocolError`, `JSONRPCError``JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) |
7171
| `setRequestHandler(Schema, …)``setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) |
@@ -1583,28 +1583,30 @@ The following v1 transport lifecycle invariants (in effect since sdk@1.26.0) app
15831583
- **A stateless `WebStandardStreamableHTTPServerTransport` serves exactly one request.**
15841584
With `sessionIdGenerator: undefined`, the second `handleRequest()` call throws
15851585
`"Stateless transport cannot be reused across requests. Create a new transport per
1586-
request."` — matching v1 behavior since 1.26.0. Stateful transports
1586+
request."` (an `SdkError` with code `StatelessTransportReuse`) — matching v1
1587+
message behavior since 1.26.0. Stateful transports
15871588
(`sessionIdGenerator` set) accept multiple requests as before;
15881589
`NodeStreamableHTTPServerTransport` inherits the behavior. Migrate shared-transport
15891590
hosting to the per-request pattern:
15901591
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`.
1592+
```typescript
1593+
app.all('/mcp', async c => {
1594+
// Fresh transport + server pair per request.
1595+
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
1596+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
1597+
await server.connect(transport);
1598+
return transport.handleRequest(c.req.raw);
1599+
});
1600+
```
1601+
1602+
Or use `createMcpHandler`, which constructs the per-request pair for you (its
1603+
`legacy: 'stateless'` fallback and the modern per-request transport are both
1604+
one-exchange by construction).
1605+
1606+
- **`Protocol.connect()` throws when already connected** (an `SdkError` with code
1607+
`AlreadyConnected`: `"Already connected to a transport. Call close() before connecting
1608+
to a new transport, or use a separate Protocol instance per connection."`) instead of
1609+
silently rebinding `this._transport`.
16081610
Sequential `close()` then `connect()` keeps working. `Client.connect()` performs the
16091611
same check up front, before any per-connection state is reset.
16101612

packages/core-internal/src/errors/sdkErrors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export enum SdkErrorCode {
1212
NotConnected = 'NOT_CONNECTED',
1313
/** Transport is already connected */
1414
AlreadyConnected = 'ALREADY_CONNECTED',
15+
/** A stateless (sessionless) transport was asked to handle a second request exchange */
16+
StatelessTransportReuse = 'STATELESS_TRANSPORT_REUSE',
1517
/** Protocol is not initialized */
1618
NotInitialized = 'NOT_INITIALIZED',
1719

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,7 +782,8 @@ export abstract class Protocol<ContextT extends BaseContext> {
782782
*/
783783
protected assertNotConnected(): void {
784784
if (this._transport) {
785-
throw new Error(
785+
throw new SdkError(
786+
SdkErrorCode.AlreadyConnected,
786787
'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.'
787788
);
788789
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,13 @@ describe('Protocol.connect() reuse guard', () => {
9494

9595
await protocol.connect(transportA);
9696

97-
await expect(protocol.connect(transportB)).rejects.toThrow(
97+
const rejection = await protocol.connect(transportB).then(
98+
() => undefined,
99+
(error: unknown) => error
100+
);
101+
expect(rejection).toBeInstanceOf(SdkError);
102+
expect((rejection as SdkError).code).toBe(SdkErrorCode.AlreadyConnected);
103+
expect((rejection as SdkError).message).toBe(
98104
'Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.'
99105
);
100106

packages/core-internal/test/types/errorSurfacePins.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe('SdkErrorCode', () => {
6969
expect({ ...SdkErrorCode }).toEqual({
7070
NotConnected: 'NOT_CONNECTED',
7171
AlreadyConnected: 'ALREADY_CONNECTED',
72+
StatelessTransportReuse: 'STATELESS_TRANSPORT_REUSE',
7273
NotInitialized: 'NOT_INITIALIZED',
7374
CapabilityNotSupported: 'CAPABILITY_NOT_SUPPORTED',
7475
RequestTimeout: 'REQUEST_TIMEOUT',

packages/middleware/node/src/streamableHttp.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type {
1818
Transport,
1919
WebStandardStreamableHTTPServerTransportOptions
2020
} from '@modelcontextprotocol/server';
21-
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
21+
import { SdkErrorCode, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
2222

2323
/**
2424
* Configuration options for {@linkcode NodeStreamableHTTPServerTransport}
@@ -228,8 +228,10 @@ export class NodeStreamableHTTPServerTransport implements Transport {
228228
// throw. Re-raise it so the documented behavior (reusing a stateless
229229
// transport across requests throws at the call site) holds for the
230230
// 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')) {
231+
// 500-response behavior. Matched by code, not `instanceof`, so the
232+
// check also holds if bundling ever yields two copies of the error
233+
// class.
234+
if (dispatchError instanceof Error && (dispatchError as { code?: unknown }).code === SdkErrorCode.StatelessTransportReuse) {
233235
throw dispatchError;
234236
}
235237
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1685,6 +1685,7 @@ describe('Zod v4', () => {
16851685
await sendPostRequest(sharedUrl, TEST_MESSAGES.toolsList);
16861686
expect(callSiteErrors).toHaveLength(1);
16871687
expect(callSiteErrors[0]!.message).toContain('Stateless transport cannot be reused across requests');
1688+
expect((callSiteErrors[0] as { code?: unknown }).code).toBe('STATELESS_TRANSPORT_REUSE');
16881689
} finally {
16891690
sharedServer.close();
16901691
}

packages/server/src/server/streamableHttp.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
isJSONRPCRequest,
1616
isJSONRPCResultResponse,
1717
JSONRPCMessageSchema,
18+
SdkError,
19+
SdkErrorCode,
1820
SUPPORTED_PROTOCOL_VERSIONS
1921
} from '@modelcontextprotocol/core-internal';
2022

@@ -376,7 +378,10 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
376378
// In stateless mode (no sessionIdGenerator), each request must use a fresh transport.
377379
// Reusing a stateless transport causes message ID collisions between clients.
378380
if (!this.sessionIdGenerator && this._hasHandledRequest) {
379-
throw new Error('Stateless transport cannot be reused across requests. Create a new transport per request.');
381+
throw new SdkError(
382+
SdkErrorCode.StatelessTransportReuse,
383+
'Stateless transport cannot be reused across requests. Create a new transport per request.'
384+
);
380385
}
381386
this._hasHandledRequest = true;
382387

packages/server/test/server/statelessReuseGuard.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { randomUUID } from 'node:crypto';
1111

1212
import type { CallToolResult, JSONRPCMessage } from '@modelcontextprotocol/core-internal';
13+
import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
1314
import { afterEach, describe, expect, it } from 'vitest';
1415
import * as z from 'zod/v4';
1516

@@ -88,7 +89,13 @@ describe('stateless transport reuse guard', () => {
8889
const first = await transport.handleRequest(postRequest(initializeMessage));
8990
expect(first.status).toBe(200);
9091

91-
await expect(transport.handleRequest(postRequest(toolsListMessage))).rejects.toThrow(REUSE_ERROR);
92+
const rejection = await transport.handleRequest(postRequest(toolsListMessage)).then(
93+
() => undefined,
94+
(error: unknown) => error
95+
);
96+
expect(rejection).toBeInstanceOf(SdkError);
97+
expect((rejection as SdkError).code).toBe(SdkErrorCode.StatelessTransportReuse);
98+
expect((rejection as SdkError).message).toMatch(REUSE_ERROR);
9299
});
93100

94101
it('throws on a GET after a handled request', async () => {

0 commit comments

Comments
 (0)