Skip to content
Open
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
26 changes: 26 additions & 0 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions docs_src/client_transports/tutorial005.py
Original file line number Diff line number Diff line change
@@ -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)
45 changes: 44 additions & 1 deletion tests/docs_src/test_client_transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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, str | None, str | None]] = []

async def record_headers(request: httpx2.Request) -> None:
await tutorial005.inject_request_headers(request)
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(): # pragma: no branch
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'."}
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
Loading