Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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.


One TLS note: `httpx2` verifies certificates against the operating system trust store (via
[`truststore`](https://pypi.org/project/truststore/)), not a bundled CA list. In an environment with
no usable system CA store (some minimal containers), set the standard `SSL_CERT_FILE`/`SSL_CERT_DIR`
Expand Down
2 changes: 1 addition & 1 deletion docs_src/identity_assertion/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ async def fetch_id_jag(audience: str, resource: str) -> str:


async def main() -> None:
async with httpx2.AsyncClient(auth=oauth) as http_client:
async with httpx2.AsyncClient(auth=oauth, timeout=httpx2.Timeout(30.0, read=300.0)) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
2 changes: 1 addition & 1 deletion docs_src/oauth_clients/tutorial001.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async def wait_for_callback() -> AuthorizationCodeResult:


async def main() -> None:
async with httpx2.AsyncClient(auth=oauth) as http_client:
async with httpx2.AsyncClient(auth=oauth, timeout=httpx2.Timeout(30.0, read=300.0)) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
2 changes: 1 addition & 1 deletion docs_src/oauth_clients/tutorial002.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None


async def main() -> None:
async with httpx2.AsyncClient(auth=oauth) as http_client:
async with httpx2.AsyncClient(auth=oauth, timeout=httpx2.Timeout(30.0, read=300.0)) as http_client:
transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
async with Client(transport) as client:
result = await client.list_tools()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ async def _default_redirect_handler(authorization_url: str) -> None:
await self._run_session(read_stream, write_stream)
else:
print("📡 Opening StreamableHTTP transport connection with auth...")
async with httpx2.AsyncClient(auth=oauth_auth) as custom_client:
async with httpx2.AsyncClient(
auth=oauth_auth, timeout=httpx2.Timeout(30.0, read=300.0)
) as custom_client:
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
read_stream,
write_stream,
Expand Down
2 changes: 1 addition & 1 deletion examples/snippets/clients/identity_assertion_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ async def main() -> None:
scope="user",
)

async with httpx2.AsyncClient(auth=oauth_auth) as http_client:
async with httpx2.AsyncClient(auth=oauth_auth, timeout=httpx2.Timeout(30.0, read=300.0)) as http_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
Expand Down
2 changes: 1 addition & 1 deletion examples/snippets/clients/oauth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ async def main():
callback_handler=handle_callback,
)

async with httpx2.AsyncClient(auth=oauth_auth) as custom_client:
async with httpx2.AsyncClient(auth=oauth_auth, timeout=httpx2.Timeout(30.0, read=300.0)) as custom_client:
async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
Expand Down
4 changes: 3 additions & 1 deletion examples/stories/_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,9 @@ async def _run() -> None:
# server origin and relative paths like "/mcp" resolve.
parts = urlsplit(url)
base = f"{parts.scheme}://{parts.netloc}"
http = await stack.enter_async_context(httpx2.AsyncClient(base_url=base))
http = await stack.enter_async_context(
httpx2.AsyncClient(base_url=base, timeout=httpx2.Timeout(30.0, read=300.0))
)
make = targets
if build_auth is not None:
http.auth = build_auth(http)
Expand Down
39 changes: 38 additions & 1 deletion tests/docs_src/test_client_transports.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""`docs/client/transports.md`: every claim the page makes, proved against the real SDK."""

import inspect
from typing import Any

import httpx2
import pytest

from docs_src.client_transports import tutorial001, tutorial004
from docs_src.client_transports import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client
from mcp.client.stdio import get_default_environment
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer

# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
Expand Down Expand Up @@ -46,6 +49,40 @@ async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None
]


async def test_the_timeout_on_the_page_is_the_sdk_clients_and_a_client_without_one_has_five_seconds(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""tutorial002 and tutorial003: the `timeout=` tutorial003 passes is what `Client(url)` builds for itself
(30 seconds, 300 for reads); an `httpx2.AsyncClient` built without one has httpx2's 5-second default."""
async with httpx2.AsyncClient() as bare:
assert bare.timeout == httpx2.Timeout(5.0)

mcp = MCPServer("Bookshop")

@mcp.tool()
def search_books(query: str) -> str:
"""Search the catalog."""
raise NotImplementedError

app = mcp.streamable_http_app()
built: list[httpx2.Timeout] = []

class InProcessClient(httpx2.AsyncClient):
"""Every `httpx2.AsyncClient` the two programs build, routed to the server above."""

def __init__(self, **kwargs: Any) -> None:
super().__init__(transport=httpx2.ASGITransport(app=app), **kwargs)
built.append(self.timeout)

monkeypatch.setattr(httpx2, "AsyncClient", InProcessClient)
async with mcp.session_manager.run():
await tutorial002.main()
await tutorial003.main()

Comment on lines +79 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

assert capsys.readouterr().out == "['search_books']\n['search_books']\n"
assert built == [httpx2.Timeout(30.0, read=300.0), httpx2.Timeout(30.0, read=300.0)]


async def test_stdio_parameters_go_straight_to_client() -> None:
"""tutorial004: `Client` takes the `StdioServerParameters` directly, and nothing is spawned until you enter it."""
client = Client(tutorial004.server)
Expand Down
Loading