diff --git a/docs/client/transports.md b/docs/client/transports.md index 69b6b84c22..b41fe51348 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -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 [`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` diff --git a/docs_src/identity_assertion/tutorial001.py b/docs_src/identity_assertion/tutorial001.py index 24bc26572f..195d234854 100644 --- a/docs_src/identity_assertion/tutorial001.py +++ b/docs_src/identity_assertion/tutorial001.py @@ -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() diff --git a/docs_src/oauth_clients/tutorial001.py b/docs_src/oauth_clients/tutorial001.py index 6e01553dc5..0f8ff41e93 100644 --- a/docs_src/oauth_clients/tutorial001.py +++ b/docs_src/oauth_clients/tutorial001.py @@ -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() diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py index 507ee4bf92..1a97f03697 100644 --- a/docs_src/oauth_clients/tutorial002.py +++ b/docs_src/oauth_clients/tutorial002.py @@ -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() diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index b04e6fb546..2e63e351e6 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -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, diff --git a/examples/snippets/clients/identity_assertion_client.py b/examples/snippets/clients/identity_assertion_client.py index 8c80f28997..a1291ee4d1 100644 --- a/examples/snippets/clients/identity_assertion_client.py +++ b/examples/snippets/clients/identity_assertion_client.py @@ -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() diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index 11e0f5f912..a52f95e26f 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -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() diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 6a9670f207..33688f71cf 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -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) diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py index 6cd9c4f8dd..9892086728 100644 --- a/tests/docs_src/test_client_transports.py +++ b/tests/docs_src/test_client_transports.py @@ -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")] @@ -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() + + 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)