docs: stop presenting the in-memory client as the way to connect (#3443)
This commit is contained in:
parent
0c9136841f
commit
7bb486a10f
6 changed files with 803 additions and 598 deletions
|
|
@ -25,7 +25,7 @@ Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately
|
|||
|
||||
On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field:
|
||||
|
||||
```python title="server.py" hl_lines="10 16"
|
||||
```python title="server.py" hl_lines="11 17"
|
||||
--8<-- "docs_src/caching/tutorial002.py"
|
||||
```
|
||||
|
||||
|
|
@ -39,10 +39,24 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on
|
|||
|
||||
On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did.
|
||||
|
||||
```python title="client.py" hl_lines="33 35 38"
|
||||
To watch that happen, serve the `server.py` from the previous section with uvicorn (its last line builds the ASGI app). The handler prints a line every time it actually runs:
|
||||
|
||||
```console
|
||||
uvicorn server:app --port 8000
|
||||
```
|
||||
|
||||
```python title="client.py" hl_lines="20 23 28"
|
||||
--8<-- "docs_src/caching/tutorial003.py"
|
||||
```
|
||||
|
||||
Run `python client.py` from a second terminal. It prints the hints the first result carried, the handler's `ttlMs` next to the map's `cacheScope`:
|
||||
|
||||
```text
|
||||
1000 public
|
||||
```
|
||||
|
||||
The server's terminal tells the rest of the story: between uvicorn's request logs, `tools/list served` appears three times.
|
||||
|
||||
Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`):
|
||||
|
||||
* `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not.
|
||||
|
|
@ -51,7 +65,7 @@ Four calls, three fetches. The second call found a fresh entry and never reached
|
|||
|
||||
One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do.
|
||||
|
||||
To turn caching off entirely, construct with `Client(server, cache=None)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
|
||||
To turn caching off entirely, pass `cache=None` when constructing the `Client`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
|
||||
|
||||
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer
|
|||
`mode="legacy"` on the `Client(...)` call is doing real work. By default `Client(...)` negotiates the modern
|
||||
protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit`
|
||||
fails before your callback ever runs. The transport doesn't decide that; the negotiated
|
||||
protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has
|
||||
protocol does. Pin `mode="legacy"` whenever your client has
|
||||
to answer one; every test behind this page does. **[Protocol versions](../protocol-versions.md)** has the whole story.
|
||||
|
||||
On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an
|
||||
|
|
@ -146,4 +146,4 @@ Two more. Neither declares anything.
|
|||
* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead.
|
||||
* `logging_callback` and `message_handler` receive notifications. They declare nothing.
|
||||
|
||||
The first argument to `Client(...)` is a transport object. **[Client transports](transports.md)** covers every kind.
|
||||
The first argument to `Client(...)` picks the transport. **[Client transports](transports.md)** covers every kind.
|
||||
|
|
|
|||
|
|
@ -6,13 +6,23 @@ It is one object with one lifecycle: construct it, enter `async with`, call meth
|
|||
|
||||
## Your first client
|
||||
|
||||
```python title="client.py" hl_lines="14-18"
|
||||
A client needs a server to talk to. This Bookshop is the one every snippet on this page connects to. Save it as `server.py` and leave it running over HTTP:
|
||||
|
||||
```python title="server.py"
|
||||
--8<-- "docs_src/client/tutorial001.py"
|
||||
```
|
||||
|
||||
The server at the top is only there so you have something to connect to. The client is the five highlighted lines.
|
||||
```console
|
||||
uv run mcp run server.py --transport streamable-http
|
||||
```
|
||||
|
||||
* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example on this page, and every test you write, connects.
|
||||
That serves it at `http://localhost:8000/mcp`. The client is its own program. Save it as `client.py` and run `python client.py` in a second terminal:
|
||||
|
||||
```python title="client.py" hl_lines="7-11"
|
||||
--8<-- "docs_src/client/tutorial001_client.py"
|
||||
```
|
||||
|
||||
* `Client("http://localhost:8000/mcp")` is given a **URL**, so it connects over Streamable HTTP to the server you just started.
|
||||
* `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends.
|
||||
* Inside the block the connection facts are already there as plain properties.
|
||||
|
||||
|
|
@ -20,10 +30,10 @@ The server at the top is only there so you have something to connect to. The cli
|
|||
|
||||
`Client` takes one positional argument and resolves the transport from its type:
|
||||
|
||||
* An `MCPServer` (or low-level `Server`) instance: connected **in-process**.
|
||||
* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path.
|
||||
* A `StdioServerParameters`: the command to launch as a **subprocess**, spoken to over its stdin and stdout.
|
||||
* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the transport you deploy behind.
|
||||
* A `StdioServerParameters`: the command to launch as a local **subprocess**, spoken to over its stdin and stdout.
|
||||
* A **transport**: anything you can `async with ... as (read, write)`, such as `streamable_http_client(url, http_client=...)` around your own HTTP client.
|
||||
* An `MCPServer` (or low-level `Server`) instance: connected **in-process**, with no subprocess and no port. That one is for tests, and **[Testing](../get-started/testing.md)** builds on it.
|
||||
|
||||
Everything else on this page is identical across all four. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**.
|
||||
|
||||
|
|
@ -44,11 +54,11 @@ You never picked a protocol version. By default the `Client` probes the server a
|
|||
|
||||
## Listing tools
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
```python title="client.py" hl_lines="8-13"
|
||||
--8<-- "docs_src/client/tutorial002.py"
|
||||
```
|
||||
|
||||
`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model:
|
||||
`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model. Here is the first:
|
||||
|
||||
```python
|
||||
tool.name # 'search_books'
|
||||
|
|
@ -72,6 +82,8 @@ and `tool.input_schema` is the JSON Schema the server derived from the function'
|
|||
|
||||
That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments.
|
||||
|
||||
The second tool, `lookup_book`, was registered without a `title=`, so its `tool.title` is `None`.
|
||||
|
||||
!!! tip
|
||||
`title` is optional, so a UI showing tools to a human has to pick: the `title` if there is one,
|
||||
the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that,
|
||||
|
|
@ -81,7 +93,7 @@ That schema is everything a UI needs to render an argument form, and everything
|
|||
|
||||
`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`.
|
||||
|
||||
```python title="client.py" hl_lines="27-34"
|
||||
```python title="client.py" hl_lines="9-16"
|
||||
--8<-- "docs_src/client/tutorial003.py"
|
||||
```
|
||||
|
||||
|
|
@ -137,7 +149,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina
|
|||
|
||||
The resource verbs come in pairs: two ways to list, one way to read.
|
||||
|
||||
```python title="client.py" hl_lines="22-31"
|
||||
```python title="client.py" hl_lines="9-18"
|
||||
--8<-- "docs_src/client/tutorial004.py"
|
||||
```
|
||||
|
||||
|
|
@ -151,7 +163,7 @@ A client can also be told when a resource changes. On 2025-era connections that
|
|||
|
||||
## Prompts
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
```python title="client.py" hl_lines="8-13"
|
||||
--8<-- "docs_src/client/tutorial005.py"
|
||||
```
|
||||
|
||||
|
|
@ -176,7 +188,7 @@ A host hands those messages straight to the model. That is the whole feature.
|
|||
|
||||
A server with a completion handler can autocomplete prompt and resource-template arguments as the user types.
|
||||
|
||||
```python title="client.py" hl_lines="27-31"
|
||||
```python title="client.py" hl_lines="9-13"
|
||||
--8<-- "docs_src/client/tutorial006.py"
|
||||
```
|
||||
|
||||
|
|
@ -189,21 +201,21 @@ The answer is in `result.completion.values`. Type `"p"` and the server comes bac
|
|||
|
||||
Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything.
|
||||
|
||||
```python title="client.py" hl_lines="22-30"
|
||||
```python title="client.py" hl_lines="7-15"
|
||||
--8<-- "docs_src/client/tutorial007.py"
|
||||
```
|
||||
|
||||
This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**.
|
||||
`list_all_tools` is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**.
|
||||
|
||||
## In tests
|
||||
|
||||
`Client(mcp)` with no process and no port is already a test harness for your server.
|
||||
Every `client.py` on this page reached `server.py` over HTTP. In a test you skip the network and hand `Client` the server object itself: `from server import mcp`, then `Client(mcp)`. No process, no port, and every method above works the same.
|
||||
|
||||
There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it.
|
||||
There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-process connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it.
|
||||
|
||||
## Recap
|
||||
|
||||
* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport.
|
||||
* `Client(x)` connects over Streamable HTTP to a URL string, launches a subprocess for a `StdioServerParameters`, enters a transport directly, and in tests takes the server object itself.
|
||||
* `async with` is the whole lifecycle. Inside it, `server_capabilities` and `protocol_version` are already populated; `server_info` and `instructions` are too when the server provides them.
|
||||
* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`.
|
||||
* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception.
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ You wrote none of it. Two keyword arguments remain (`client_metadata_url` and `v
|
|||
|
||||
### Try it
|
||||
|
||||
Most examples in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server.
|
||||
The in-memory `Client(server)` your tests use is no help here: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server.
|
||||
|
||||
The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by.
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand
|
|||
## Recap
|
||||
|
||||
* `ClientSessionGroup` holds many server connections and merges their tools, resources, and prompts into one `dict` each.
|
||||
* `connect_to_server(params)` per server. It takes transport parameters, never the server object or URL a `Client` takes.
|
||||
* `connect_to_server(params)` per server. It takes transport parameters, never the URL or `Transport` a `Client` takes.
|
||||
* `group.call_tool(name, arguments)` routes to the owning server for you.
|
||||
* Names must be unique across the whole group; two servers with a `search` tool cannot coexist on their own.
|
||||
* `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not.
|
||||
|
|
|
|||
|
|
@ -6,24 +6,9 @@ You never configure one separately. `Client` takes a single positional argument
|
|||
|
||||
The *server* side of each (what `mcp.run()` does and what you deploy) is **[Running your server](../run/index.md)**.
|
||||
|
||||
## In memory
|
||||
|
||||
Pass the server object itself:
|
||||
|
||||
```python title="client.py" hl_lines="14"
|
||||
--8<-- "docs_src/client_transports/tutorial001.py"
|
||||
```
|
||||
|
||||
No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP.
|
||||
|
||||
That makes it two things at once:
|
||||
|
||||
* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** page builds the whole pattern around it.
|
||||
* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools.
|
||||
|
||||
## Streamable HTTP
|
||||
|
||||
Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind:
|
||||
Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind and the one to reach for first:
|
||||
|
||||
```python title="client.py" hl_lines="5"
|
||||
--8<-- "docs_src/client_transports/tutorial002.py"
|
||||
|
|
@ -121,6 +106,18 @@ The child's stderr goes to yours. To send it somewhere else, build the transport
|
|||
A server that needs an API key won't find it there. Pass it explicitly with `env=`; those
|
||||
variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above.
|
||||
|
||||
## In memory
|
||||
|
||||
In a test there is nothing to deploy and nothing to launch. Pass the server object itself:
|
||||
|
||||
```python hl_lines="14"
|
||||
--8<-- "docs_src/client_transports/tutorial001.py"
|
||||
```
|
||||
|
||||
No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP. **[Testing](../get-started/testing.md)** builds the whole pattern around it.
|
||||
|
||||
The same form doubles as an embedding API: an application that constructs the server itself can call its tools without a network hop.
|
||||
|
||||
## SSE
|
||||
|
||||
`sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it.
|
||||
|
|
@ -129,16 +126,16 @@ The child's stderr goes to yours. To send it somewhere else, build the transport
|
|||
|
||||
To `Client`, all of the above are the same thing.
|
||||
|
||||
A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, a `StdioServerParameters` becomes `stdio_client(params)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own.
|
||||
A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a `str` becomes `streamable_http_client(url)`, a `StdioServerParameters` becomes `stdio_client(params)`, a server object connects in-process, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own.
|
||||
|
||||
## Recap
|
||||
|
||||
* `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.
|
||||
* Redirects are followed only within the URL's own origin (a trailing-slash `307`/`308`), plus `http`→`https` on the same host. Anything else fails with `Redirect to … not followed`; configure the final URL.
|
||||
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
|
||||
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
|
||||
* `Client(mcp)` (the server object) connects in memory. Use it in tests, or to embed a server in the application that built it.
|
||||
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object, a URL or `StdioServerParameters` straight to that protocol.
|
||||
* Constructing a `Client` picks the transport. `async with` opens it.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue