Document the timeout a hand-built httpx2 client needs - #3618
Conversation
An `httpx2.AsyncClient` built without `timeout=` gets httpx2's 5-second default, so a tool call that runs longer than that fails with a read timeout once the client is passed to `streamable_http_client`. The client transports page now says to keep the `timeout=` its example passes, which is the one the SDK's own client uses (30 seconds, 300 for reads). The OAuth and identity assertion tutorials, their example counterparts, the simple-auth client and the stories harness built their clients without a timeout; they now pass the same one. Fixes #3238
📚 Documentation preview
|
There was a problem hiding this comment.
1 issue found across 9 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/client/transports.md">
<violation number="1" location="docs/client/transports.md:42">
P2: This overstates `httpx2`’s read timeout as a maximum tool-call duration. A call can run longer than five seconds when response data or keepalives arrive within the read interval, while a shorter call can time out if an HTTP read is idle; document the idle-read condition instead.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| * You own the `httpx2.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create. | ||
| * `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else. | ||
|
|
||
| Keep the `timeout=`. It is the one the SDK's own client uses (30 seconds, 300 for reads); an `httpx2.AsyncClient` built without one gets `httpx2`'s 5-second default, and a tool call that runs longer than that fails with a read timeout. |
There was a problem hiding this comment.
P2: This overstates httpx2’s read timeout as a maximum tool-call duration. A call can run longer than five seconds when response data or keepalives arrive within the read interval, while a shorter call can time out if an HTTP read is idle; document the idle-read condition instead.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/client/transports.md, line 42:
<comment>This overstates `httpx2`’s read timeout as a maximum tool-call duration. A call can run longer than five seconds when response data or keepalives arrive within the read interval, while a shorter call can time out if an HTTP read is idle; document the idle-read condition instead.</comment>
<file context>
@@ -39,6 +39,8 @@ Two things to notice:
* You own the `httpx2.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else.
+Keep the `timeout=`. It is the one the SDK's own client uses (30 seconds, 300 for reads); an `httpx2.AsyncClient` built without one gets `httpx2`'s 5-second default, and a tool call that runs longer than that fails with a read timeout.
+
One TLS note: `httpx2` verifies certificates against the operating system trust store (via
</file context>
| Keep the `timeout=`. It is the one the SDK's own client uses (30 seconds, 300 for reads); an `httpx2.AsyncClient` built without one gets `httpx2`'s 5-second default, and a tool call that runs longer than that fails with a read timeout. | |
| Keep the `timeout=`. It is the one the SDK's own client uses (30 seconds, 300 for reads); an `httpx2.AsyncClient` built without one gets `httpx2`'s 5-second default, so a call can fail if an HTTP read receives no data for 5 seconds. |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
LGTM — a docs/examples-only change that spells out the timeout the SDK already uses, with a test pinning the claim; the one inline note is a nit about bounding the in-process round trips.
Extended reasoning...
The diff touches only docs/client/transports.md, seven docs_src/examples clients that gain timeout=httpx2.Timeout(30.0, read=300.0), and one new test in tests/docs_src/test_client_transports.py; nothing under src/ changes and no auth, crypto or data-exposure surface is altered (the examples' OAuth providers are passed through unchanged). The page's numbers match MCP_DEFAULT_TIMEOUT / MCP_DEFAULT_SSE_READ_TIMEOUT in src/mcp/shared/_httpx_utils.py, and the test's monkeypatch reaches Client(url) because create_mcp_http_client constructs via the httpx2.AsyncClient module attribute; the InProcessClient only receives timeout=/headers= from the two programs, so there is no transport= collision. The changed docs_src files are not embedded in README.md, so no snippet regeneration was needed, and the repository has no CODEOWNERS file. The change is small, mechanical and self-contained, and the sole finding is a test-hardening nit, which decided approve.
| await tutorial002.main() | ||
| await tutorial003.main() | ||
|
|
There was a problem hiding this comment.
🟡 nit (optional): a maintainer whose change breaks the in-process handshake gets a hung CI job here instead of a failed test. The awaits of tutorial002.main() and tutorial003.main() at tests/docs_src/test_client_transports.py:79-81 are full client round trips over an ASGI transport with nothing bounding them, and httpx2.ASGITransport does not apply the client's timeout. Fix: wrap the two program runs (and the session_manager.run() block around them) in anyio.fail_after(5) so a stalled list_tools fails the test instead of stalling the run.
Why this was flagged
The new test enters mcp.session_manager.run() and awaits tutorial002.main() then tutorial003.main() at tests/docs_src/test_client_transports.py:79-81. Each main() does Client.list_tools() over streamable_http_client routed through httpx2.ASGITransport (tests/docs_src/test_client_transports.py:74). If the server never answers a POST or the session manager stalls, that await never returns: ClientSession has no default read timeout and the in-process ASGI transport does not enforce the Timeout(30.0, read=300.0) the client carries. AGENTS.md asks that indefinite waits be wrapped in anyio.fail_after(5) to prevent hangs; the test adds none, so a regression shows up as a hung job rather than a test failure. On the base branch this test does not exist, so no new unbounded wait was present.
Verification: nit. Trigger: any future regression that stalls the streamable-HTTP handshake or a tools/list POST turns this test into a hang rather than a failure. The test at tests/docs_src/test_client_transports.py:78-80 runs await tutorial002.main(); await tutorial003.main() with no fail_after. Client defaults read_timeout_seconds=None (src/mcp/client/session.py:408), so the RPC wait is unbounded.
Fixes #3238.
This documents the supported way to customise the HTTP client rather than exporting the helper the issue asked for.
create_mcp_http_clientishttpx2.AsyncClient(...)with a timeout filled in, so building the client yourself needs nothing private once that timeout is spelled out. The docs showed it but never said why it matters, and several of our own examples left it off.What changed
docs/client/transports.mdhttpx2.AsyncClient": keep thetimeout=. A client built without one gets httpx2's 5-second default, and a tool call that runs longer than that fails with a read timeout. The value on the page (30 seconds, 300 for reads) is the one the SDK's own client uses.timeout=httpx2.Timeout(30.0, read=300.0):docs_src/oauth_clients/tutorial001.pyandtutorial002.pydocs_src/identity_assertion/tutorial001.pyexamples/snippets/clients/oauth_client.pyandidentity_assertion_client.pyexamples/clients/simple-auth-clientexamples/stories/_harness.pytests/docs_src/test_client_transports.pyClient(url)builds have the same timeouts, and that a client built withouttimeout=has 5 seconds.Nothing changes under
src/, and the translated pages aren't regenerated here.AI Disclaimer