Repository navigation
Description
What happens
maxTotalTimeout is documented as a hard cap:
Maximum total time (in milliseconds) to wait for a response. If exceeded, an
SdkErrorwith codeSdkErrorCode.RequestTimeoutwill be raised, regardless of progress notifications.
It is not enforced unless a progress notification arrives and resetTimeoutOnProgress is set. Without both, the budget does nothing.
Measured on main, with a transport that never responds:
{ timeout: 1000, maxTotalTimeout: 150 }
at 200ms → still pending (cap already exceeded)
at 1200ms → "Request timed out" (fired on `timeout`, not the cap)
{ maxTotalTimeout: 400 }
after 3000ms → still pending (waits for the 60s default timeout)
So a caller asking for a 150ms ceiling got 1000ms, and a caller asking for 400ms got 60s.
Why it happens
In packages/core-internal/src/shared/protocol.ts, _setupTimeout arms the pending timer with timeout only:
timeoutId: setTimeout(onTimeout, timeout),maxTotalTimeout is read in exactly one place — the elapsed check inside _resetTimeout — and _resetTimeout has a single caller, guarded by:
if (timeoutInfo && responseHandler && timeoutInfo.resetTimeoutOnProgress) {So the only way the budget is ever consulted is the progress path. A request with no progress notifications, or with resetTimeoutOnProgress left at its default of false, never checks it.
Why it survived
The existing test (test/shared/protocol.test.ts, "should respect maxTotalTimeout") passes resetTimeoutOnProgress: true and feeds two progress notifications, so it exercises the one path that works.
Expected
The pending timer should be armed for whichever limit comes first, and re-armed against the remaining budget on each progress notification, so the cap holds whether or not progress arrives.
Environment
main at 3924de99 (2.0.0-alpha.0). The same shape is present on v1.x.
Not related to #2224, #2136, #1870 or #2082 — those concern resetTimeoutOnProgress needing an onprogress callback, and none of them touch _setupTimeout or the arming.
I have a fix and tests ready and am happy to open a PR if useful.
AI assistance was used to investigate and write this report.
Activity
Ben-Home commentedon Aug 21, 2026
Lstarsky0 commentedon Aug 23, 2026
Mechanism confirmed on
mainat3924de99. One correction to "read in exactly one place": that's true insideprotocol.ts, but there is a second reader, and it's what makes this bigger than one option not working.packages/core-internal/src/shared/inputRequiredDriver.ts:276-286shrinks the budget between legs of aninput_requiredflow:remaininggoes throughinputRequiredEngine.ts:188back into the request funnel and lands on_setupTimeoutatprotocol.ts:1569— the arming that ignores it. The module doc atinputRequiredDriver.ts:17states the design as "the per-legtimeoutapplies to every wire leg unchanged, andmaxTotalTimeoutbounds the whole flow by shrinking the budget passed to each leg — no new timer system."So the budget is enforced only at leg boundaries today. One leg that hangs sits on the per-leg
timeout(60s by default) and the driver doesn't get to look at the budget again until that leg returns, so a flow asked to finish in 5s can run past 60s with nothing noticing. The synthetic progress the driver emits at:244doesn't help either — it's a local call to the caller'sonprogress, not an incomingnotifications/progress, so it never reaches_resetTimeout.That's an argument for the fix you describe over any narrower one: arming on whichever of
timeoutand the remaining budget comes first makes the driver's stated contract true for free, whereas anything that only touches the progress path leaves it broken.jstar0 commentedon Aug 23, 2026
I intend to prepare a focused fix for this issue, starting from main/v2. The implementation will arm each request timeout against the earlier of the per-request timeout and the remaining maxTotalTimeout budget, while preserving progress-based reset behavior. I will add regressions for a non-progressing request and for an input_required flow whose per-leg timeout exceeds the total budget, then check whether the same patch applies cleanly to v1.x before proposing a backport.
mturac commentedon Aug 24, 2026
I pushed a fix for this on
mturac:fix/issue-2695(commitcf95432):min(timeout, maxTotalTimeout)so the hard ceiling applies even without progress.Maximum total timeout exceededwhen the total budget is what fired.packages/core-internal/test/shared/protocol.test.ts(should enforce maxTotalTimeout without progress notifications).Local:
npm test -- --run test/shared/protocol.test.tsinpackages/core-internal— 50 passed.I could not open the PR from this account right now (
CreatePullRequestis forbidden). Happy to open it as soon as that path works, or a maintainer can pull from https://github.com/mturac/typescript-sdk/tree/fix/issue-2695.test(core): move modelcontextprotocol#2695 regression to protocol.tes…
test(core): add fake-timer maxTotalTimeout regressions for modelconte…
fix(core): satisfy unicorn/no-negated-condition in timeout helpers
test(core): add maxTotalTimeout regression tests for modelcontextprot…
test(core): add maxTotalTimeout regression tests for modelcontextprot…
test(core): add maxTotalTimeout regression tests for modelcontextprot…
mturac commentedon Aug 24, 2026
Prepared fix branch: https://github.com/mturac/typescript-sdk/tree/fix/issue-2695
Open PR: https://github.com/modelcontextprotocol/typescript-sdk/compare/main...mturac:fix/issue-2695?expand=1
mturac commentedon Aug 24, 2026
Opened draft PR #2712 with CI green on
afadc39(prettier fix included). MCP org policy blocks marking fork PRs ready via API (markPullRequestReadyForReviewforbidden); will mark ready in the GitHub UI when available.#2712
mturac commentedon Aug 24, 2026
Opened draft PR: #2714
mturac commentedon Aug 24, 2026
Draft PR #2714 is up with 14/14 CI green on
afadc39(includes the prettier fix).Fork authors cannot mark MCP org fork PRs ready via API (
markPullRequestReadyForReview/gh pr readyboth forbidden). If this looks good for triage, marking it ready for review from the GitHub UI would unblock maintainer review.Happy to adjust anything still off.
mturac commentedon Aug 24, 2026
PR #2714 is up for this issue with 14/14 CI green on
afadc39.GitHub blocks fork authors from marking MCP org fork PRs ready via API (
markPullRequestReadyForReviewforbidden). If the fix looks good for triage, marking the PR ready for review from the UI would unblock maintainer review.Happy to adjust anything still off.
test(core): add maxTotalTimeout regression tests for modelcontextprot…
claxman commentedon Sep 7, 2026
Reproduced on main at 5119ee7: TestProtocol plus a silent MockTransport with { timeout: 1000, maxTotalTimeout: 150 } is still pending at 150ms (settled undefined), and the existing should respect maxTotalTimeout test stays green because it feeds progress. I will arm _setupTimeout with min(timeout, maxTotalTimeout), re-arm remaining budget on progress reset, reject with Maximum total timeout exceeded when the cap fires, and add no-progress plus remaining-budget regressions.
veriton-dev commentedon Sep 7, 2026
Host-side note from agent runtimes that treat
maxTotalTimeoutas the hard budget for tool/RPC slots:Failure modes this creates in practice
{ timeout: 30_000, maxTotalTimeout: 5_000 }expecting a 5s ceiling; without progress (or withresetTimeoutOnProgress: false, the default) the pending timer is armed only withtimeout, so the call can run ~30s beforeRequestTimeout. The documented “regardless of progress” contract is inverted.maxTotalTimeoutsits inside_resetTimeout, which is itself gated onresetTimeoutOnProgress. No progress ⇒ never consulted. Quiet long tools (common for MCP) never trip the cap.resetTimeoutOnProgress: true, so it only covers the one path that works and can’t catch regressions on the no-progress path.Minimal shape that matches the docs
_setupTimeout, arm the timer withMath.min(timeout, maxTotalTimeout ?? timeout)(or whichever absolute deadline is sooner).deadline = start + maxTotalTimeouton the timeout info.Math.min(timeout, remainingUntilDeadline)— never extend past the absolute cap.maxTotalTimeoutis set and already elapsed at arm/reset time, fireSdkErrorCode.RequestTimeoutimmediately (don’t wait for the next tick of the soft timer).Acceptance checks
{ timeout: 1000, maxTotalTimeout: 150 }, silent transport → reject by ~150ms, not ~1000ms.{ maxTotalTimeout: 400 }alone (default soft timeout) → reject by ~400ms, not 60s.resetTimeoutOnProgress: truestill resets the soft timer but never past the absolute deadline.resetTimeoutOnProgress: false+ progress noise still enforces the absolute cap.RequestTimeout(or a distinctMaxTotalTimeoutif you want callers to distinguish soft vs hard — optional; docs currently say RequestTimeout).Happy to review a PR that moves the arming into
_setupTimeoutand adds a no-progress case next to the existing progress test. No product pitch — just the lifecycle gap.Sanath91009 commentedon Nov 6, 2026
Opened #3155 with a fix and three tests.
Reproduced on
mainat1e608ca:{ timeout: 1000, maxTotalTimeout: 150 }with no progress is still pending at 151ms and rejects at 1000ms withRequest timed out. With{ timeout: 1000, maxTotalTimeout: 300, resetTimeoutOnProgress: true }and one progress notification at 200ms, the timer is re-armed for the full 1000ms, so it settles at 1200ms instead of 300ms.The change arms the timer with
min(timeout, remaining maxTotalTimeout), re-arms with the remaining budget on a progress reset, and reportsMaximum total timeout exceededwhen the cap is what fired. Two of the three new tests fail onmain; all three pass with the fix.packages/core-internal78 files / 1853 tests andpackages/server55 files / 674 tests are green, typecheck and lint clean.One thing I could not do myself: the PR is a draft. Creating a non-draft PR from this account on this repo returns 404 from the API, and
markPullRequestReadyForReviewreturns FORBIDDEN, so a maintainer needs to mark it ready. The diff and CI are complete.#2763 covers the same issue and has been conflicted against
mainsince 2026-09-23. Happy to close #3155 if that one is rebased and lands first.