Skip to content

fix(client): bound initialized handshake timeout - #3079

Open
YingzuoLiu wants to merge 6 commits into
modelcontextprotocol:mainfrom
YingzuoLiu:fix/client-connect-handshake-timeout-3078
Open

YingzuoLiu wants to merge 6 commits into
modelcontextprotocol:mainfrom
YingzuoLiu:fix/client-connect-handshake-timeout-3078

Conversation

@YingzuoLiu

@YingzuoLiu YingzuoLiu commented Oct 23, 2026 •

Copy link
Copy Markdown

Summary

  • Bound the legacy notifications/initialized POST with its own per-stage timeout (options.timeout, or the default request timeout). initialize and initialized do not share a total connection budget.
  • Pass a per-message abort signal to the transport and reject connect() promptly even if a custom transport ignores that signal.
  • Only the initialized stage preserves the original caller abort reason. The initialize stage retains request() behavior: non-SdkError abort reasons are wrapped in RequestTimeout.
  • Verify transport closure on timeout, in-flight cancellation, and cancellation after initialize but before initialized is sent.
  • Record the current cross-stage cancellation boundary using the same Error reason; update that test alongside any change to request() cancellation semantics. The broader discussion is already tracked in AbortSignal cancellation throws SdkErrorCode.RequestTimeout instead of a distinct abort error #2165.
  • Construct the initialized timeout error inside the timer callback so the stack points to the timeout, preserving code, message, timeout data, and abort-before-reject order.

Fixes #3078

Tests

  • Latest commit: client vitest run — 45 files, 1,085 tests passed; client typecheck and lint passed.
  • Focused handshake test file rerun — 9 tests passed.
  • One-off stack probe confirmed the timer callback is the first stack frame; temporary probe removed.
  • At 753157b: changeset formatting passed. Temporarily restoring timeout wrapping in initialized caused the cross-stage test to fail; implementation restored.
  • At d7a104f: core-internal 1,836 tests, typecheck and lint passed; client build passed. No core-internal changes in the subsequent two commits.

@YingzuoLiu
YingzuoLiu requested a review from a team as a code owner October 23, 2026 13:49
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1a45dc8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/server Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@3079

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@3079

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@3079

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@3079

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@3079

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@3079

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@3079

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@3079

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@3079

commit: 1a45dc8

@claude claude Bot added the v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes label Oct 24, 2026
@arhancanli

Copy link
Copy Markdown

Read the diff and the two tests. The race is set up correctly (listener and timer both cleaned in finally, Promise.race keeps the losers from becoming unhandled rejections, and the first test covers a transport that ignores requestSignal). Three points:

  1. The budget restarts. notificationTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC is a fresh full timeout for the last leg, while initialize already got its own. With connect(t, { timeout: 5_000 }) the handshake can now take up to ~10 s before failing. If timeout is documented as the bound for connect(), compute the remainder from a start time taken before initialize; if it is per-request, the changeset line and the comment should say so.
  2. A caller abort is reported as RequestTimeout. toConnectAbortError wraps any non-SdkError reason in new SdkError(SdkErrorCode.RequestTimeout, String(reason)), and the second test pins it (controller.abort(new Error('connect cancelled')) expects code: RequestTimeout). Callers who branch on the code to decide whether to retry will treat a deliberate cancel as a timeout. Is there an abort/cancel code in SdkErrorCode to use, or can the original reason be passed as cause?
  3. Edge: the options?.signal?.aborted check happens after this._instructions is set but before the notification, and the throw leaves no listener behind, which is fine; but I could not see in the diff whether connect() closes the transport on this new failure path. A test asserting transport.close was called (or not) would document it, since a half-open session that already received initialize is the state a retrying caller will hit.

Document per-stage timeout semantics and verify the initialized stage receives a fresh budget. Preserve caller abort reasons instead of classifying cancellation as RequestTimeout. Assert transport closure on timeout, in-flight abort, and abort between handshake stages.

Validation: client 1084 tests; core-internal 1836 tests; typecheck and lint passed for both packages.
Clarify that only the initialized stage preserves the caller abort reason; initialize retains request() wrapping for non-SdkError reasons. Compare both stages with the same Error object without asserting wrapper messages. Record that this boundary test should change alongside request() cancellation semantics.

Validation: client vitest run (1085 passed), typecheck, lint, changeset formatting. Mutation check: restoring timeout wrapping in initialized makes the new boundary test fail; implementation restored.

Copy link
Copy Markdown
Author

@arhancanli Thanks for the review. Addressed the three points in d7a104f and 753157b:

  1. Timeout budget: Kept a separate timeout for each legacy handshake stage, consistent with the existing per-request semantics. The connect() JSDoc, inline comment, and changeset now state this explicitly. Added a test verifying that initialized gets a fresh budget after initialize uses part of its timeout.

  2. Caller cancellation: The initialized stage now rejects with the original abort reason and passes that same reason to the transport signal. initialize still follows the existing request() behavior, which wraps non-SdkError reasons as RequestTimeout. I kept that broader behavior unchanged to limit this PR’s scope, and documented the distinction. A cross-stage test records the current boundary using the same Error object.

  3. Transport cleanup: The existing handshake failure handler already calls close(). Added assertions covering timeout, in-flight cancellation, and cancellation between stages. The last case also verifies that no initialized notification is sent.

Validation: all 1,085 client tests, typecheck, and lint pass. Temporarily restoring timeout wrapping in the initialized stage makes the new cross-stage test fail as expected.

Could you take another look when you have a chance?

@arhancanli

Copy link
Copy Markdown

Re-read 753157b against the three points.

  1. Budget: the JSDoc, the inline comment and the changeset now all say the timeout is per stage, and the fake-timer test (initialize takes 80 ms of 100, initialized still gets the full 100) pins it. That settles it for me.
  2. Abort reason: confirmed that requestSignal reaches the transport (_notificationViaCodec forwards options whole to transport.send), and the initialized stage now rejects with the caller's own reason, including the string and DOMException cases. The cross-stage test makes the asymmetry explicit, which is the honest way to leave it.
  3. Cleanup: closeCalls is asserted on the timeout, in-flight abort and between-stages paths, and the last one also asserts no initialized was sent.

Two small things, neither blocking:

  • timeoutError is built before the timer starts, so its stack trace points at the setup, not at the moment of the timeout. Constructing it inside the setTimeout callback gives the stack the same shape as other RequestTimeout errors.
  • The asymmetry in (2) means a caller who branches on code === RequestTimeout sees a cancel during initialize as a timeout but a cancel during initialized as their own reason. Worth a follow-up issue so request() can stop wrapping non-SdkError reasons, if the maintainers agree.

Move only the timeout error allocation into the timer callback so its stack originates there. Preserve its code, message, timeout data, and abort-before-reject order.

Validation: client 1085 tests, typecheck and lint passed; focused handshake file rerun (9 passed). One-off stack probe confirmed the first frame is the timer callback; probe removed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v2] Client.connect(): timeout and signal do not bound the notifications/initialized POST

2 participants