docs: stop presenting the in-memory client as the way to connect (#3443)

This commit is contained in:
Max 2026-09-05 04:15:58 +01:00 • committed by GitHub
commit 7bb486a10f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 803 additions and 598 deletions

View file

@ -20,7 +20,7 @@ then come back.
## A clock with a face
```python title="server.py" hl_lines="19 22 30 32"
```python title="server.py" hl_lines="17 20 28 30"
--8<-- "docs_src/apps/tutorial001.py"
```
@ -51,15 +51,36 @@ The model reads `content`; the iframe is for humans. A UI-capable host still fee
the text result to the model, and a text-only client gets *only* that. So the
canonical pattern is one tool, two answers. Look at `get_time` again:
```python title="server.py" hl_lines="23-27"
```python title="server.py" hl_lines="21-25"
--8<-- "docs_src/apps/tutorial001.py"
```
`client_supports_apps(ctx)` is `True` only when the client declared the
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
in its `mimeTypes` settings. The field is required, so a client that omits it
does not count. That is exactly what `main()` in the same file declares: the
client half of the negotiation, and the rich answer comes back.
does not count. Here is the client half of the negotiation:
```python title="client.py" hl_lines="8 12"
--8<-- "docs_src/apps/tutorial001_client.py"
```
Serve `server.py` over HTTP, then run the client from a second terminal:
```console
uv run mcp run server.py --transport streamable-http
```
```console
python client.py
```
```text
2026-06-26T12:00:00Z
```
The rich answer came back. Drop `extensions=[APPS_SUPPORT]` from the `Client` call
and the same program prints `The time is 2026-06-26T12:00:00Z.` instead, which is
all a text-only client ever sees.
!!! warning
Never return a placeholder like `"[Rendered UI]"` as the only content. If the

View file

@ -57,7 +57,7 @@ specified by the MCP project itself.
The smallest useful extension is one tool and a settings map:
```python title="server.py" hl_lines="17 19-20 22-23 26"
```python title="server.py" hl_lines="16 18-19 21-22 25"
--8<-- "docs_src/extensions/tutorial003.py"
```
@ -69,18 +69,25 @@ The smallest useful extension is one tool and a settings map:
* The extension never receives the server. It declares contributions as data;
`MCPServer` consumes them. There is no `self.server` to mutate.
And `main()` is the proof, an in-memory client straight against `mcp`:
Serve it over HTTP, and a client is the proof:
```python title="server.py" hl_lines="29-34"
--8<-- "docs_src/extensions/tutorial003.py"
```console
uv run mcp run server.py --transport streamable-http
```
```python title="client.py" hl_lines="7-11"
--8<-- "docs_src/extensions/tutorial003_client.py"
```
Every `server.py` on this page is served with that command, and every `client.py`
runs beside it with `python client.py` from a second terminal.
### Serving your own methods
An extension can register **new request methods**: its own verbs, served next to the
spec's:
```python title="server.py" hl_lines="16-22 31 40-48"
```python title="server.py" hl_lines="14-20 24 33-41"
--8<-- "docs_src/extensions/tutorial004.py"
```
@ -107,10 +114,10 @@ runtime:
### The client side
The same file's `main()` is the whole client story, both halves of it:
The client is its own program, and it carries both halves of the client story:
```python title="server.py" hl_lines="54-58"
--8<-- "docs_src/extensions/tutorial004.py"
```python title="client.py" hl_lines="21-23 27-30"
--8<-- "docs_src/extensions/tutorial004_client.py"
```
* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The
@ -122,6 +129,9 @@ The same file's `main()` is the whole client story, both halves of it:
* Vendor methods drop one layer to `client.session.send_request(...)`; `Client`
only grows first-class methods for spec verbs. `send_request` accepts any
`Request` subclass, so the vendor request passes as-is.
* `SearchRequest` and the two models it carries are the extension's wire contract,
so the client declares them for itself. A published extension would ship them in
a package that both sides import.
### Intercepting `tools/call`
@ -155,13 +165,20 @@ The hook wraps `tools/call` and nothing else. For every-message concerns, use
## Using a client extension
A **client extension** is the same contract from the consuming side: a bundle of
client-side behaviour behind one identifier. Pass instances to
`Client(extensions=[...])` and call tools normally:
client-side behaviour behind one identifier. The server here answers `buy` with a
receipt to redeem instead of the goods, and only for a client that declared the
extension:
```python title="client.py" hl_lines="66-68"
```python title="server.py" hl_lines="22-25"
--8<-- "docs_src/extensions/tutorial006.py"
```
On the client, pass instances to `Client(extensions=[...])` and call tools normally:
```python title="client.py" hl_lines="33-35"
--8<-- "docs_src/extensions/tutorial006_client.py"
```
`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What
the extension changed: the server may now answer `buy` with a `receipt` **result
shape** instead of a final result, and `Receipts` finishes it (here by redeeming the
@ -180,7 +197,7 @@ the capability, the client does nothing, as in the search client above), use
```python
from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
client = Client("http://localhost:8000/mcp", extensions=[advertise("com.example/search")])
```
## Writing a client extension
@ -188,8 +205,8 @@ client = Client(mcp, extensions=[advertise("com.example/search")])
Subclass `ClientExtension` and override only what you need. Three contribution
kinds, each with a default: `settings()`, `claims()`, and `notifications()`.
```python title="client.py" hl_lines="17-18 43-44 46-47"
--8<-- "docs_src/extensions/tutorial006.py"
```python title="client.py" hl_lines="16-17 25-26 28-29"
--8<-- "docs_src/extensions/tutorial006_client.py"
```
* The identifier follows the same grammar as the server's, validated when the class
@ -227,14 +244,21 @@ claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`.
An extension's own request methods need no client-side registration. A vendor request
type subclasses `mcp.types.Request` and goes through `client.session.send_request`,
as in [Serving your own methods](#serving-your-own-methods). One addition: when a
params key must ride the `Mcp-Name` header (extension specs such as tasks require
this for their verbs), the request type declares `name_param`:
as in [Serving your own methods](#serving-your-own-methods). Take a server whose
extension serves one verb about a named job:
```python title="client.py" hl_lines="22-25 46-47"
```python title="server.py" hl_lines="12-13 30"
--8<-- "docs_src/extensions/tutorial007.py"
```
One addition on the client: when a params key must ride the `Mcp-Name` header
(extension specs such as tasks require this for their verbs), the request type
declares `name_param`:
```python title="client.py" hl_lines="20-23 28-29"
--8<-- "docs_src/extensions/tutorial007_client.py"
```
The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a
missing value fails loudly rather than silently omitting a required header.

View file

@ -31,18 +31,22 @@ Three things changed, and they are the whole low-level API:
### Try it
There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`:
`mcp dev` and `mcp run` only accept an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from it, and uvicorn runs that:
```python title="main.py"
```console
uvicorn server:app --port 8000
```
Point the Inspector, or any client, at `http://localhost:8000/mcp`:
```python title="client.py"
import asyncio
from mcp import Client
from server import server
async def main() -> None:
async with Client(server) as client:
async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
print(result.content)
@ -59,6 +63,8 @@ The same text the `@mcp.tool()` version produced. Two honest differences:
* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build.
* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there.
In a test you skip uvicorn and the port: `Client(server)` takes a low-level `Server` in-process exactly like it takes an `MCPServer`, and **[Testing](../get-started/testing.md)** is that pattern.
## Nothing is checked for you
`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**).
@ -210,4 +216,4 @@ Each of these is one idea you now have the vocabulary for; each has its own page
* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved.
* The capabilities a `Server` advertises are derived from which handlers you registered.
`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.
The client treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**.

View file

@ -26,9 +26,13 @@ Pagination is for the server whose resource list is really a database: thousands
### Try it
`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`.
`mcp run` only accepts an `MCPServer`, so you serve this one yourself. The last line of `server.py` builds an ordinary ASGI app from the `Server`, and uvicorn runs that:
Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
```console
uvicorn server:app --port 8000
```
Point any client (**[The Client](../client/index.md)**, or the Inspector) at `http://localhost:8000/mcp` and call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`.
Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`.
@ -38,7 +42,7 @@ The tenth page comes back with `next_cursor` set to `None`. Done.
Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`:
```python title="client.py" hl_lines="26-32"
```python title="client.py" hl_lines="9-15"
--8<-- "docs_src/pagination/tutorial002.py"
```
@ -46,7 +50,7 @@ Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resourc
* Extend **before** you look at `next_cursor`: the last page has resources too.
* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched.
Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
With uvicorn still serving `server.py`, run `python client.py` in a second terminal. It prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages.
This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -12,7 +12,7 @@ Three words you'll see on every page from here on:
* A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to.
* A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly.
You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later on this page.
You write the server. Hosts are someone else's product. The SDK also gives you a `Client`, the same class a host would use to reach a server by URL or launch it as a subprocess. It shows up later on this page, and it is also how you'll test your servers.
## The three primitives
@ -78,22 +78,20 @@ You saw three tabs in the Inspector. How did it know there were three?
When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you.
Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port):
Look at it yourself. Leave `server.py` running over HTTP in one terminal:
```python
import asyncio
```console
uv run mcp run server.py --transport streamable-http
```
from mcp import Client
and point a client at it from another:
from server import mcp
```python title="client.py" hl_lines="7-8"
--8<-- "docs_src/first_steps/tutorial001_client.py"
```
async def main() -> None:
async with Client(mcp) as client:
print(client.server_capabilities.model_dump(exclude_none=True))
asyncio.run(main())
```console
python client.py
```
```text
@ -113,8 +111,9 @@ That dictionary is your server's declared **capabilities**. It's the first thing
Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](../servers/completions.md)** proves it.
!!! info
`Client(mcp)` is the same in-memory client every example in these docs is tested with, and
it's how you'll test yours. It gets a whole page: **[Testing](testing.md)**.
That `client.py` is a complete MCP client, and **[The Client](../client/index.md)** is its page.
In a test you skip the terminal and the port and hand `Client` the server object itself,
`Client(mcp)`. That gets a whole page too: **[Testing](testing.md)**.
## What you did not write
@ -123,7 +122,7 @@ Look back over this page. You wrote three small Python functions. You did **not*
* A JSON Schema. `a: int, b: int` *is* the schema for `add`.
* A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you.
* A capability declaration. `MCPServer` made it for you.
* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it.
* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `client.py`, and you never saw it.
That ratio is the whole point of the SDK.
@ -134,6 +133,6 @@ That ratio is the whole point of the SDK.
* One decorator per primitive: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, description, and schema come from the function.
* A URI with a `{param}` makes a resource **template**, listed separately from concrete resources.
* The server's **capabilities** are declared for you, and a client only asks for what a server declares.
* `Client(mcp)` connects to the server object in memory: your test harness from day one.
* `Client("http://localhost:8000/mcp")` talks to your running server. Hand it the server object instead, `Client(mcp)`, and it is your test harness from day one.
Next up is **[Connect to a real host](real-host.md)**: this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**.

View file

@ -1,8 +1,8 @@
# Testing
The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly.
The SDK's `Client` class, the same one that connects to a URL or launches a subprocess, also connects **in memory**: pass it your server object and it talks to it directly.
No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`.
No subprocess. No port. Nothing on a wire. It's the same idea as FastAPI's `TestClient`.
## Basic usage
@ -91,7 +91,7 @@ instead of the sanitised one.
Leave it on in tests. It has no meaning in production code.
## In-process by default
## Era-neutral by default
!!! note
`Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and

View file

@ -159,7 +159,7 @@ The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is
## A 2026-07-28 result
`InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got.
`InputRequiredResult` only exists at protocol version **2026-07-28**. `Client`'s default `mode="auto"` discovers it on any connection. After connecting, `client.protocol_version` tells you what you got.
!!! warning
A pre-2026 session has nowhere to put an `InputRequiredResult`. Return one from your handler on a

View file

@ -24,19 +24,17 @@ Three arguments, and you decide what they mean:
The client opts in **per call**, by passing `progress_callback=` to `call_tool`:
```python title="client.py" hl_lines="7 16"
```python title="client.py" hl_lines="5 14"
import anyio
from mcp import Client
from server import mcp
async def show(progress: float, total: float | None, message: str | None) -> None:
print(f"{message} ({progress}/{total})")
async def main() -> None:
async with Client(mcp) as client:
async with Client("http://localhost:8000/mcp") as client:
result = await client.call_tool(
"import_catalog",
{"urls": ["https://example.com/a.json", "https://example.com/b.json"]},
@ -51,28 +49,31 @@ anyio.run(main)
The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`.
!!! info
`Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)**
page is built on. `progress_callback` is the same parameter whatever transport the `Client`
uses; the *timing* you are about to see is the in-memory connection's. It runs your callback
inline, so every report lands before `call_tool` returns. Over a real transport the
notifications race the result, and a slow callback can still be running after `call_tool` has
returned.
`progress_callback` is the same parameter whatever you handed `Client`: a URL as here, a
`StdioServerParameters`, or the server object in a test. Mind the timing over a real
transport, though. Each notification is delivered on its own, beside the response, so a slow
callback can still be running after `call_tool` has returned. Only the in-process test
connection runs the callback inline and guarantees every report lands first.
### Try it
Put `client.py` next to `server.py` and run it:
Serve `server.py` over HTTP, then run the client from a second terminal:
```console
uv run mcp run server.py --transport streamable-http
```
```console
python client.py
```
```text
Imported https://example.com/a.json (1/2)
Imported https://example.com/b.json (2/2)
Imported https://example.com/a.json (1.0/2.0)
Imported https://example.com/b.json (2.0/2.0)
{'result': 'Imported 2 records.'}
```
Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working.
Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order. Progress is not bundled into the result. It streams while the tool is still working.
!!! warning
`progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument

View file

@ -6,9 +6,17 @@ Servers released before 2026-07-28 open every connection with the **`initialize`
You almost never have to care, because `Client` negotiates for you. This page is about the one constructor argument that controls it, `mode=`, and the three times you change it.
Every snippet on this page is a `client.py` that talks to the Bookshop `server.py` from **[The Client](client/index.md)**. Start that server in one terminal:
```console
uv run mcp run server.py --transport streamable-http
```
Then run each snippet in a second terminal with `python client.py`.
## `mode="auto"`
```python title="client.py" hl_lines="14-15"
```python title="client.py" hl_lines="7-8"
--8<-- "docs_src/protocol_versions/tutorial001.py"
```
@ -26,13 +34,14 @@ Either way you come out connected, and `client.protocol_version` tells you which
That is the whole feature. One `Client`, any era of server, no branching in your code.
!!! info
`MCPServer` answers `server/discover` on every transport — in-memory, stdio, streamable
HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only
ever fires against a real pre-2026 server, which is exactly when you want it to.
`MCPServer` answers `server/discover` on every transport — Streamable HTTP, stdio, and the
in-process connection your tests use — so against your own server `auto` always lands on
`2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly
when you want it to.
## `mode="legacy"`
```python title="client.py" hl_lines="14"
```python title="client.py" hl_lines="7"
--8<-- "docs_src/protocol_versions/tutorial002.py"
```
@ -56,7 +65,7 @@ At 2026-07-28 it is gone. The server *returns* its questions and you retry the c
`mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`.
```python title="client.py" hl_lines="14"
```python title="client.py" hl_lines="7"
--8<-- "docs_src/protocol_versions/tutorial003.py"
```
@ -89,7 +98,7 @@ The probe is cheap, but it is still a round trip you pay on every reconnect, and
So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `instructions`, and the identity the server stamped into the result's `_meta`. Hand it back as `prior_discover=` the next time:
```python title="client.py" hl_lines="15 17"
```python title="client.py" hl_lines="8 10"
--8<-- "docs_src/protocol_versions/tutorial004.py"
```

View file

@ -137,4 +137,4 @@ A browser-based client needs two permissions from you: to **send** its MCP reque
* Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response.
* `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`.
Once the server is reachable at a real URL, **[The Client](../client/index.md)** connects to it with that URL instead of a server object.
Once the server is reachable at a real URL, **[The Client](../client/index.md)** connects to it with that URL.

View file

@ -124,6 +124,6 @@ An authorization server can also accept an enterprise identity provider's signed
* `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together.
* The SDK publishes [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story.
* `get_access_token()` in any handler is who's calling.
* Authorization is an HTTP concern. `stdio` and the in-memory client never see it.
* Authorization is an HTTP concern. `stdio` and the in-memory test client never see it.
The client half (discovering your authorization server and fetching the token for you) is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**.

View file

@ -16,15 +16,25 @@ So a legacy client is not something you build *for*. It is something that connec
## One handler, both eras
Here is a tool that has to ask the user something, and both eras of client calling it:
Here is a tool that has to ask the user something:
```python title="server.py" hl_lines="24 37-38"
```python title="server.py" hl_lines="21"
--8<-- "docs_src/legacy_clients/tutorial001.py"
```
`reserve` needs one thing the model didn't supply: how many copies. `Annotated[..., Resolve(ask_quantity)]` is how a tool declares that (**[Dependencies](../handlers/dependencies.md)** is that whole story). Nothing in `reserve` names a version, checks a capability, or branches.
The two clients are open **at the same time**, on the same `mcp` object. `mode="legacy"` runs the `initialize` handshake: the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`.
Serve it over HTTP, and here are both eras of client calling it:
```console
uv run mcp run server.py --transport streamable-http
```
```python title="client.py" hl_lines="14-15"
--8<-- "docs_src/legacy_clients/tutorial001_client.py"
```
The two clients are open **at the same time**, against the same running server. `mode="legacy"` runs the `initialize` handshake: the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`. Run `python client.py` from a second terminal:
```text
2025-11-25 {'result': "Reserved 2 of 'Dune'."}
@ -115,7 +125,7 @@ Two things about it matter more than what it does.
!!! check
Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
`stateless_http=True`, connect the same two clients over HTTP, and call it from each.
`stateless_http=True`, connect the same two clients, and call it from each.
The modern client still gets `Reserved 2 of 'Dune'.` The modern leg didn't change.

View file

@ -8,6 +8,12 @@ Several entries run against this one server. One tool and one templated resource
--8<-- "docs_src/troubleshooting/tutorial001.py"
```
Those entries reach it at `http://localhost:8000/mcp`, so leave it running over HTTP:
```console
uv run mcp run server.py --transport streamable-http
```
The errors this page quotes are real: the SDK's own test suite reproduces every one of them.
## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)`
@ -18,7 +24,7 @@ This is not an MCP error. It is anyio noise, and your real error is the **last l
```python
async def main() -> None:
async with Client(mcp) as client:
async with Client("http://localhost:8000/mcp") as client:
await client.read_resource("weather://Atlantis")
```
@ -44,7 +50,7 @@ Two things to do with that:
```python
async def main() -> None:
async with Client(mcp) as client:
async with Client("http://localhost:8000/mcp") as client:
try:
await client.read_resource("weather://Atlantis")
except MCPError as e:
@ -62,7 +68,7 @@ async def main() -> None:
```python
async def main() -> None:
client = Client(mcp)
client = Client("http://localhost:8000/mcp")
tools = await client.list_tools() # RuntimeError
```
@ -70,7 +76,7 @@ Enter it. `__aenter__` is the connection:
```python
async def main() -> None:
async with Client(mcp) as client:
async with Client("http://localhost:8000/mcp") as client:
tools = await client.list_tools()
```
@ -270,7 +276,13 @@ One thing does **not** produce this error, despite being a request the modern pr
Your server wants to ask the user something, and this client never said it can be asked.
An elicitation resolver refuses up front when the connected client did not declare form elicitation, and `e.error.data` names exactly what is missing:
This Bistro asks before it books, through a resolver:
```python title="server.py" hl_lines="15-17 21"
--8<-- "docs_src/troubleshooting/tutorial007.py"
```
Serve it in place of the Weather server and call `book_table` from a client that passed no `elicitation_callback`. The resolver refuses up front, because the connected client never declared form elicitation, and `e.error.data` names exactly what is missing:
```json
{
@ -284,7 +296,7 @@ Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the
```python
async def main() -> None:
async with Client(mcp, elicitation_callback=handle_elicitation) as client:
async with Client("http://localhost:8000/mcp", elicitation_callback=handle_elicitation) as client:
result = await client.call_tool("book_table", {"date": "Friday"})
```
@ -309,14 +321,14 @@ You see this one from `ctx.elicit()` on a legacy connection, and on any connecti
Your handler tried to reach the client mid-request, on a connection whose call has no channel that can carry a request from the server. There are three server configurations that put a call there.
**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this, usually in that tool's very first in-memory **[test](get-started/testing.md)**, since `Client(mcp)` negotiates `2026-07-28` without being asked. Passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
```python title="server.py" hl_lines="16"
--8<-- "docs_src/troubleshooting/tutorial006.py"
```
```python
async def main() -> None:
async def test_book_table() -> None:
async with Client(mcp) as client:
await client.call_tool("book_table", {"date": "Friday"})
```
@ -358,7 +370,7 @@ The server could not verify the `requestState` token your client echoed back, so
```python
async def main() -> None:
async with Client(mcp) as client:
async with Client("http://localhost:8000/mcp") as client:
await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
```

View file

@ -37,11 +37,11 @@ Not everything a tool needs should come from the model. New in v2, a tool parame
v1 handed you three nested layers: a transport context manager yielding raw streams, a `ClientSession` wrapped around them, and a hand-called `await session.initialize()`. v2 has one object:
```python title="client.py" hl_lines="14-18"
--8<-- "docs_src/client/tutorial001.py"
```python title="client.py" hl_lines="7-11"
--8<-- "docs_src/client/tutorial001_client.py"
```
`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), a `StdioServerParameters` (a stdio subprocess), or any other transport context manager such as `sse_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_capabilities` and `client.protocol_version` are simply there afterwards, and `client.server_info` is too when the server identifies itself (it is `Implementation | None` now, since 2026-era identity is optional). The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down.
`Client` takes a URL (Streamable HTTP), a `StdioServerParameters` (a stdio subprocess), any other transport context manager such as `sse_client(...)`, or, in tests, the server object itself (in memory, no transport). Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_capabilities` and `client.protocol_version` are simply there afterwards, and `client.server_info` is too when the server identifies itself (it is `Implementation | None` now, since 2026-era identity is optional). The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down.
**[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the four connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper.
@ -166,11 +166,15 @@ Every server-initiated request is gone at 2026-07-28: push elicitation, sampling
The replacement turns the call around. A tool that needs something from the user *returns* the question (`InputRequiredResult`), the client answers it with the same callbacks it always had, and the call is retried with the answers attached. `Client` drives that loop for you. On the server you rarely build the result yourself, because a **[dependency](handlers/dependencies.md)** does it: annotate a parameter with `Resolve(ask_quantity)`, where `ask_quantity` is an ordinary function you write, and the SDK asks over whichever mechanism the connection supports, a live elicitation request on a legacy session or a multi-round-trip on 2026. One tool body, both eras:
```python title="dual_era.py" hl_lines="24 37-38"
```python title="server.py" hl_lines="21"
--8<-- "docs_src/legacy_clients/tutorial001.py"
```
That file is the pitch in one place: one server, one `Resolve`-backed tool, and a legacy client plus a modern client both getting their answer, in memory. **[Multi-round-trip requests](handlers/multi-round-trip.md)** explains the mechanism (including `request_state`, which the SDK seals and verifies for you); **[Elicitation](handlers/elicitation.md)** covers the asking.
```python title="client.py" hl_lines="14-15"
--8<-- "docs_src/legacy_clients/tutorial001_client.py"
```
Those two files are the whole pitch: one server, one `Resolve`-backed tool, and a legacy client plus a modern client both getting their answer from the same running server (**[Serving legacy clients](run/legacy-clients.md)** walks through them). **[Multi-round-trip requests](handlers/multi-round-trip.md)** explains the mechanism (including `request_state`, which the SDK seals and verifies for you); **[Elicitation](handlers/elicitation.md)** covers the asking.
!!! warning "This is the one place a ported v1 server changes behavior"
Your own tests hit it first: `Client(mcp)` negotiates 2026-07-28 against your v2 server by

Some files were not shown because too many files have changed in this diff Show more