From 45951d4ce495a6377e4ac053bf272cc5930b1918 Mon Sep 17 00:00:00 2001 From: James Yang Date: Tue, 11 Aug 2026 13:20:13 -0400 Subject: [PATCH 1/2] Document per-request HTTP headers for Streamable HTTP clients. Show how contextvars plus an httpx2 request event hook on a shared AsyncClient cover per-call Authorization and trace headers without new Client API, addressing #1966. --- docs/client/transports.md | 26 +++++++++++++ docs_src/client_transports/tutorial005.py | 37 +++++++++++++++++++ tests/docs_src/test_client_transports.py | 45 ++++++++++++++++++++++- 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 docs_src/client_transports/tutorial005.py diff --git a/docs/client/transports.md b/docs/client/transports.md index b453100655..f0c315abd4 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -78,6 +78,30 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A nothing away. It is also where OAuth plugs in: `httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**. +### Per-request headers + +Headers on the `httpx2.AsyncClient` are fixed for every request that client sends. When the value +has to change between calls — a per-user `Authorization`, a fresh `X-Trace-ID` — put the varying +bits in `contextvars` and attach an `event_hooks["request"]` hook that copies them onto each +outbound request: + +```python title="client.py" hl_lines="8-18 22-23 29-36" +--8<-- "docs_src/client_transports/tutorial005.py" +``` + +What makes this work with a long-lived `Client` session: + +* The Streamable HTTP transport runs each outbound POST in the **caller's** `contextvars.Context`, + so a value you `set()` just before `await client.call_tool(...)` is visible inside the request + hook for that call — and not for the next one with a different value. +* The shared `httpx2.AsyncClient` stays open; only the headers the hook writes change per request. +* Transport-internal traffic (the long-lived GET stream, session `DELETE`) also hits the hook. Guard + optional headers with `if value is not None` so a missing context var does not invent an empty + `Authorization`. + +Static headers and this pattern stack: put connection-wide defaults on the client, and let the hook +overlay the per-request ones. + ## stdio A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file. @@ -115,6 +139,8 @@ A **transport** is any async context manager that yields a `(read, write)` pair * `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. * `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. * Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. +* Per-request headers (auth tokens, trace IDs) go through `contextvars` plus an + `event_hooks["request"]` hook on that same client — not through new `Client` kwargs. * stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone. * The subprocess gets an allow-listed environment, not yours; `env=` adds to it. * A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol. diff --git a/docs_src/client_transports/tutorial005.py b/docs_src/client_transports/tutorial005.py new file mode 100644 index 0000000000..cbf3e23323 --- /dev/null +++ b/docs_src/client_transports/tutorial005.py @@ -0,0 +1,37 @@ +import contextvars + +import httpx2 + +from mcp import Client +from mcp.client.streamable_http import streamable_http_client + +auth_token: contextvars.ContextVar[str | None] = contextvars.ContextVar("auth_token", default=None) +trace_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("trace_id", default=None) + + +async def inject_request_headers(request: httpx2.Request) -> None: + token = auth_token.get() + if token is not None: + request.headers["Authorization"] = f"Bearer {token}" + current_trace = trace_id.get() + if current_trace is not None: + request.headers["X-Trace-ID"] = current_trace + + +async def main() -> None: + async with httpx2.AsyncClient( + event_hooks={"request": [inject_request_headers]}, + timeout=httpx2.Timeout(30.0, read=300.0), + follow_redirects=True, + ) as http_client: + transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client) + async with Client(transport) as client: + auth_token.set("user-123-token") + trace_id.set("trace-abc") + first = await client.call_tool("search_books", {"query": "dune"}) + print(first.structured_content) + + auth_token.set("user-456-token") + trace_id.set("trace-def") + second = await client.call_tool("search_books", {"query": "neuromancer"}) + print(second.structured_content) diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py index 4da0da1f42..89d81a0511 100644 --- a/tests/docs_src/test_client_transports.py +++ b/tests/docs_src/test_client_transports.py @@ -2,12 +2,14 @@ import inspect +import httpx2 import pytest -from docs_src.client_transports import tutorial001, tutorial004 +from docs_src.client_transports import tutorial001, tutorial004, tutorial005 from mcp import Client from mcp.client.stdio import get_default_environment, stdio_client 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")] @@ -57,3 +59,44 @@ async def test_the_child_environment_is_an_allowlist(monkeypatch: pytest.MonkeyP extra = tutorial004.server.env assert extra is not None assert (inherited | extra)["BOOKSHOP_API_KEY"] == "secret" + + +async def test_request_hook_sees_contextvars_set_around_each_call() -> None: + """tutorial005: values set before each Client call reach the shared client's request hook as headers.""" + mcp = MCPServer("Bookshop") + + @mcp.tool() + def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + seen: list[tuple[str | None, str | None]] = [] + + async def record_headers(request: httpx2.Request) -> None: + await tutorial005.inject_request_headers(request) + if request.method == "POST": + seen.append((request.headers.get("Authorization"), request.headers.get("X-Trace-ID"))) + + url = "http://127.0.0.1:8000/mcp" + transport = httpx2.ASGITransport(app=mcp.streamable_http_app()) + async with mcp.session_manager.run(): + async with ( + httpx2.AsyncClient( + transport=transport, + base_url=url, + event_hooks={"request": [record_headers]}, + follow_redirects=True, + ) as http_client, + Client(streamable_http_client(url, http_client=http_client)) as client, + ): + tutorial005.auth_token.set("user-123-token") + tutorial005.trace_id.set("trace-abc") + first = await client.call_tool("search_books", {"query": "dune"}) + tutorial005.auth_token.set("user-456-token") + tutorial005.trace_id.set("trace-def") + second = await client.call_tool("search_books", {"query": "neuromancer"}) + + assert first.structured_content == {"result": "Found 3 books matching 'dune'."} + assert second.structured_content == {"result": "Found 3 books matching 'neuromancer'."} + assert ("Bearer user-123-token", "trace-abc") in seen + assert ("Bearer user-456-token", "trace-def") in seen From 47e240cea66ec0688e4ced8a64ab73e776f73037 Mon Sep 17 00:00:00 2001 From: James Yang Date: Tue, 11 Aug 2026 13:26:46 -0400 Subject: [PATCH 2/2] Fix docs_src transport test branch coverage for CI. Avoid a partial-covered POST-only branch and match the usual session_manager pragma so fail_under=100 passes. --- tests/docs_src/test_client_transports.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py index 89d81a0511..1da02a041f 100644 --- a/tests/docs_src/test_client_transports.py +++ b/tests/docs_src/test_client_transports.py @@ -70,16 +70,15 @@ def search_books(query: str) -> str: """Search the catalog by title or author.""" return f"Found 3 books matching {query!r}." - seen: list[tuple[str | None, str | None]] = [] + seen: list[tuple[str, str | None, str | None]] = [] async def record_headers(request: httpx2.Request) -> None: await tutorial005.inject_request_headers(request) - if request.method == "POST": - seen.append((request.headers.get("Authorization"), request.headers.get("X-Trace-ID"))) + seen.append((request.method, request.headers.get("Authorization"), request.headers.get("X-Trace-ID"))) url = "http://127.0.0.1:8000/mcp" transport = httpx2.ASGITransport(app=mcp.streamable_http_app()) - async with mcp.session_manager.run(): + async with mcp.session_manager.run(): # pragma: no branch async with ( httpx2.AsyncClient( transport=transport, @@ -98,5 +97,6 @@ async def record_headers(request: httpx2.Request) -> None: assert first.structured_content == {"result": "Found 3 books matching 'dune'."} assert second.structured_content == {"result": "Found 3 books matching 'neuromancer'."} - assert ("Bearer user-123-token", "trace-abc") in seen - assert ("Bearer user-456-token", "trace-def") in seen + post_headers = [(auth, trace) for method, auth, trace in seen if method == "POST"] + assert ("Bearer user-123-token", "trace-abc") in post_headers + assert ("Bearer user-456-token", "trace-def") in post_headers