Skip to content

feat: add reset_timeout_on_progress and max_total_timeout to send_request#2589

Open
stefandevo wants to merge 5 commits into
modelcontextprotocol:mainfrom
stefandevo:feat/reset-timeout-on-progress
Open

feat: add reset_timeout_on_progress and max_total_timeout to send_request#2589
stefandevo wants to merge 5 commits into
modelcontextprotocol:mainfrom
stefandevo:feat/reset-timeout-on-progress

Conversation

@stefandevo
Copy link
Copy Markdown

Summary

Adds two new opt-in parameters to BaseSession.send_request() and ClientSession.call_tool():

  • reset_timeout_on_progress (bool, default False) — when enabled, each matching notifications/progress resets the timeout window to current_time + timeout, keeping the request alive as long as the server keeps sending progress.
  • max_total_timeout (float | None, default None) — optional absolute ceiling (seconds) measured from the original request start time. If exceeded, the request fails even if progress keeps arriving.

These mirror the TypeScript SDK's resetTimeoutOnProgress / maxTotalTimeout semantics in RequestOptions.

Motivation

Long-running MCP tool calls that send periodic progress updates currently time out on a fixed wall-clock budget. This breaks tools that intentionally wait for external events (human clicks, approval workflows) and use progress notifications as heartbeats. The TypeScript SDK already solved this with resetTimeoutOnProgress; this PR brings parity to the Python SDK.

Implementation

  • Uses anyio.move_on_after() in a loop with a per-request _ProgressTimeoutInfo dataclass. When the receive loop processes a matching progress notification, it sets was_reset = True, causing the send loop to restart the timeout window.
  • The approach avoids cross-task CancelScope manipulation and works with both asyncio and trio backends.
  • When reset_timeout_on_progress=True but no progress_callback is provided, a no-op callback is registered so the progress token is included in the request and the receive loop processes notifications.
  • Backward-compatible: existing code is completely unaffected.

Tests

7 new tests covering all TypeScript SDK scenarios:

Test Behavior
test_no_progress_no_reset_timeout_fires No progress → timeout fires as before
test_progress_resets_timeout Progress resets window, response arrives after original deadline
test_max_total_timeout_exceeded Absolute ceiling enforced
test_progress_stops_timeout_fires Progress stops → per-window timeout fires
test_multiple_progress_notifications Multiple resets keep request alive
test_reset_timeout_false_by_default Default behavior unchanged
test_call_tool_threads_reset_timeout End-to-end via ClientSession.call_tool

Full suite: 1179 passed, 98 skipped, 1 xfailed (zero regressions from 1172 baseline).

Related

  • TypeScript SDK implementation: packages/core/src/shared/protocol.ts (_setupTimeout, _resetTimeout, _cleanupTimeout)
  • DevFlow issue: DEV-3332

…uest

When an MCP client calls a long-running tool that sends periodic progress
notifications, the request currently times out on a fixed wall-clock budget
even though the server is actively sending progress updates. This breaks
blocking UI-card tools in agent frameworks (e.g., DevFlow's
devflow_user_choice, devflow_suggest_create_pr) that intentionally wait
for human interaction and rely on progress notifications as heartbeats.

Add two new opt-in parameters to BaseSession.send_request():

- reset_timeout_on_progress (bool, default False): when enabled, each
  matching notifications/progress resets the timeout window to
  current_time + timeout.
- max_total_timeout (float | None, default None): optional absolute
  ceiling measured from the original request start time. If exceeded,
  the request fails even if progress keeps arriving.

Both parameters are threaded through ClientSession.call_tool() and are
backward-compatible — existing code is unaffected.

Implementation uses anyio.move_on_after() in a loop, checking a per-request
_ProgressTimeoutInfo.was_reset flag set by the receive loop when a matching
progress notification arrives. This avoids cross-task CancelScope manipulation
and works with both asyncio and trio backends.

Semantics mirror the TypeScript SDK's resetTimeoutOnProgress / maxTotalTimeout
in RequestOptions.

Tests cover: no progress → timeout, progress resets timeout, max total
timeout ceiling, progress stops → timeout fires, multiple progress
notifications, default-off behavior, and call_tool passthrough.

DEV-3332
Remove unused `field` import and break long line (E501).
Add isinstance(msg, SessionMessage) guard before accessing .message
to satisfy pyright's type narrowing. Mark unused server_write with
underscore prefix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant