Repository navigation
Python: Reject a non-positive MCP request timeout instead of disabling it - #9314
Grapette.L (Lesereingrape) wants to merge 9 commits into
Conversation
|
/review |
There was a problem hiding this comment.
MAF Automated Review — Iteration 1
Result: Findings reported
Scope: full PR (2 commit(s)): 444031688dd6, ab00244a5c8d
Model: gpt-5.6-sol
Overview
The change correctly rejects non-positive constructor inputs and explicitly forwards positive values to the MCP SDK; the added tests cover both validation and the initial connection path. All three transport subclasses share the base guard, and no repository production caller supplies this option from untrusted input. One lifecycle gap remains: the public mutable attribute can bypass construction-time validation, and the changed consumer then turns a later zero assignment into immediate request failures.
Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
1 verified finding remained after source verification (1 medium) across 1 file. Details are attached to the affected lines below.
Affected areas: python/packages/core/agent_framework/_mcp.py
| write_stream=transport[1], | ||
| read_timeout_seconds=( | ||
| timedelta(seconds=self.request_timeout) if self.request_timeout else None | ||
| timedelta(seconds=self.request_timeout) if self.request_timeout is not None else None |
There was a problem hiding this comment.
request_timeout remains a public mutable attribute, so assigning 0 after construction bypasses the new guard. On the next connect or reconnect, this branch passes timedelta(0) to ClientSession, making every MCP request expire immediately instead of raising the documented configuration error. Please preserve the positive-or-None invariant for later assignments, or revalidate the value before creating the session.
There was a problem hiding this comment.
Good catch — the invariant only held for the constructor. _connect_on_owner() now revalidates request_timeout immediately before the framework-owned Client is built, so a later tool.request_timeout = 0 raises the same ValueError instead of opening a session whose requests expire immediately. Added test_connect_revalidates_request_timeout_cleared_after_construction, which also asserts the SDK Client is never constructed.
For context on the line you quoted: the branch also merged main, which now passes the timeout as Client(read_timeout_seconds=...) rather than a timedelta into ClientSession; the guard follows that. Local run on this base: 426 passed, 2 skipped for tests/core/test_mcp.py, ruff check and ruff format --check clean.
…e' into fix/mcp-request-timeout-nonpositive
|
Follow-up on the AI-review thread: The checks for this head are parked as |
Eduard van Valkenburg (eavanvalkenburg)
left a comment
There was a problem hiding this comment.
The constructor and reconnect-time validation consistently enforce a positive timeout or None, while preserving the existing positive and unbounded paths. The regression covers post-construction mutation before SDK client creation.
Motivation & Context
MCPTooldocumentedrequest_timeoutas "Timeout in seconds for MCP requests" but never validated it, and its single consumer folded the value through truthiness:So
request_timeout=0— and likewise a negative value — becameread_timeout_seconds=None. In the MCP SDK that is the unbounded setting, not the strictest one:mcp/shared/session.py:192documentsread_timeout_secondsas# If none, reading will never time out, and:284-291passes it toanyio.fail_after(timeout), which is a no-op forNone. A caller who configured the tightest possible timeout got the opposite behaviour: an MCP server that accepts the connection but never answers a request blocks that agent forever, with noMcpErrorto catch and no timeout to retry against.MCPStdioTool,MCPStreamableHTTPToolandMCPWebsocketTooleach forward their ownrequest_timeoutintoMCPTool.__init__unchanged (:3684,:3963,:4497), so all three transports inherit the base-class behaviour and the base-class guard covers all of them. Reported and reproducible in #9313.Description & Review Guide
What are the major changes? Three small edits in
python/packages/core/agent_framework/_mcp.py(7 lines changed total):MCPTool.__init__, placed directly after the existingmax_host_payload_size_bytesguard (:998), so the two constructor knobs that take "a positive size/count, orNoneto opt out" are validated the same way;:2007now checksis not Noneinstead of truthiness, so a positive value of any magnitude reachesClientSessionas written;request_timeoutdocstring states the contract ("Must be positive, orNoneto leave requests without a timeout").Plus two tests in
python/packages/core/tests/core/test_mcp.py, placed next totest_mcp_host_payload_size_limit_must_be_positive_or_none, which is the same shape of test for the sibling knob.What is the impact of these changes?
Nonekeeps its exact current meaning (no timeout), and every positive value is unaffected — the consumer change is a no-op for those, since0was the only falsy value that could reach it as anint. What changes is the previously-silent group:request_timeout=0and negative values now raiseValueErrorat construction instead of quietly disabling the timeout. That is a behaviour change for a caller who was relying on0meaning "no timeout", butNoneis the documented way to express that, and the value reads as a timeout in seconds, so0is far more likely to be a typo or a mis-set config. Failing at construction also localises the mistake to the line that set it, rather than surfacing much later as an unbounded await.What do you want reviewers to focus on? Whether rejecting non-positive values at construction is the right contract, versus treating
0as "no timeout" and letting it through. I followed the in-repo precedent in this same module:max_host_payload_size_bytesalready raises"max_host_payload_size_bytes must be positive or None."(:1000), andMCPTaskOptions.__post_init__rejects non-positive durations fordefault_ttlandmax_task_wait(:785-788), so a "positive, orNoneto opt out" knob is validated at construction here rather than reinterpreted downstream. If the team would rather have0mean "no timeout", the consumer change alone (is not None, with0mapped toNone) delivers that, and the tests can be re-pointed at the resultingread_timeout_secondsvalue instead of the raised error.Local verification, on
mainat726dcff8:main(Failed: DID NOT RAISE ValueError,tests/core/test_mcp.py:1055); the second new test already passes onmainand is kept to pin the positive-value path so theis not Nonechange cannot regress it.test_mcp.py(398),test_mcp_http_auth.py(91),test_mcp_observability.py(15),test_mcp_skills.py(615) — 1,119 tests, no failures.ruff==0.16.10, reports no findings on both changed files (ruff check --no-fix) andruff format --diffreports both already formatted.tests/coresuite was also run: 4 tests fail, and all 4 fail identically on a pristine726dcff8checkout without this change — two need theaiohttpextra, one is a long-session-id temp path that exceeds the Windows path limit, andtest_feature_stage.py::test_feature_id_allows_lowercase_valuesis unrelated to MCP. Nothing in the MCP surface fails.Related Issue
Fixes #9313
I checked for competing open PRs before filing: no open PR in this repository mentions
request_timeout, and the last commit to touch_mcp.pyis84d13537(#8972), so the hunks in this change are untouched on currentmain.AI Assistance
AI assistance details: the guard, the consumer/docstring edits, both tests and this description were drafted by the Qoder coding assistant working from the repro written up in #9313, and were then run locally through the checks listed above (pinned
ruff==0.16.10plus the four MCP test modules on726dcff8, before and after the source change). The diff is deliberately small and self-contained so it is quick to audit. The checkbox above is left for the human submitter to tick, since accepting responsibility is theirs to state.Contribution Checklist
breaking changelabel requested:Noneand every positive value behave exactly as before; only values that previously fell through to the unbounded branch now raise. Callers passing0do see a new error, which is the point of the fix and is noted under "What is the impact of these changes?" above.