diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md index a60c997b42..3b6232cad8 100644 --- a/docs/advanced/apps.md +++ b/docs/advanced/apps.md @@ -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 diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index de7937fc75..b8372da3df 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -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. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 575ecb8ff9..5d49846b5f 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -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)**. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index 9f807a8e61..1549f39d9a 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -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. diff --git a/docs/client/caching.md b/docs/client/caching.md index 5d83cfa92a..544fe96a5f 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -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. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index 5f1dd1948a..4ebdd26d20 100644 --- a/docs/client/callbacks.md +++ b/docs/client/callbacks.md @@ -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. diff --git a/docs/client/index.md b/docs/client/index.md index 767f9eb06a..9433e703b0 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -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 +``` + +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: -* `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. +```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. diff --git a/docs/client/oauth-clients.md b/docs/client/oauth-clients.md index fe9f8111be..347d3dc91d 100644 --- a/docs/client/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -87,7 +87,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. diff --git a/docs/client/session-groups.md b/docs/client/session-groups.md index 70ac02e859..de43e74890 100644 --- a/docs/client/session-groups.md +++ b/docs/client/session-groups.md @@ -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. diff --git a/docs/client/transports.md b/docs/client/transports.md index afb33caf38..18a0585791 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -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" @@ -100,6 +85,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. @@ -108,15 +105,15 @@ 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. * 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. diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md index ad2f4c63fd..a5d8b4ffa4 100644 --- a/docs/get-started/first-steps.md +++ b/docs/get-started/first-steps.md @@ -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): - -```python -import asyncio - -from mcp import Client - -from server import mcp +Look at it yourself. Leave `server.py` running over HTTP in one terminal: +```console +uv run mcp run server.py --transport streamable-http +``` -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.model_dump(exclude_none=True)) +and point a client at it from another: +```python title="client.py" hl_lines="7-8" +--8<-- "docs_src/first_steps/tutorial001_client.py" +``` -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)**. diff --git a/docs/get-started/testing.md b/docs/get-started/testing.md index e45d13b470..98c671738f 100644 --- a/docs/get-started/testing.md +++ b/docs/get-started/testing.md @@ -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 diff --git a/docs/handlers/multi-round-trip.md b/docs/handlers/multi-round-trip.md index 1d5b9f52c6..c76069dbdc 100644 --- a/docs/handlers/multi-round-trip.md +++ b/docs/handlers/multi-round-trip.md @@ -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 diff --git a/docs/handlers/progress.md b/docs/handlers/progress.md index 57bbb59e03..d48b4c6ab1 100644 --- a/docs/handlers/progress.md +++ b/docs/handlers/progress.md @@ -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 diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 9ef19a7cf7..ab40f24ab9 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -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" ``` diff --git a/docs/run/asgi.md b/docs/run/asgi.md index 2eca9273cd..bbcb4ccbd1 100644 --- a/docs/run/asgi.md +++ b/docs/run/asgi.md @@ -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. diff --git a/docs/run/authorization.md b/docs/run/authorization.md index b7d731b1e2..7e2b5280b2 100644 --- a/docs/run/authorization.md +++ b/docs/run/authorization.md @@ -120,6 +120,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)**. diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md index 699632ad27..15809fd608 100644 --- a/docs/run/legacy-clients.md +++ b/docs/run/legacy-clients.md @@ -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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 838c92caf8..bb7dfda4f9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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") ``` diff --git a/docs/whats-new.md b/docs/whats-new.md index 068efb26ce..6627f794cd 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py index 27d9eded83..6cbefdff7a 100644 --- a/docs_src/apps/tutorial001.py +++ b/docs_src/apps/tutorial001.py @@ -1,6 +1,4 @@ -from mcp import Client -from mcp.client import advertise -from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps +from mcp.server.apps import Apps, client_supports_apps from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context @@ -30,10 +28,3 @@ def get_time(ctx: Context) -> str: apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock") mcp = MCPServer("clock", extensions=[apps]) - - -async def main() -> None: - async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client: - result = await client.call_tool("get_time", {}) - print(result.content) - # [TextContent(text='2026-06-26T12:00:00Z')] diff --git a/docs_src/apps/tutorial001_client.py b/docs_src/apps/tutorial001_client.py new file mode 100644 index 0000000000..dae52b4f0e --- /dev/null +++ b/docs_src/apps/tutorial001_client.py @@ -0,0 +1,20 @@ +import anyio + +from mcp import Client +from mcp.client import advertise +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID +from mcp.types import TextContent + +APPS_SUPPORT = advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[APPS_SUPPORT]) as client: + result = await client.call_tool("get_time", {}) + for block in result.content: + if isinstance(block, TextContent): + print(block.text) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/caching/tutorial002.py b/docs_src/caching/tutorial002.py index e1722e81f7..acd95f2d28 100644 --- a/docs_src/caching/tutorial002.py +++ b/docs_src/caching/tutorial002.py @@ -7,6 +7,7 @@ async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + print("tools/list served") return ListToolsResult(tools=TOOLS, ttl_ms=1_000) @@ -15,3 +16,4 @@ async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestPar on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, ) +app = server.streamable_http_app() diff --git a/docs_src/caching/tutorial003.py b/docs_src/caching/tutorial003.py index 9ff3c36101..f2495babe6 100644 --- a/docs_src/caching/tutorial003.py +++ b/docs_src/caching/tutorial003.py @@ -1,39 +1,34 @@ from dataclasses import dataclass -from typing import Any + +import anyio from mcp import Client from mcp.client import CacheConfig -from mcp.server import CacheHint, Server, ServerRequestContext -from mcp.types import ListToolsResult, PaginatedRequestParams, Tool +from mcp.types import ListToolsResult @dataclass -class DemoState: - fetches: int = 0 - now: float = 1_000_000.0 +class Clock: + now: float = 0.0 -state = DemoState() +clock = Clock() # advanced by hand below, so the TTL runs out without sleeping -async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: - state.fetches += 1 - return ListToolsResult(tools=[Tool(name="forecast", input_schema={"type": "object"})]) +async def run(client: Client) -> ListToolsResult: + tools = await client.list_tools() # fetch 1 + await client.list_tools() # still fresh: served from the cache + clock.now += 2 + await client.list_tools() # past the one-second TTL: fetch 2 + await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3 + return tools -server = Server( - "Weather", - on_list_tools=list_tools, - cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, -) +async def main() -> None: + async with Client("http://localhost:8000/mcp", cache=CacheConfig(clock=lambda: clock.now)) as client: + tools = await run(client) + print(tools.ttl_ms, tools.cache_scope) -async def main() -> None: - start = state.fetches - async with Client(server, cache=CacheConfig(clock=lambda: state.now)) as client: - await client.list_tools() # fetch 1 - await client.list_tools() # fresh for 60s: served from the cache - state.now += 60.0 - await client.list_tools() # the TTL ran out: fetch 2 - await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3 - print(f"4 calls, {state.fetches - start} fetches") +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial001.py b/docs_src/client/tutorial001.py index b020926dda..e7645509c7 100644 --- a/docs_src/client/tutorial001.py +++ b/docs_src/client/tutorial001.py @@ -1,18 +1,56 @@ -from mcp import Client +from pydantic import BaseModel + from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.") +GENRES = ["fiction", "non-fiction", "poetry"] -@mcp.tool() -def search_books(query: str) -> str: + +class Book(BaseModel): + title: str + author: str + year: int + + +@mcp.tool(title="Search the catalog") +def search_books(query: str, limit: int = 10) -> str: """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." + return f"Found 3 books matching {query!r} (showing up to {limit})." + + +@mcp.tool() +def lookup_book(title: str) -> Book: + """Look up a book by its exact title.""" + if title != "Dune": + raise ToolError(f"No book titled {title!r} in the catalog.") + return Book(title="Dune", author="Frank Herbert", year=1965) + + +@mcp.resource("catalog://genres") +def genres() -> list[str]: + """The genres the catalog is organised by.""" + return GENRES + + +@mcp.resource("catalog://genres/{genre}") +def books_in_genre(genre: str) -> str: + """Every title we stock in one genre.""" + return f"3 books filed under {genre}." + + +@mcp.prompt(title="Recommend a book") +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." -async def main() -> None: - async with Client(mcp) as client: - print(client.server_info) - print(client.server_capabilities) - print(client.protocol_version) - print(client.instructions) +@mcp.completion() +async def complete_genre( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)]) diff --git a/docs_src/client/tutorial001_client.py b/docs_src/client/tutorial001_client.py new file mode 100644 index 0000000000..faf06506a5 --- /dev/null +++ b/docs_src/client/tutorial001_client.py @@ -0,0 +1,15 @@ +import anyio + +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + print(client.server_info) + print(client.server_capabilities) + print(client.protocol_version) + print(client.instructions) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial002.py b/docs_src/client/tutorial002.py index a3e379ab44..68fc7a6b26 100644 --- a/docs_src/client/tutorial002.py +++ b/docs_src/client/tutorial002.py @@ -1,20 +1,17 @@ -from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - +import anyio -@mcp.tool(title="Search the catalog") -def search_books(query: str, limit: int = 10) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r} (showing up to {limit})." +from mcp import Client async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.list_tools() for tool in result.tools: print(tool.name) print(tool.title) print(tool.description) print(tool.input_schema) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial003.py b/docs_src/client/tutorial003.py index 0831f5f752..f104205a5a 100644 --- a/docs_src/client/tutorial003.py +++ b/docs_src/client/tutorial003.py @@ -1,29 +1,11 @@ -from pydantic import BaseModel +import anyio from mcp import Client -from mcp.server import MCPServer -from mcp.server.mcpserver.exceptions import ToolError from mcp.types import TextContent -mcp = MCPServer("Bookshop") - - -class Book(BaseModel): - title: str - author: str - year: int - - -@mcp.tool() -def lookup_book(title: str) -> Book: - """Look up a book by its exact title.""" - if title != "Dune": - raise ToolError(f"No book titled {title!r} in the catalog.") - return Book(title="Dune", author="Frank Herbert", year=1965) - async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool("lookup_book", {"title": "Dune"}) for block in result.content: @@ -32,3 +14,7 @@ async def main() -> None: print(result.structured_content) print(result.is_error) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial004.py b/docs_src/client/tutorial004.py index b0d62a7714..35acc3d0fb 100644 --- a/docs_src/client/tutorial004.py +++ b/docs_src/client/tutorial004.py @@ -1,24 +1,11 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer from mcp.types import TextResourceContents -mcp = MCPServer("Bookshop") - - -@mcp.resource("catalog://genres") -def genres() -> list[str]: - """The genres the catalog is organised by.""" - return ["fiction", "non-fiction", "poetry"] - - -@mcp.resource("catalog://genres/{genre}") -def books_in_genre(genre: str) -> str: - """Every title we stock in one genre.""" - return f"3 books filed under {genre}." - async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: listed = await client.list_resources() print([resource.uri for resource in listed.resources]) @@ -29,3 +16,7 @@ async def main() -> None: for contents in result.contents: if isinstance(contents, TextResourceContents): print(contents.text) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial005.py b/docs_src/client/tutorial005.py index ce4d164775..18e4d49835 100644 --- a/docs_src/client/tutorial005.py +++ b/docs_src/client/tutorial005.py @@ -1,20 +1,17 @@ -from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - +import anyio -@mcp.prompt(title="Recommend a book") -def recommend(genre: str) -> str: - """Ask for a recommendation in a genre.""" - return f"Recommend one {genre} book from the catalog and say why." +from mcp import Client async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: listed = await client.list_prompts() print(listed.prompts) result = await client.get_prompt("recommend", {"genre": "poetry"}) for message in result.messages: print(message.role, message.content) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial006.py b/docs_src/client/tutorial006.py index 370e0b79ef..5410e3b118 100644 --- a/docs_src/client/tutorial006.py +++ b/docs_src/client/tutorial006.py @@ -1,31 +1,17 @@ -from mcp import Client -from mcp.server import MCPServer -from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference - -mcp = MCPServer("Bookshop") - -GENRES = ["fiction", "non-fiction", "poetry"] - - -@mcp.prompt() -def recommend(genre: str) -> str: - """Ask for a recommendation in a genre.""" - return f"Recommend one {genre} book from the catalog and say why." +import anyio - -@mcp.completion() -async def complete_genre( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, -) -> Completion | None: - return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)]) +from mcp import Client +from mcp.types import PromptReference async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.complete( ref=PromptReference(type="ref/prompt", name="recommend"), argument={"name": "genre", "value": "p"}, ) print(result.completion.values) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/client/tutorial007.py b/docs_src/client/tutorial007.py index c5c918bc63..85580114a8 100644 --- a/docs_src/client/tutorial007.py +++ b/docs_src/client/tutorial007.py @@ -1,30 +1,25 @@ +import anyio + from mcp import Client -from mcp.server import MCPServer from mcp.types import Tool -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}." - -@mcp.tool() -def reserve_book(title: str) -> str: - """Put a book on hold.""" - return f"Reserved {title!r}." +async def list_all_tools(client: Client) -> list[Tool]: + tools: list[Tool] = [] + cursor: str | None = None + while True: + page = await client.list_tools(cursor=cursor) + tools.extend(page.tools) + if page.next_cursor is None: + return tools + cursor = page.next_cursor async def main() -> None: - async with Client(mcp) as client: - tools: list[Tool] = [] - cursor: str | None = None - while True: - page = await client.list_tools(cursor=cursor) - tools.extend(page.tools) - if page.next_cursor is None: - break - cursor = page.next_cursor + async with Client("http://localhost:8000/mcp") as client: + tools = await list_all_tools(client) print([tool.name for tool in tools]) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py index 312371bee4..882e5e73e5 100644 --- a/docs_src/extensions/tutorial003.py +++ b/docs_src/extensions/tutorial003.py @@ -1,7 +1,6 @@ from collections.abc import Sequence from typing import Any -from mcp import Client from mcp.server.extension import Extension, ToolBinding from mcp.server.mcpserver import MCPServer @@ -24,12 +23,3 @@ def tools(self) -> Sequence[ToolBinding]: mcp = MCPServer("post-office", extensions=[Stamps()]) - - -async def main() -> None: - async with Client(mcp) as client: - print(client.server_capabilities.extensions) - # {'com.example/stamps': {'sealed': True}} - result = await client.call_tool("stamp", {"text": "hello"}) - print(result.content) - # [TextContent(text='[stamped] hello')] diff --git a/docs_src/extensions/tutorial003_client.py b/docs_src/extensions/tutorial003_client.py new file mode 100644 index 0000000000..3d1b47b8cc --- /dev/null +++ b/docs_src/extensions/tutorial003_client.py @@ -0,0 +1,16 @@ +import anyio + +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + print(client.server_capabilities.extensions) + # {'com.example/stamps': {'sealed': True}} + result = await client.call_tool("stamp", {"text": "hello"}) + print(result.content) + # [TextContent(type='text', text='[stamped] hello', annotations=None, meta=None)] + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py index d3e2ef3bf8..01cb3d8e19 100644 --- a/docs_src/extensions/tutorial004.py +++ b/docs_src/extensions/tutorial004.py @@ -1,11 +1,9 @@ from collections.abc import Sequence -from typing import Any, Literal +from typing import Any from pydantic import Field import mcp.types as types -from mcp import Client -from mcp.client import advertise from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, MethodBinding from mcp.server.mcpserver import MCPServer, require_client_extension @@ -22,11 +20,6 @@ class SearchResult(types.Result): items: list[str] -class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): - method: Literal["com.example/search"] = "com.example/search" - params: SearchParams - - async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: require_client_extension(ctx, EXTENSION_ID) return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) @@ -49,11 +42,3 @@ def methods(self) -> Sequence[MethodBinding]: mcp = MCPServer("catalog", extensions=[Search()]) - - -async def main() -> None: - async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: - request = SearchRequest(params=SearchParams(query="mcp", limit=3)) - result = await client.session.send_request(request, SearchResult) - print(result.items) - # ['mcp-0', 'mcp-1', 'mcp-2'] diff --git a/docs_src/extensions/tutorial004_client.py b/docs_src/extensions/tutorial004_client.py new file mode 100644 index 0000000000..fdfcddc846 --- /dev/null +++ b/docs_src/extensions/tutorial004_client.py @@ -0,0 +1,35 @@ +from typing import Literal + +import anyio + +import mcp.types as types +from mcp import Client +from mcp.client import advertise + +EXTENSION_ID = "com.example/search" + + +class SearchParams(types.RequestParams): + query: str + limit: int = 10 + + +class SearchResult(types.Result): + items: list[str] + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[advertise(EXTENSION_ID)]) as client: + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(request, SearchResult) + print(result.items) + # ['mcp-0', 'mcp-1', 'mcp-2'] + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial006.py b/docs_src/extensions/tutorial006.py index 88592af99e..9d9f996682 100644 --- a/docs_src/extensions/tutorial006.py +++ b/docs_src/extensions/tutorial006.py @@ -1,9 +1,6 @@ -from collections.abc import Sequence -from typing import Any, Literal +from typing import Any import mcp.types as types -from mcp import Client -from mcp.client import ClaimContext, ClientExtension, ResultClaim from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.server.extension import Extension from mcp.server.mcpserver import MCPServer, require_client_extension @@ -11,13 +8,6 @@ EXTENSION_ID = "com.example/receipts" -class ReceiptResult(types.Result): - """The claimed result shape; `result_type` pins the wire tag.""" - - result_type: Literal["receipt"] = "receipt" - receipt_token: str - - class ReceiptIssuer(Extension): """Server half: answers `buy` with a receipt instead of a final result.""" @@ -35,18 +25,6 @@ async def intercept_tool_call( return {"resultType": "receipt", "receiptToken": "r-117"} -class Receipts(ClientExtension): - """Client half: claims the `receipt` shape and supplies the code that finishes it.""" - - identifier = EXTENSION_ID - - def claims(self) -> Sequence[ResultClaim[Any]]: - return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)] - - async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult: - return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token}) - - mcp = MCPServer("shop", extensions=[ReceiptIssuer()]) @@ -60,10 +38,3 @@ def buy(item: str) -> types.CallToolResult: def redeem(token: str) -> str: """Exchange a receipt token for the goods.""" return f"goods for {token}" - - -async def main() -> None: - async with Client(mcp, extensions=[Receipts()]) as client: - result = await client.call_tool("buy", {"item": "lamp"}) - print(result.content) - # [TextContent(text='goods for r-117')] diff --git a/docs_src/extensions/tutorial006_client.py b/docs_src/extensions/tutorial006_client.py new file mode 100644 index 0000000000..11c655ca72 --- /dev/null +++ b/docs_src/extensions/tutorial006_client.py @@ -0,0 +1,40 @@ +from collections.abc import Sequence +from typing import Any, Literal + +import anyio + +import mcp.types as types +from mcp import Client +from mcp.client import ClaimContext, ClientExtension, ResultClaim + +EXTENSION_ID = "com.example/receipts" + + +class ReceiptResult(types.Result): + """The claimed result shape; `result_type` pins the wire tag.""" + + result_type: Literal["receipt"] = "receipt" + receipt_token: str + + +class Receipts(ClientExtension): + """Client half: claims the `receipt` shape and supplies the code that finishes it.""" + + identifier = EXTENSION_ID + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)] + + async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult: + return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[Receipts()]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + print(result.content) + # [TextContent(type='text', text='goods for r-117', annotations=None, meta=None)] + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/extensions/tutorial007.py b/docs_src/extensions/tutorial007.py index 182fc8f61b..659c728fa2 100644 --- a/docs_src/extensions/tutorial007.py +++ b/docs_src/extensions/tutorial007.py @@ -1,9 +1,7 @@ from collections.abc import Sequence -from typing import Any, Literal +from typing import Any import mcp.types as types -from mcp import Client -from mcp.client import advertise from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, MethodBinding from mcp.server.mcpserver import MCPServer @@ -19,12 +17,6 @@ class JobStatus(types.Result): status: str -class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]): - method: Literal["com.example/jobs.status"] = "com.example/jobs.status" - params: JobParams - name_param = "jobId" # params["jobId"] rides the Mcp-Name header - - async def job_status(ctx: ServerRequestContext[Any, Any], params: JobParams) -> JobStatus: return JobStatus(status=f"{params.job_id} is running") @@ -39,11 +31,3 @@ def methods(self) -> Sequence[MethodBinding]: mcp = MCPServer("worker", extensions=[Jobs()]) - - -async def main() -> None: - async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: - request = JobStatusRequest(params=JobParams(job_id="job-7")) - result = await client.session.send_request(request, JobStatus) - print(result.status) - # job-7 is running diff --git a/docs_src/extensions/tutorial007_client.py b/docs_src/extensions/tutorial007_client.py new file mode 100644 index 0000000000..0f7d7961ac --- /dev/null +++ b/docs_src/extensions/tutorial007_client.py @@ -0,0 +1,35 @@ +from typing import Literal + +import anyio + +import mcp.types as types +from mcp import Client +from mcp.client import advertise + +EXTENSION_ID = "com.example/jobs" + + +class JobParams(types.RequestParams): + job_id: str + + +class JobStatus(types.Result): + status: str + + +class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]): + method: Literal["com.example/jobs.status"] = "com.example/jobs.status" + params: JobParams + name_param = "jobId" # params["jobId"] rides the Mcp-Name header + + +async def main() -> None: + async with Client("http://localhost:8000/mcp", extensions=[advertise(EXTENSION_ID)]) as client: + request = JobStatusRequest(params=JobParams(job_id="job-7")) + result = await client.session.send_request(request, JobStatus) + print(result.status) + # job-7 is running + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/first_steps/tutorial001_client.py b/docs_src/first_steps/tutorial001_client.py new file mode 100644 index 0000000000..f0ba92ff11 --- /dev/null +++ b/docs_src/first_steps/tutorial001_client.py @@ -0,0 +1,12 @@ +import anyio + +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/legacy_clients/tutorial001.py b/docs_src/legacy_clients/tutorial001.py index 2090201f91..998444de09 100644 --- a/docs_src/legacy_clients/tutorial001.py +++ b/docs_src/legacy_clients/tutorial001.py @@ -2,11 +2,8 @@ from pydantic import BaseModel -from mcp import Client -from mcp.client import ClientRequestContext from mcp.server import MCPServer from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve -from mcp.types import ElicitRequestParams, ElicitResult mcp = MCPServer("Bookshop") @@ -26,17 +23,3 @@ async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], R if isinstance(quantity, AcceptedElicitation): return f"Reserved {quantity.data.copies} of {title!r}." return "Nothing reserved." - - -async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: - return ElicitResult(action="accept", content={"copies": 2}) - - -async def main() -> None: - async with ( - Client(mcp, mode="legacy", elicitation_callback=answer) as legacy, - Client(mcp, elicitation_callback=answer) as modern, - ): - for client in (legacy, modern): - result = await client.call_tool("reserve", {"title": "Dune"}) - print(client.protocol_version, result.structured_content) diff --git a/docs_src/legacy_clients/tutorial001_client.py b/docs_src/legacy_clients/tutorial001_client.py new file mode 100644 index 0000000000..afe0981e81 --- /dev/null +++ b/docs_src/legacy_clients/tutorial001_client.py @@ -0,0 +1,23 @@ +import anyio + +from mcp import Client +from mcp.client import ClientRequestContext +from mcp.types import ElicitRequestParams, ElicitResult + + +async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"copies": 2}) + + +async def main() -> None: + async with ( + Client("http://localhost:8000/mcp", mode="legacy", elicitation_callback=answer) as legacy, + Client("http://localhost:8000/mcp", elicitation_callback=answer) as modern, + ): + for client in (legacy, modern): + result = await client.call_tool("reserve", {"title": "Dune"}) + print(client.protocol_version, result.structured_content) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/lowlevel/tutorial001.py b/docs_src/lowlevel/tutorial001.py index 3b96aa2af4..ac705a2e02 100644 --- a/docs_src/lowlevel/tutorial001.py +++ b/docs_src/lowlevel/tutorial001.py @@ -30,3 +30,4 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) +app = server.streamable_http_app() diff --git a/docs_src/pagination/tutorial001.py b/docs_src/pagination/tutorial001.py index 3bc97540f9..d1dc7c2ade 100644 --- a/docs_src/pagination/tutorial001.py +++ b/docs_src/pagination/tutorial001.py @@ -17,3 +17,4 @@ async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestPar server = Server("Bookshop", on_list_resources=list_books) +app = server.streamable_http_app() diff --git a/docs_src/pagination/tutorial002.py b/docs_src/pagination/tutorial002.py index f72847772a..61e40bc7dc 100644 --- a/docs_src/pagination/tutorial002.py +++ b/docs_src/pagination/tutorial002.py @@ -1,33 +1,26 @@ -from typing import Any +import anyio from mcp import Client -from mcp.server import Server, ServerRequestContext -from mcp.types import ListResourcesResult, PaginatedRequestParams, Resource +from mcp.types import Resource -BOOKS = [f"book-{n}" for n in range(1, 101)] -PAGE_SIZE = 10 - - -async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: - start = 0 if params is None or params.cursor is None else int(params.cursor) - end = start + PAGE_SIZE - page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] - next_cursor = str(end) if end < len(BOOKS) else None - return ListResourcesResult(resources=page, next_cursor=next_cursor) - - -server = Server("Bookshop", on_list_resources=list_books) +async def list_all_resources(client: Client) -> list[Resource]: + resources: list[Resource] = [] + cursor: str | None = None + while True: + page = await client.list_resources(cursor=cursor) + resources.extend(page.resources) + if page.next_cursor is None: + break + cursor = page.next_cursor + return resources async def main() -> None: - async with Client(server) as client: - resources: list[Resource] = [] - cursor: str | None = None - while True: - page = await client.list_resources(cursor=cursor) - resources.extend(page.resources) - if page.next_cursor is None: - break - cursor = page.next_cursor + async with Client("http://localhost:8000/mcp") as client: + resources = await list_all_resources(client) print(f"{len(resources)} resources") + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial001.py b/docs_src/protocol_versions/tutorial001.py index 23570e3bcf..1da11030f1 100644 --- a/docs_src/protocol_versions/tutorial001.py +++ b/docs_src/protocol_versions/tutorial001.py @@ -1,15 +1,12 @@ -from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - +import anyio -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." +from mcp import Client async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: print(client.protocol_version) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial002.py b/docs_src/protocol_versions/tutorial002.py index 5c00c8b4ee..d43ebb7b0b 100644 --- a/docs_src/protocol_versions/tutorial002.py +++ b/docs_src/protocol_versions/tutorial002.py @@ -1,15 +1,12 @@ -from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - +import anyio -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." +from mcp import Client async def main() -> None: - async with Client(mcp, mode="legacy") as client: + async with Client("http://localhost:8000/mcp", mode="legacy") as client: print(client.protocol_version) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial003.py b/docs_src/protocol_versions/tutorial003.py index 5fd32ac109..e5ca3ca472 100644 --- a/docs_src/protocol_versions/tutorial003.py +++ b/docs_src/protocol_versions/tutorial003.py @@ -1,15 +1,12 @@ -from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - +import anyio -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." +from mcp import Client async def main() -> None: - async with Client(mcp, mode="2026-07-28") as client: + async with Client("http://localhost:8000/mcp", mode="2026-07-28") as client: print(client.protocol_version) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py index dd0443b972..3608776955 100644 --- a/docs_src/protocol_versions/tutorial004.py +++ b/docs_src/protocol_versions/tutorial004.py @@ -1,20 +1,17 @@ -from mcp import Client -from mcp.server import MCPServer - -mcp = MCPServer("Bookshop") - +import anyio -@mcp.tool() -def search_books(query: str) -> str: - """Search the catalog by title or author.""" - return f"Found 3 books matching {query!r}." +from mcp import Client async def main() -> None: - async with Client(mcp) as client: + async with Client("http://localhost:8000/mcp") as client: saved = client.session.discover_result - async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: + async with Client("http://localhost:8000/mcp", mode="2026-07-28", prior_discover=saved) as client: print(client.protocol_version) if client.server_info is not None: print(client.server_info.name) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md index 9e809fad05..530f048a28 100644 --- a/examples/stories/bearer_auth/README.md +++ b/examples/stories/bearer_auth/README.md @@ -78,7 +78,7 @@ kill "$SERVER_PID" - `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin). If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's `resource` field will be wrong. -- Auth is HTTP-only; over stdio or the in-memory transport `get_access_token()` +- Auth is HTTP-only; over stdio (or the in-memory test transport) `get_access_token()` returns `None` and there is no gate. - The 401/403 status codes and `WWW-Authenticate` header are HTTP-level and `Client` cannot observe them; they are pinned by diff --git a/examples/stories/identity_assertion/README.md b/examples/stories/identity_assertion/README.md index 1002a5f757..68b986df99 100644 --- a/examples/stories/identity_assertion/README.md +++ b/examples/stories/identity_assertion/README.md @@ -87,7 +87,7 @@ kill "$SERVER_PID" `exp` has passed and expires tokens out of its own store. - `transport_security=NO_DNS_REBIND` is harness-only; drop it for a real deployment. -- Auth is HTTP-only; over stdio or the in-memory transport there is no gate. +- Auth is HTTP-only; over stdio (or the in-memory test transport) there is no gate. ## Spec diff --git a/examples/stories/pagination/README.md b/examples/stories/pagination/README.md index f7113d4cc5..19fd7f0daf 100644 --- a/examples/stories/pagination/README.md +++ b/examples/stories/pagination/README.md @@ -22,8 +22,8 @@ Drop `--server server_lowlevel` (on either transport) to run against the - `client.py` `main` — `async with Client(target, mode=mode) as client:` is the whole connection. The story owns the construction; `target` is whatever - `Client()` accepts (an in-process server, a transport, or an HTTP URL) and - the entry point picks it. + `Client()` accepts (an HTTP URL, `StdioServerParameters`, a transport, or, + in tests, an in-process server) and the entry point picks it. - `client.py` — `if page.next_cursor is None: break`. Termination is key-absent, not falsy; `while cursor:` would be a spec bug. - `server_lowlevel.py` — the handler owns the cursor encoding (here: an diff --git a/examples/stories/prompts/README.md b/examples/stories/prompts/README.md index 3bce94b995..7d184768bf 100644 --- a/examples/stories/prompts/README.md +++ b/examples/stories/prompts/README.md @@ -21,8 +21,9 @@ uv run python -m stories.prompts.client --http --server server_lowlevel ## What to look at - `client.py` `main` — the body opens with `async with Client(target, - mode=mode) as client:`; `target` is anything `Client(...)` accepts (an - in-process server, a `Transport`, or an HTTP URL). + mode=mode) as client:`; `target` is anything `Client(...)` accepts (an HTTP + URL, `StdioServerParameters`, a `Transport`, or, in tests, an in-process + server). - `server.py` `greet` vs `code_review` — return a bare `str` (wrapped as one user message) or a `list[Message]` for a multi-turn seed conversation. - `server.py` `complete()` — one global handler dispatches on `ref` + diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index a5d3d8f595..f07befd8dc 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -9,7 +9,7 @@ traffic and has `server_info` / `server_capabilities` available immediately. ## Run it ```bash -# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip. +# HTTP only: the point of this story is skipping a network round trip. # The client self-hosts the server on a free port, runs, then tears it down. uv run python -m stories.reconnect.client --http # same, against the lowlevel-API server variant diff --git a/examples/stories/resources/README.md b/examples/stories/resources/README.md index 10b210fe91..0436c033ed 100644 --- a/examples/stories/resources/README.md +++ b/examples/stories/resources/README.md @@ -21,8 +21,9 @@ uv run python -m stories.resources.client --http --server server_lowlevel - `client.py` `async with Client(target, mode=mode) as client:` — the one line every client example exists to teach. `target` is anything `Client()` - accepts (an in-process server, a transport, or an HTTP URL) and `mode=` is - always explicit; the rest of the story is the body of that `async with`. + accepts (an HTTP URL, `StdioServerParameters`, a transport, or, in tests, an + in-process server) and `mode=` is always explicit; the rest of the story is + the body of that `async with`. - `server.py` `app_config` vs `greeting` — a URI with no `{}` registers a static resource (appears in `resources/list`); a URI with `{name}` registers a template (appears only in `resources/templates/list`) and the placeholder diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md index 984f1595ba..62b3d0988c 100644 --- a/examples/stories/schema_validators/README.md +++ b/examples/stories/schema_validators/README.md @@ -20,8 +20,9 @@ uv run python -m stories.schema_validators.client --http --server server_lowleve ## What to look at - `client.py` `main` — the body opens with `async with Client(target, mode=mode) - as client:`. `target` is anything `Client` accepts (an in-process server, a - transport, or an HTTP URL); the entry point picks it, the story constructs it. + as client:`. `target` is anything `Client` accepts (an HTTP URL, + `StdioServerParameters`, a transport, or, in tests, an in-process server); + the entry point picks it, the story constructs it. - `server.py` — `who.name` vs `who["name"]`: pydantic and dataclass parameters arrive as **instances** (attribute access); TypedDict and `dict[str, Any]` arrive as plain dicts. diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 2f467c2614..f921c7e30b 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -268,17 +268,12 @@ class Client: Example: ```python - from mcp.client import Client - from mcp.server.mcpserver import MCPServer + import asyncio - server = MCPServer("test") - - @server.tool() - def add(a: int, b: int) -> int: - return a + b + from mcp import Client async def main(): - async with Client(server) as client: + async with Client("http://localhost:8000/mcp") as client: result = await client.call_tool("add", {"a": 1, "b": 2}) asyncio.run(main()) diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py index e91d12a3ea..b82b9c0387 100644 --- a/tests/docs_src/test_apps.py +++ b/tests/docs_src/test_apps.py @@ -5,9 +5,8 @@ import pytest from mcp_types import TextContent, TextResourceContents -from docs_src.apps import tutorial001, tutorial002, tutorial003 +from docs_src.apps import tutorial001, tutorial001_client, tutorial002, tutorial003 from mcp import Client -from mcp.client import advertise from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -33,11 +32,10 @@ async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None: async def test_one_tool_two_answers() -> None: - """tutorial001: the canonical degradation pattern: raw data for a client that - negotiated Apps, a human sentence for one that did not.""" - async with Client( - tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})] - ) as ui_client: + """tutorial001_client's `APPS_SUPPORT` declaration, driven in-process against + tutorial001's server: the client that negotiated Apps (with the required + `mimeTypes`) gets raw data, one that did not gets the human sentence.""" + async with Client(tutorial001.mcp, extensions=[tutorial001_client.APPS_SUPPORT]) as ui_client: rich = await ui_client.call_tool("get_time", {}) async with Client(tutorial001.mcp) as text_client: plain = await text_client.call_tool("get_time", {}) @@ -45,13 +43,6 @@ async def test_one_tool_two_answers() -> None: assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")] -async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial001: `main()` declares Apps support with the required `mimeTypes` and - receives the rich answer the page promises.""" - await tutorial001.main() - assert "2026-06-26T12:00:00Z" in capsys.readouterr().out - - async def test_capability_advertised_under_server_extensions() -> None: """tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`.""" async with Client(tutorial001.mcp) as client: diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index 751ca86121..9a697fa268 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -64,9 +64,13 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None: async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls( capsys: pytest.CaptureFixture[str], ) -> None: - """tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches.""" - await tutorial003.main() - assert capsys.readouterr().out == "4 calls, 3 fetches\n" + """tutorial003's four calls, driven in-process against tutorial002's server with the demo's own + clock: a cache hit, an expiry, and `cache_mode="refresh"` make them cost three fetches, each one + a line the server printed. The first result carries the handler's TTL and the map's scope.""" + async with Client(tutorial002.server, cache=CacheConfig(clock=lambda: tutorial003.clock.now)) as client: + tools = await tutorial003.run(client) + assert (tools.ttl_ms, tools.cache_scope) == (1_000, "public") + assert capsys.readouterr().out == "tools/list served\n" * 3 def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]: diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index d07ee4c9e4..7121e98d02 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -1,10 +1,14 @@ -"""`docs/client/index.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/client/index.md`: every claim the page makes, proved against the real SDK. + +`tutorial001` is the page's `server.py`. The other snippets are `client.py` programs that reach it +by URL, so each test drives the same calls in-process against `tutorial001.mcp` instead. +""" import pytest from inline_snapshot import snapshot from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool -from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007 +from docs_src.client import tutorial001, tutorial007 from mcp import Client, MCPDeprecationWarning, MCPError from mcp.shared.metadata_utils import get_display_name @@ -12,31 +16,23 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] -async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: - """Each `main()` is the literal client program shown on the page; all seven run clean in-memory.""" - await tutorial001.main() - await tutorial002.main() - await tutorial003.main() - await tutorial004.main() - await tutorial005.main() - await tutorial006.main() - await tutorial007.main() - assert "Bookshop" in capsys.readouterr().out - - async def test_connected_properties_are_populated_inside_the_block() -> None: - """tutorial001: server_info, server_capabilities, protocol_version and instructions are just there.""" + """tutorial001_client's four prints, in-process against server.py: server_info, capabilities, protocol_version + and instructions are just there, and a capability the server lacks is None.""" async with Client(tutorial001.mcp) as client: assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.protocol_version == "2026-07-28" assert client.instructions == "Search the catalog before recommending a book." assert client.server_capabilities.tools is not None + assert client.server_capabilities.resources is not None + assert client.server_capabilities.prompts is not None + assert client.server_capabilities.completions is not None assert client.server_capabilities.logging is None async def test_a_client_is_not_reusable_after_the_block_ends() -> None: - """tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection.""" + """The lifecycle bullet: `async with` is the whole lifecycle. Construct a new Client per connection.""" client = Client(tutorial001.mcp) async with client: assert client.server_info is not None @@ -46,13 +42,14 @@ async def test_a_client_is_not_reusable_after_the_block_ends() -> None: async def test_list_tools_returns_the_full_definition() -> None: - """tutorial002: each listed tool carries its name, title, description and the derived input schema.""" - async with Client(tutorial002.mcp) as client: - (tool,) = (await client.list_tools()).tools - assert tool.name == "search_books" - assert tool.title == "Search the catalog" - assert tool.description == "Search the catalog by title or author." - assert tool.input_schema == snapshot( + """tutorial002's listing, in-process: each tool carries its name, title, description and the derived + input schema, and a tool registered without `title=` lists with title None.""" + async with Client(tutorial001.mcp) as client: + (search, lookup) = (await client.list_tools()).tools + assert search.name == "search_books" + assert search.title == "Search the catalog" + assert search.description == "Search the catalog by title or author." + assert search.input_schema == snapshot( { "type": "object", "properties": { @@ -63,6 +60,8 @@ async def test_list_tools_returns_the_full_definition() -> None: "title": "search_booksArguments", } ) + assert lookup.name == "lookup_book" + assert lookup.title is None def test_get_display_name_prefers_the_title() -> None: @@ -74,8 +73,8 @@ def test_get_display_name_prefers_the_title() -> None: async def test_call_tool_result_has_three_things_to_read() -> None: - """tutorial003: content for the model, structured_content for code, is_error for both.""" - async with Client(tutorial003.mcp) as client: + """tutorial003's call, in-process: content for the model, structured_content for code, is_error for both.""" + async with Client(tutorial001.mcp) as client: result = await client.call_tool("lookup_book", {"title": "Dune"}) assert not result.is_error (block,) = result.content @@ -85,8 +84,8 @@ async def test_call_tool_result_has_three_things_to_read() -> None: async def test_a_raising_tool_is_a_result_not_an_exception() -> None: - """tutorial003 `!!! check`: the ToolError's message comes back in content with is_error=True.""" - async with Client(tutorial003.mcp) as client: + """The `!!! check`: the ToolError's message comes back in content with is_error=True.""" + async with Client(tutorial001.mcp) as client: result = await client.call_tool("lookup_book", {"title": "Solaris"}) assert result.is_error (block,) = result.content @@ -97,7 +96,7 @@ async def test_a_raising_tool_is_a_result_not_an_exception() -> None: async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None: """The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError.""" - async with Client(tutorial003.mcp) as client: + async with Client(tutorial001.mcp) as client: result = await client.call_tool("does_not_exist", {}) assert result.is_error (block,) = result.content @@ -107,8 +106,9 @@ async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None: async def test_resources_and_templates_are_two_separate_lists() -> None: - """tutorial004: concrete resources and parameterised templates come back from different verbs.""" - async with Client(tutorial004.mcp) as client: + """tutorial004's two listings, in-process: concrete resources and parameterised templates come back + from different verbs.""" + async with Client(tutorial001.mcp) as client: (resource,) = (await client.list_resources()).resources assert resource.uri == "catalog://genres" (template,) = (await client.list_resource_templates()).resource_templates @@ -116,8 +116,9 @@ async def test_resources_and_templates_are_two_separate_lists() -> None: async def test_read_resource_fills_in_a_template() -> None: - """tutorial004: read_resource takes a plain str URI; narrow the contents with isinstance.""" - async with Client(tutorial004.mcp) as client: + """tutorial004's read, in-process: read_resource takes a plain str URI, the server matches it to the + template, and the contents narrow with isinstance.""" + async with Client(tutorial001.mcp) as client: (contents,) = (await client.read_resource("catalog://genres/poetry")).contents assert isinstance(contents, TextResourceContents) assert contents.text == "3 books filed under poetry." @@ -126,7 +127,7 @@ async def test_read_resource_fills_in_a_template() -> None: async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> None: """The Resources section: at 2026-07-28 `resources.subscribe` is True (served via subscriptions/listen) while the legacy subscribe_resource verb answers -32601.""" - async with Client(tutorial004.mcp) as client: + async with Client(tutorial001.mcp) as client: assert client.server_capabilities.resources is not None assert client.server_capabilities.resources.subscribe is True with pytest.raises(MCPError) as exc_info: @@ -138,8 +139,8 @@ async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> N async def test_list_prompts_describes_the_arguments() -> None: - """tutorial005: a listed prompt carries its name, title and the arguments it needs.""" - async with Client(tutorial005.mcp) as client: + """tutorial005's listing, in-process: a listed prompt carries its name, title and the arguments it needs.""" + async with Client(tutorial001.mcp) as client: (prompt,) = (await client.list_prompts()).prompts assert prompt == snapshot( Prompt( @@ -152,8 +153,8 @@ async def test_list_prompts_describes_the_arguments() -> None: async def test_get_prompt_renders_the_messages() -> None: - """tutorial005: get_prompt returns the rendered messages a host hands to the model.""" - async with Client(tutorial005.mcp) as client: + """tutorial005's render, in-process: get_prompt returns the messages a host hands to the model.""" + async with Client(tutorial001.mcp) as client: result = await client.get_prompt("recommend", {"genre": "poetry"}) (message,) = result.messages assert message.role == "user" @@ -163,8 +164,9 @@ async def test_get_prompt_renders_the_messages() -> None: async def test_complete_suggests_values_for_an_argument() -> None: - """tutorial006: complete takes a ref and a name/value pair and returns the matching values.""" - async with Client(tutorial006.mcp) as client: + """tutorial006's call, in-process: complete takes a ref and a name/value pair and returns the matching + values.""" + async with Client(tutorial001.mcp) as client: result = await client.complete( ref=PromptReference(type="ref/prompt", name="recommend"), argument={"name": "genre", "value": "p"}, @@ -172,16 +174,17 @@ async def test_complete_suggests_values_for_an_argument() -> None: assert result.completion.values == ["poetry"] -async def test_a_single_page_server_ends_the_pagination_loop_immediately() -> None: - """tutorial007: every list_* takes cursor=; next_cursor is None when there is nothing left.""" - async with Client(tutorial007.mcp) as client: - page = await client.list_tools(cursor=None) - assert page.next_cursor is None - assert [tool.name for tool in page.tools] == ["search_books", "reserve_book"] +async def test_the_pagination_loop_collects_every_tool_from_a_single_page_server() -> None: + """tutorial007's `list_all_tools`, driven in-process against server.py: MCPServer answers in one page, + so the loop ends on the first `next_cursor is None` with every tool collected.""" + async with Client(tutorial001.mcp) as client: + tools = await tutorial007.list_all_tools(client) + assert [tool.name for tool in tools] == ["search_books", "lookup_book"] + assert (await client.list_tools(cursor=None)).next_cursor is None async def test_raise_exceptions_is_a_constructor_flag() -> None: """The `## In tests` section: `raise_exceptions=True` is accepted by the in-memory Client.""" async with Client(tutorial001.mcp, raise_exceptions=True) as client: result = await client.call_tool("search_books", {"query": "dune"}) - assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."} diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py index 2a141337b1..b0c7d2e032 100644 --- a/tests/docs_src/test_extensions.py +++ b/tests/docs_src/test_extensions.py @@ -11,9 +11,12 @@ tutorial002, tutorial003, tutorial004, + tutorial004_client, tutorial005, tutorial006, + tutorial006_client, tutorial007, + tutorial007_client, ) from mcp import Client, MCPError from mcp.client import advertise @@ -42,13 +45,15 @@ def test_a_prefixless_identifier_fails_at_class_definition() -> None: async def test_extension_settings_advertised_under_capabilities() -> None: - """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`.""" + """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`, + which is the first line tutorial003_client prints.""" async with Client(tutorial003.mcp) as client: assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}} async def test_contributed_tool_is_listed_and_callable() -> None: - """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable.""" + """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable, + with the content tutorial003_client prints.""" async with Client(tutorial003.mcp) as client: listed = await client.list_tools() assert [tool.name for tool in listed.tools] == ["stamp"] @@ -56,28 +61,23 @@ async def test_contributed_tool_is_listed_and_callable() -> None: assert result.content == [TextContent(type="text", text="[stamped] hello")] -async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial003: `main()` is the literal client program on the page; both printed - lines match the page's comments.""" - await tutorial003.main() - out = capsys.readouterr().out - assert "{'com.example/stamps': {'sealed': True}}" in out - assert "[stamped] hello" in out - - -async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial004: `main()` declares the extension and gets the vendor method's result.""" - await tutorial004.main() - assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out +async def test_declaring_client_gets_the_vendor_method_result() -> None: + """tutorial004_client's request against tutorial004's server, driven in-process: a client + that advertises the extension gets the vendor method's typed result, and the client's + own copy of the wire types agrees with the server's.""" + async with Client(tutorial004.mcp, extensions=[advertise(tutorial004_client.EXTENSION_ID)]) as client: + request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(request, tutorial004_client.SearchResult) + assert result.items == ["mcp-0", "mcp-1", "mcp-2"] async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None: """tutorial004: `require_client_extension` answers a non-declaring client with `-32021` and the machine-readable `requiredCapabilities` payload.""" async with Client(tutorial004.mcp) as client: - request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(request, tutorial004.SearchResult) + await client.session.send_request(request, tutorial004_client.SearchResult) assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}} @@ -85,10 +85,12 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None: """tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND at any other wire version; for a legacy client it doesn't exist.""" - async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client: - request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + async with Client( + tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004_client.EXTENSION_ID)] + ) as client: + request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(request, tutorial004.SearchResult) + await client.session.send_request(request, tutorial004_client.SearchResult) assert exc_info.value.code == METHOD_NOT_FOUND @@ -104,10 +106,12 @@ async def test_interceptor_observes_the_call_and_passes_the_result_through( assert messages == ["tool 'add' called"] -async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape.""" - await tutorial006.main() - assert "goods for r-117" in capsys.readouterr().out +async def test_declaring_client_receives_the_redeemed_result_not_the_claimed_shape() -> None: + """tutorial006_client's `Receipts` against tutorial006's server, driven in-process: + `call_tool("buy")` returns what the resolver redeemed, never the claimed receipt shape.""" + async with Client(tutorial006.mcp, extensions=[tutorial006_client.Receipts()]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + assert result.content == [TextContent(type="text", text="goods for r-117")] async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None: @@ -120,13 +124,17 @@ async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None: async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None: """The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result.""" - async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client: + async with Client(tutorial006.mcp, extensions=[tutorial006_client.Receipts()]) as client: result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True) - assert isinstance(result, tutorial006.ReceiptResult) + assert isinstance(result, tutorial006_client.ReceiptResult) assert result.receipt_token == "r-117" -async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration.""" - await tutorial007.main() - assert "job-7 is running" in capsys.readouterr().out +async def test_name_param_request_round_trips_with_no_client_registration() -> None: + """tutorial007_client's `JobStatusRequest` against tutorial007's server, driven in-process: + a vendor request declaring `name_param` round-trips `send_request` with no client-side + registration.""" + async with Client(tutorial007.mcp, extensions=[advertise(tutorial007_client.EXTENSION_ID)]) as client: + request = tutorial007_client.JobStatusRequest(params=tutorial007_client.JobParams(job_id="job-7")) + result = await client.session.send_request(request, tutorial007_client.JobStatus) + assert result.status == "job-7 is running" diff --git a/tests/docs_src/test_legacy_clients.py b/tests/docs_src/test_legacy_clients.py index 0b2f1e08ca..90daf1bd96 100644 --- a/tests/docs_src/test_legacy_clients.py +++ b/tests/docs_src/test_legacy_clients.py @@ -6,7 +6,7 @@ import pytest from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent -from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003 +from docs_src.legacy_clients import tutorial001, tutorial001_client, tutorial002, tutorial003 from mcp import Client, MCPError from mcp.client.streamable_http import streamable_http_client from mcp.server import MCPServer @@ -25,14 +25,21 @@ URL = "http://localhost:8000/mcp" -async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once( - capsys: pytest.CaptureFixture[str], -) -> None: - """tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer.""" - await tutorial001.main() - assert capsys.readouterr().out == ( - """2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n""" - ) +async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once() -> None: + """tutorial001_client's flow, driven in-process against tutorial001's server: two eras of client open at + once with the page's `answer` callback, one `Resolve` tool, and the two lines the page prints.""" + lines: list[tuple[str, object]] = [] + async with ( + Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial001_client.answer) as legacy, + Client(tutorial001.mcp, elicitation_callback=tutorial001_client.answer) as modern, + ): + for client in (legacy, modern): + result = await client.call_tool("reserve", {"title": "Dune"}) + lines.append((client.protocol_version, result.structured_content)) + assert lines == [ + ("2025-11-25", {"result": "Reserved 2 of 'Dune'."}), + ("2026-07-28", {"result": "Reserved 2 of 'Dune'."}), + ] async def test_neither_era_of_client_sees_the_resolved_parameter() -> None: @@ -114,13 +121,13 @@ async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_ httpx2.AsyncClient(transport=transport) as http, ): modern_target = streamable_http_client(URL, http_client=http) - async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern: + async with Client(modern_target, elicitation_callback=tutorial001_client.answer) as modern: assert modern.protocol_version == "2026-07-28" result = await modern.call_tool("reserve", {"title": "Dune"}) assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")] legacy_target = streamable_http_client(URL, http_client=http) - async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy: + async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001_client.answer) as legacy: assert legacy.protocol_version == "2025-11-25" with pytest.raises(MCPError) as exc_info: # pragma: no branch await legacy.call_tool("reserve", {"title": "Dune"}) diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py index 7d58e941d2..8a89aa014d 100644 --- a/tests/docs_src/test_lowlevel.py +++ b/tests/docs_src/test_lowlevel.py @@ -11,6 +11,7 @@ RequestParams, TextContent, ) +from starlette.routing import Route from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 from mcp import Client, MCPError @@ -45,6 +46,13 @@ async def test_the_client_does_not_care_which_server_class_it_connects_to() -> N assert result.structured_content is None +def test_the_last_line_is_an_asgi_app_uvicorn_can_serve() -> None: + """tutorial001: the Try-it's `uvicorn server:app` target is a Starlette app with one route at `/mcp`.""" + (route,) = tutorial001.app.routes + assert isinstance(route, Route) + assert route.path == "/mcp" + + async def test_only_the_handlers_you_passed_become_capabilities() -> None: """tutorial001: two tool handlers advertise `tools` and nothing else.""" async with Client(tutorial001.server) as client: diff --git a/tests/docs_src/test_pagination.py b/tests/docs_src/test_pagination.py index ab5949df96..e1c9eb9c27 100644 --- a/tests/docs_src/test_pagination.py +++ b/tests/docs_src/test_pagination.py @@ -1,7 +1,6 @@ """`docs/advanced/pagination.md`: every claim the page makes, proved against the real SDK.""" import pytest -from mcp_types import Resource from docs_src.pagination import tutorial001, tutorial002 from mcp import Client, MCPError @@ -48,27 +47,20 @@ async def test_the_last_page_carries_no_cursor() -> None: assert page.next_cursor is None -async def test_the_loop_collects_all_one_hundred() -> None: - """tutorial001: the `cursor=` loop visits ten pages and reassembles the whole catalog.""" +async def test_the_client_loop_collects_all_one_hundred_in_order() -> None: + """tutorial002's `list_all_resources()`, driven in-process against tutorial001's server: the `cursor=` loop + stitches the pages back into the whole catalog, in order, with no gaps and no repeats.""" async with Client(tutorial001.server) as client: - resources: list[Resource] = [] - cursor: str | None = None - pages = 0 - while True: - page = await client.list_resources(cursor=cursor) - resources.extend(page.resources) - pages += 1 - if page.next_cursor is None: - break - cursor = page.next_cursor - assert pages == 10 - assert len({resource.uri for resource in resources}) == 100 - - -async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial002: `main()` is the literal client program on the page and prints the stitched total.""" - await tutorial002.main() - assert capsys.readouterr().out == "100 resources\n" + resources = await tutorial002.list_all_resources(client) + assert [resource.name for resource in resources] == tutorial001.BOOKS + + +async def test_the_client_loop_runs_once_against_a_server_that_does_not_page() -> None: + """tutorial002's loop against the `MCPServer` above: `next_cursor` is `None` on the first response, so one + pass returns the whole catalog.""" + async with Client(mcp) as client: + resources = await tutorial002.list_all_resources(client) + assert [resource.name for resource in resources] == [f"book-{n}" for n in range(1, 101)] async def test_an_invented_cursor_is_an_error() -> None: diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py index a05577fbad..cfe0731523 100644 --- a/tests/docs_src/test_progress.py +++ b/tests/docs_src/test_progress.py @@ -43,7 +43,7 @@ async def show(progress: float, total: float | None, message: str | None) -> Non async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None: - """The `!!! info`: only the in-memory connection runs the callback inline. + """The `!!! info`: only the in-process connection runs the callback inline. On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so `call_tool` can return while a slow callback is still running. The callbacks below block on an diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py index 06b8a3a0c0..691b6cc242 100644 --- a/tests/docs_src/test_protocol_versions.py +++ b/tests/docs_src/test_protocol_versions.py @@ -1,11 +1,16 @@ -"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK. + +The page's snippets are URL clients for the Bookshop `server.py` from The Client page +(`docs_src/client/tutorial001.py`). These tests open the same connections in-process +against that server object, so each `mode=` is exercised without a port. +""" import re import pytest from mcp_types import SERVER_INFO_META_KEY, DiscoverResult, Implementation, ServerCapabilities -from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004 +from docs_src.client import tutorial001 as bookshop from mcp import Client # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -13,8 +18,9 @@ async def test_auto_lands_on_the_modern_version() -> None: - """tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result.""" - async with Client(tutorial001.mcp) as client: + """tutorial001's connection, in-process: the default `mode="auto"` probes `server/discover` + and adopts the result, so the page's `2026-07-28` output block is what it prints.""" + async with Client(bookshop.mcp) as client: assert client.protocol_version == "2026-07-28" assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -23,8 +29,9 @@ async def test_auto_lands_on_the_modern_version() -> None: async def test_legacy_forces_the_initialize_handshake() -> None: - """tutorial002: `mode="legacy"` runs `initialize` against the very same server.""" - async with Client(tutorial002.mcp, mode="legacy") as client: + """tutorial002's connection, in-process: `mode="legacy"` runs `initialize` against the very + same server and lands on the newest handshake-era version.""" + async with Client(bookshop.mcp, mode="legacy") as client: assert client.protocol_version == "2025-11-25" assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -33,14 +40,16 @@ async def test_legacy_forces_the_initialize_handshake() -> None: async def test_version_pin_sends_nothing_and_knows_nothing() -> None: - """tutorial003: a pin adopts the version locally; `server_info` is None and capabilities are blank.""" - async with Client(tutorial003.mcp, mode="2026-07-28") as client: + """tutorial003's connection, in-process: a pin adopts the version locally, so `server_info` + is None and every capability is blank, yet a tool call still round-trips.""" + async with Client(bookshop.mcp, mode="2026-07-28") as client: assert client.protocol_version == "2026-07-28" + assert client.session.initialize_result is None # The `!!! check` fence is the literal `print(client.server_info)` output: None. assert client.server_info is None assert client.server_capabilities == ServerCapabilities() result = await client.call_tool("search_books", {"query": "dune"}) - assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."} def test_handshake_era_version_is_not_a_valid_pin() -> None: @@ -52,18 +61,20 @@ def test_handshake_era_version_is_not_a_valid_pin() -> None: "got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')" ), ): - Client(tutorial003.mcp, mode="2025-06-18") + Client(bookshop.mcp, mode="2025-06-18") async def test_prior_discover_round_trips() -> None: - """tutorial004: save `discover_result`, reconnect with it, and the identity comes back.""" - async with Client(tutorial004.mcp) as client: + """tutorial004's flow, driven in-process against the Bookshop server: save `discover_result`, + reconnect pinned with it, and the identity comes back without any negotiation.""" + async with Client(bookshop.mcp) as client: saved = client.session.discover_result assert saved is not None assert saved.supported_versions == ["2026-07-28"] - async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client: + async with Client(bookshop.mcp, mode="2026-07-28", prior_discover=saved) as client: assert client.protocol_version == "2026-07-28" + assert client.session.discover_result is saved assert client.server_info is not None assert client.server_info.name == "Bookshop" assert client.server_capabilities.tools is not None @@ -71,14 +82,14 @@ async def test_prior_discover_round_trips() -> None: async def test_discover_result_survives_json() -> None: """`DiscoverResult` is a Pydantic model: dump it to JSON, validate it back, reconnect with it.""" - async with Client(tutorial004.mcp) as client: + async with Client(bookshop.mcp) as client: saved = client.session.discover_result assert saved is not None restored = DiscoverResult.model_validate_json(saved.model_dump_json()) assert restored == saved - async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client: + async with Client(bookshop.mcp, mode="2026-07-28", prior_discover=restored) as client: assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -94,9 +105,9 @@ async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None: ) }, ) - async with Client(tutorial004.mcp, prior_discover=stale) as client: + async with Client(bookshop.mcp, prior_discover=stale) as client: assert client.server_info is not None assert client.server_info.name == "Bookshop" - async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client: + async with Client(bookshop.mcp, mode="legacy", prior_discover=stale) as client: assert client.session.discover_result is None assert client.protocol_version == "2025-11-25"