-
Notifications
You must be signed in to change notification settings - Fork 2k
fix(server): restore v1 transport lifecycle parity: single-use stateless transports, fail-fast on double connect #2421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
de8a6cc
acccb8e
6e83ce2
a95a303
c9505ae
652694c
b38fa44
9aaea87
18747af
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| --- | ||
|
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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
claude[bot] marked this conversation as resolved.
|
||
| if (options?.prior !== undefined) { | ||
| // Zero-round-trip reconnect from a previously-obtained | ||
| // DiscoverResult: bypasses versionNegotiation resolution entirely. | ||
|
|
@@ -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
|
||
|
Comment on lines
947
to
954
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Both session-resume branches (here and in Extended reasoning...What the bug is. |
||
| // 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); | ||
| } | ||
|
|
@@ -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; | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.