Skip to content

Commit 45951d4

Browse files
committed
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.
1 parent 6e30452 commit 45951d4

3 files changed

Lines changed: 107 additions & 1 deletion

File tree

docs/client/transports.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,30 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A
7878
nothing away. It is also where OAuth plugs in:
7979
`httpx2.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**.
8080

81+
### Per-request headers
82+
83+
Headers on the `httpx2.AsyncClient` are fixed for every request that client sends. When the value
84+
has to change between calls — a per-user `Authorization`, a fresh `X-Trace-ID` — put the varying
85+
bits in `contextvars` and attach an `event_hooks["request"]` hook that copies them onto each
86+
outbound request:
87+
88+
```python title="client.py" hl_lines="8-18 22-23 29-36"
89+
--8<-- "docs_src/client_transports/tutorial005.py"
90+
```
91+
92+
What makes this work with a long-lived `Client` session:
93+
94+
* The Streamable HTTP transport runs each outbound POST in the **caller's** `contextvars.Context`,
95+
so a value you `set()` just before `await client.call_tool(...)` is visible inside the request
96+
hook for that call — and not for the next one with a different value.
97+
* The shared `httpx2.AsyncClient` stays open; only the headers the hook writes change per request.
98+
* Transport-internal traffic (the long-lived GET stream, session `DELETE`) also hits the hook. Guard
99+
optional headers with `if value is not None` so a missing context var does not invent an empty
100+
`Authorization`.
101+
102+
Static headers and this pattern stack: put connection-wide defaults on the client, and let the hook
103+
overlay the per-request ones.
104+
81105
## stdio
82106

83107
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
115139
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
116140
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
117141
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
142+
* Per-request headers (auth tokens, trace IDs) go through `contextvars` plus an
143+
`event_hooks["request"]` hook on that same client — not through new `Client` kwargs.
118144
* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
119145
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
120146
* 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.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import contextvars
2+
3+
import httpx2
4+
5+
from mcp import Client
6+
from mcp.client.streamable_http import streamable_http_client
7+
8+
auth_token: contextvars.ContextVar[str | None] = contextvars.ContextVar("auth_token", default=None)
9+
trace_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("trace_id", default=None)
10+
11+
12+
async def inject_request_headers(request: httpx2.Request) -> None:
13+
token = auth_token.get()
14+
if token is not None:
15+
request.headers["Authorization"] = f"Bearer {token}"
16+
current_trace = trace_id.get()
17+
if current_trace is not None:
18+
request.headers["X-Trace-ID"] = current_trace
19+
20+
21+
async def main() -> None:
22+
async with httpx2.AsyncClient(
23+
event_hooks={"request": [inject_request_headers]},
24+
timeout=httpx2.Timeout(30.0, read=300.0),
25+
follow_redirects=True,
26+
) as http_client:
27+
transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client)
28+
async with Client(transport) as client:
29+
auth_token.set("user-123-token")
30+
trace_id.set("trace-abc")
31+
first = await client.call_tool("search_books", {"query": "dune"})
32+
print(first.structured_content)
33+
34+
auth_token.set("user-456-token")
35+
trace_id.set("trace-def")
36+
second = await client.call_tool("search_books", {"query": "neuromancer"})
37+
print(second.structured_content)

tests/docs_src/test_client_transports.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import inspect
44

5+
import httpx2
56
import pytest
67

7-
from docs_src.client_transports import tutorial001, tutorial004
8+
from docs_src.client_transports import tutorial001, tutorial004, tutorial005
89
from mcp import Client
910
from mcp.client.stdio import get_default_environment, stdio_client
1011
from mcp.client.streamable_http import streamable_http_client
12+
from mcp.server import MCPServer
1113

1214
# See test_index.py for why this is a per-module mark and not a conftest hook.
1315
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
5759
extra = tutorial004.server.env
5860
assert extra is not None
5961
assert (inherited | extra)["BOOKSHOP_API_KEY"] == "secret"
62+
63+
64+
async def test_request_hook_sees_contextvars_set_around_each_call() -> None:
65+
"""tutorial005: values set before each Client call reach the shared client's request hook as headers."""
66+
mcp = MCPServer("Bookshop")
67+
68+
@mcp.tool()
69+
def search_books(query: str) -> str:
70+
"""Search the catalog by title or author."""
71+
return f"Found 3 books matching {query!r}."
72+
73+
seen: list[tuple[str | None, str | None]] = []
74+
75+
async def record_headers(request: httpx2.Request) -> None:
76+
await tutorial005.inject_request_headers(request)
77+
if request.method == "POST":
78+
seen.append((request.headers.get("Authorization"), request.headers.get("X-Trace-ID")))
79+
80+
url = "http://127.0.0.1:8000/mcp"
81+
transport = httpx2.ASGITransport(app=mcp.streamable_http_app())
82+
async with mcp.session_manager.run():
83+
async with (
84+
httpx2.AsyncClient(
85+
transport=transport,
86+
base_url=url,
87+
event_hooks={"request": [record_headers]},
88+
follow_redirects=True,
89+
) as http_client,
90+
Client(streamable_http_client(url, http_client=http_client)) as client,
91+
):
92+
tutorial005.auth_token.set("user-123-token")
93+
tutorial005.trace_id.set("trace-abc")
94+
first = await client.call_tool("search_books", {"query": "dune"})
95+
tutorial005.auth_token.set("user-456-token")
96+
tutorial005.trace_id.set("trace-def")
97+
second = await client.call_tool("search_books", {"query": "neuromancer"})
98+
99+
assert first.structured_content == {"result": "Found 3 books matching 'dune'."}
100+
assert second.structured_content == {"result": "Found 3 books matching 'neuromancer'."}
101+
assert ("Bearer user-123-token", "trace-abc") in seen
102+
assert ("Bearer user-456-token", "trace-def") in seen

0 commit comments

Comments
 (0)