From af7203471e5bf4dce8f9eb322a5ccd50a63b904c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:31:14 +0000 Subject: [PATCH 1/5] docs: stop presenting the in-memory client as the way to connect Client(server_object) connects in-process, which is a testing technique, but much of the documentation led with it: the client page's first example, the transports page's first section, several "what Client accepts" lists, and a number of feature pages whose snippets connect that way so they can run as-is. This reframes all of that around URL and stdio as the normal ways to connect. The client page now starts a small server over HTTP and connects to it by URL, then says once that the remaining snippets build their server inline the way a test would. The transports page leads with Streamable HTTP and stdio and moves the in-memory section down, scoped to tests and embedding. Enumerations put the server object last, "in tests". Feature pages whose snippets connect in-process get one sentence saying so instead of a rewrite, and fences that define a server are no longer titled client.py. The progress page, the prior_discover example and the low-level Try-it now use real connections, since their point depends on one. A few stale statements found along the way are corrected (the callbacks page's "first argument is a transport object", the testing page's "In-process by default" heading, the Client docstring example). --- docs/advanced/apps.md | 6 +++- docs/advanced/extensions.md | 15 ++++++--- docs/advanced/low-level-server.md | 18 ++++++---- docs/advanced/pagination.md | 4 +-- docs/client/caching.md | 6 ++-- docs/client/callbacks.md | 4 +-- docs/client/index.md | 30 ++++++++++++----- docs/client/oauth-clients.md | 2 +- docs/client/session-groups.md | 2 +- docs/client/transports.md | 33 +++++++++---------- docs/get-started/first-steps.md | 9 ++--- docs/get-started/testing.md | 6 ++-- docs/handlers/multi-round-trip.md | 2 +- docs/handlers/progress.md | 29 ++++++++-------- docs/protocol-versions.md | 19 ++++++----- docs/run/asgi.md | 2 +- docs/run/authorization.md | 2 +- docs/run/legacy-clients.md | 2 +- docs/troubleshooting.md | 2 +- docs/whats-new.md | 6 ++-- docs_src/client/tutorial001.py | 9 ----- docs_src/client/tutorial001_client.py | 15 +++++++++ docs_src/lowlevel/tutorial001.py | 1 + docs_src/protocol_versions/tutorial004.py | 13 ++------ examples/stories/bearer_auth/README.md | 2 +- examples/stories/identity_assertion/README.md | 2 +- examples/stories/pagination/README.md | 4 +-- examples/stories/prompts/README.md | 5 +-- examples/stories/reconnect/README.md | 2 +- examples/stories/resources/README.md | 5 +-- examples/stories/schema_validators/README.md | 5 +-- src/mcp/client/client.py | 11 ++----- tests/docs_src/test_client.py | 8 ++--- tests/docs_src/test_lowlevel.py | 8 +++++ tests/docs_src/test_progress.py | 2 +- tests/docs_src/test_protocol_versions.py | 17 +++++----- 36 files changed, 171 insertions(+), 137 deletions(-) create mode 100644 docs_src/client/tutorial001_client.py diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md index a60c997b42..c34a8ccd10 100644 --- a/docs/advanced/apps.md +++ b/docs/advanced/apps.md @@ -59,7 +59,11 @@ canonical pattern is one tool, two answers. Look at `get_time` again: `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. +client half of the negotiation, and the rich answer comes back. `main()` hands +`Client` the `mcp` object so the file runs as-is, the way a test does +([Testing](../get-started/testing.md)). In a real client that argument is a URL or +`StdioServerParameters`, and the `extensions=[...]` declaration stays exactly the +same. !!! 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..22dc8cbd72 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -69,12 +69,17 @@ 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`: +And `main()` is the proof, an in-memory client straight against `mcp`, the way a test +connects ([Testing](../get-started/testing.md)): ```python title="server.py" hl_lines="29-34" --8<-- "docs_src/extensions/tutorial003.py" ``` +Every `main()` on this page connects that way, so each file runs as-is. In your own +program the first argument to `Client` is a URL or `StdioServerParameters` and nothing +else changes. + ### Serving your own methods An extension can register **new request methods**: its own verbs, served next to the @@ -158,7 +163,7 @@ 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: -```python title="client.py" hl_lines="66-68" +```python hl_lines="66-68" --8<-- "docs_src/extensions/tutorial006.py" ``` @@ -180,7 +185,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("https://example.com/mcp", extensions=[advertise("com.example/search")]) ``` ## Writing a client extension @@ -188,7 +193,7 @@ 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" +```python hl_lines="17-18 43-44 46-47" --8<-- "docs_src/extensions/tutorial006.py" ``` @@ -231,7 +236,7 @@ as in [Serving your own methods](#serving-your-own-methods). One addition: when 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="22-25 46-47" +```python hl_lines="22-25 46-47" --8<-- "docs_src/extensions/tutorial007.py" ``` 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..8725c991c6 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -26,7 +26,7 @@ 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`. +In a test, `Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer` ([Testing](../get-started/testing.md)), and that is how the client loop below runs. In your own program you hand `Client` a URL or `StdioServerParameters` instead, and every call reads the same. Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`. @@ -38,7 +38,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 hl_lines="26-32" --8<-- "docs_src/pagination/tutorial002.py" ``` diff --git a/docs/client/caching.md b/docs/client/caching.md index 5d83cfa92a..7e89d2c225 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -37,9 +37,9 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on ## What the client sees -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. +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. The demo below hands `Client` the server object and an injected clock so it runs as-is, the way a test does ([Testing](../get-started/testing.md)). In your own program that first argument is a URL or `StdioServerParameters`, and the calls read the same. -```python title="client.py" hl_lines="33 35 38" +```python hl_lines="33 35 38" --8<-- "docs_src/caching/tutorial003.py" ``` @@ -51,7 +51,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..2e1dae5148 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 small one will do. 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 +``` + +The client is its own program: -* `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,13 +30,15 @@ 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)**. +The snippets below use the last form so that each one runs as-is: it builds its Bookshop server inline and hands it to `Client`, the way a test would. In your own program that argument is the URL or `StdioServerParameters` above. + ### What's on a connected client Four read-only properties, populated the moment you enter the block: @@ -197,13 +209,13 @@ This loop is correct against every server. `MCPServer` returns everything in one ## In tests -`Client(mcp)` with no process and no port is already a test harness for your server. +`Client(mcp)`, the form the snippets above use, is already a test harness for your server: no process, no port. -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..b00ead8cd1 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, and most snippets in these docs connect this way so they run as-is. + +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..345429f786 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. On this page you'll use it in memory to inspect the server you just wrote, which is also how you'll test it. ## The three primitives @@ -78,7 +78,7 @@ You saw three tabs in the Inspector. How did it know there were three? When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you. -Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port): +Look at it yourself. For a quick check like this, `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port): ```python import asyncio @@ -113,8 +113,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)**. + `Client(mcp)` is how you'll test your servers, and it gets a whole page: **[Testing](testing.md)**. + To connect to a server that is actually running, you hand `Client` a URL or a + `StdioServerParameters` instead: **[The Client](../client/index.md)**. ## What you did not write 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..c581469359 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -4,11 +4,11 @@ MCP has two eras. Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result. -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. +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. Most snippets here build a small Bookshop server inline and hand it to `Client`, the way a test does (**[Testing](get-started/testing.md)**), so they run as-is. `mode=` behaves identically when that argument is a URL or `StdioServerParameters`, and the reconnect example at the end connects by URL. ## `mode="auto"` -```python title="client.py" hl_lines="14-15" +```python hl_lines="14-15" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -26,13 +26,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 hl_lines="14" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -56,7 +57,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 hl_lines="14" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -87,9 +88,9 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. -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: +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. This snippet connects by URL rather than in-process, because a round trip is only worth saving when there is a network to cross. Run it against the `server.py` from **[The Client](client/index.md)** (`uv run mcp run server.py --transport streamable-http`): -```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..300b1b6b40 100644 --- a/docs/run/legacy-clients.md +++ b/docs/run/legacy-clients.md @@ -24,7 +24,7 @@ Here is a tool that has to ask the user something, and both eras of client calli `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`. +The two clients are open **at the same time**, on the same `mcp` object. They connect to it in-process here so the whole demonstration fits in one file, and clients arriving over HTTP or stdio get exactly the same treatment. `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`. ```text 2025-11-25 {'result': "Reserved 2 of 'Dune'."} diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 838c92caf8..6815279c6c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,7 +8,7 @@ Several entries run against this one server. One tool and one templated resource --8<-- "docs_src/troubleshooting/tutorial001.py" ``` -The errors this page quotes are real: the SDK's own test suite reproduces every one of them. +The errors this page quotes are real: the SDK's own test suite reproduces every one of them. Most client snippets below hand `Client` the server object itself, the way a test does ([Testing](get-started/testing.md)). In your own program that argument is a URL or `StdioServerParameters`, and every error reads the same. ## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` diff --git a/docs/whats-new.md b/docs/whats-new.md index 068efb26ce..40f49e2a30 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. diff --git a/docs_src/client/tutorial001.py b/docs_src/client/tutorial001.py index b020926dda..6d7abc0554 100644 --- a/docs_src/client/tutorial001.py +++ b/docs_src/client/tutorial001.py @@ -1,4 +1,3 @@ -from mcp import Client from mcp.server import MCPServer mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.") @@ -8,11 +7,3 @@ def search_books(query: str) -> str: """Search the catalog by title or author.""" return f"Found 3 books matching {query!r}." - - -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) 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/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/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py index dd0443b972..1f7bfc1c2b 100644 --- a/docs_src/protocol_versions/tutorial004.py +++ b/docs_src/protocol_versions/tutorial004.py @@ -1,20 +1,13 @@ from mcp import Client -from mcp.server import MCPServer -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}." +SERVER_URL = "http://localhost:8000/mcp" async def main() -> None: - async with Client(mcp) as client: + async with Client(SERVER_URL) as client: saved = client.session.discover_result - async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: + async with Client(SERVER_URL, 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) 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_client.py b/tests/docs_src/test_client.py index d07ee4c9e4..36ab2c08ee 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -13,19 +13,19 @@ 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() + """Each in-process `main()` is the literal client program shown on the page; all six run clean.""" await tutorial002.main() await tutorial003.main() await tutorial004.main() await tutorial005.main() await tutorial006.main() await tutorial007.main() - assert "Bookshop" in capsys.readouterr().out + assert "search_books" 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 is the page's `server.py`: connected to it, server_info, capabilities, protocol_version and + instructions are just there.""" async with Client(tutorial001.mcp) as client: assert client.server_info is not None assert client.server_info.name == "Bookshop" 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_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..6ea6dad2a1 100644 --- a/tests/docs_src/test_protocol_versions.py +++ b/tests/docs_src/test_protocol_versions.py @@ -5,7 +5,7 @@ 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.protocol_versions import tutorial001, tutorial002, tutorial003 from mcp import Client # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -56,13 +56,14 @@ def test_handshake_era_version_is_not_a_valid_pin() -> None: 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 tutorial001's server: save `discover_result`, + reconnect with it, and the identity comes back.""" + async with Client(tutorial001.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(tutorial001.mcp, mode="2026-07-28", prior_discover=saved) as client: assert client.protocol_version == "2026-07-28" assert client.server_info is not None assert client.server_info.name == "Bookshop" @@ -71,14 +72,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(tutorial001.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(tutorial001.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 +95,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(tutorial001.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(tutorial001.mcp, mode="legacy", prior_discover=stale) as client: assert client.session.discover_result is None assert client.protocol_version == "2025-11-25" From 1d358dbf7ff26ccd500beade255e3cc0c803a58f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:52:26 +0000 Subject: [PATCH 2/5] docs: give every client example a served server and a real connection The previous commit left most feature pages' snippets connecting to the server object in-process and explained that away with a sentence per page. That was a disclaimer, not a fix. This replaces it: each affected page now shows its server once as server.py with the command that serves it, and every client snippet is its own client.py that connects to http://localhost:8000/mcp. Where a client has logic worth testing (the pagination loop, the caching demo) it is a function the tests drive in-process against the server module, the pattern the subscriptions page already used. Pages: the client page (one Bookshop server, six clients), protocol versions (four clients against that same server), extensions and MCP Apps (server/client pairs), pagination and caching (uvicorn-served low-level servers, the caching handler prints each real fetch), serving legacy clients (both eras from one client program over HTTP), and the inline fragments on the troubleshooting page. The framing sentences are gone. --- docs/advanced/apps.md | 33 +++++-- docs/advanced/extensions.md | 63 +++++++----- docs/advanced/pagination.md | 12 ++- docs/client/caching.md | 20 +++- docs/client/index.md | 26 ++--- docs/client/transports.md | 2 +- docs/protocol-versions.md | 18 +++- docs/run/legacy-clients.md | 18 +++- docs/troubleshooting.md | 24 +++-- docs/whats-new.md | 8 +- docs_src/apps/tutorial001.py | 11 +-- docs_src/apps/tutorial001_client.py | 20 ++++ docs_src/caching/tutorial002.py | 2 + docs_src/caching/tutorial003.py | 43 ++++----- docs_src/client/tutorial001.py | 53 ++++++++++- docs_src/client/tutorial002.py | 17 ++-- docs_src/client/tutorial003.py | 26 ++--- docs_src/client/tutorial004.py | 23 ++--- docs_src/client/tutorial005.py | 17 ++-- docs_src/client/tutorial006.py | 30 ++---- docs_src/client/tutorial007.py | 39 ++++---- docs_src/extensions/tutorial003.py | 10 -- docs_src/extensions/tutorial003_client.py | 16 ++++ docs_src/extensions/tutorial004.py | 17 +--- docs_src/extensions/tutorial004_client.py | 35 +++++++ docs_src/extensions/tutorial006.py | 31 +----- docs_src/extensions/tutorial006_client.py | 40 ++++++++ docs_src/extensions/tutorial007.py | 18 +--- docs_src/extensions/tutorial007_client.py | 35 +++++++ docs_src/legacy_clients/tutorial001.py | 17 ---- docs_src/legacy_clients/tutorial001_client.py | 23 +++++ docs_src/pagination/tutorial001.py | 1 + docs_src/pagination/tutorial002.py | 43 ++++----- docs_src/protocol_versions/tutorial001.py | 17 ++-- docs_src/protocol_versions/tutorial002.py | 17 ++-- docs_src/protocol_versions/tutorial003.py | 17 ++-- docs_src/protocol_versions/tutorial004.py | 12 ++- tests/docs_src/test_apps.py | 19 +--- tests/docs_src/test_caching.py | 10 +- tests/docs_src/test_client.py | 95 ++++++++++--------- tests/docs_src/test_extensions.py | 68 +++++++------ tests/docs_src/test_legacy_clients.py | 29 +++--- tests/docs_src/test_pagination.py | 34 +++---- tests/docs_src/test_protocol_versions.py | 46 +++++---- 44 files changed, 655 insertions(+), 500 deletions(-) create mode 100644 docs_src/apps/tutorial001_client.py create mode 100644 docs_src/extensions/tutorial003_client.py create mode 100644 docs_src/extensions/tutorial004_client.py create mode 100644 docs_src/extensions/tutorial006_client.py create mode 100644 docs_src/extensions/tutorial007_client.py create mode 100644 docs_src/legacy_clients/tutorial001_client.py diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md index c34a8ccd10..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,19 +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. `main()` hands -`Client` the `mcp` object so the file runs as-is, the way a test does -([Testing](../get-started/testing.md)). In a real client that argument is a URL or -`StdioServerParameters`, and the `extensions=[...]` declaration stays exactly the -same. +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 22dc8cbd72..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,23 +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`, the way a test -connects ([Testing](../get-started/testing.md)): +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 `main()` on this page connects that way, so each file runs as-is. In your own -program the first argument to `Client` is a URL or `StdioServerParameters` and nothing -else changes. +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" ``` @@ -112,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 @@ -127,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` @@ -160,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 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 @@ -185,7 +197,7 @@ the capability, the client does nothing, as in the search client above), use ```python from mcp.client import advertise -client = Client("https://example.com/mcp", extensions=[advertise("com.example/search")]) +client = Client("http://localhost:8000/mcp", extensions=[advertise("com.example/search")]) ``` ## Writing a client extension @@ -193,8 +205,8 @@ client = Client("https://example.com/mcp", extensions=[advertise("com.example/se Subclass `ClientExtension` and override only what you need. Three contribution kinds, each with a default: `settings()`, `claims()`, and `notifications()`. -```python 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 @@ -232,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 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/pagination.md b/docs/advanced/pagination.md index 8725c991c6..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 -In a test, `Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer` ([Testing](../get-started/testing.md)), and that is how the client loop below runs. In your own program you hand `Client` a URL or `StdioServerParameters` instead, and every call reads the same. +`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 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 7e89d2c225..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" ``` @@ -37,12 +37,26 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on ## What the client sees -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. The demo below hands `Client` the server object and an injected clock so it runs as-is, the way a test does ([Testing](../get-started/testing.md)). In your own program that first argument is a URL or `StdioServerParameters`, and the calls read the same. +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 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. diff --git a/docs/client/index.md b/docs/client/index.md index 2e1dae5148..9433e703b0 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -6,7 +6,7 @@ It is one object with one lifecycle: construct it, enter `async with`, call meth ## Your first client -A client needs a server to talk to. This small one will do. Save it as `server.py` and leave it running over HTTP: +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" @@ -16,7 +16,7 @@ A client needs a server to talk to. This small one will do. Save it as `server.p uv run mcp run server.py --transport streamable-http ``` -The client is its own program: +That serves it at `http://localhost:8000/mcp`. The client is its own program. Save it as `client.py` and run `python client.py` in a second terminal: ```python title="client.py" hl_lines="7-11" --8<-- "docs_src/client/tutorial001_client.py" @@ -37,8 +37,6 @@ The client is its own program: 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)**. -The snippets below use the last form so that each one runs as-is: it builds its Bookshop server inline and hands it to `Client`, the way a test would. In your own program that argument is the URL or `StdioServerParameters` above. - ### What's on a connected client Four read-only properties, populated the moment you enter the block: @@ -56,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' @@ -84,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, @@ -93,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" ``` @@ -149,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" ``` @@ -163,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" ``` @@ -188,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" ``` @@ -201,15 +201,15 @@ 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)`, the form the snippets above use, is already a test harness for your server: no process, no port. +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-process connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it. diff --git a/docs/client/transports.md b/docs/client/transports.md index b00ead8cd1..18a0585791 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -93,7 +93,7 @@ In a test there is nothing to deploy and nothing to launch. Pass the server obje --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, and most snippets in these docs connect this way so they run as-is. +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. diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index c581469359..ab40f24ab9 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -4,11 +4,19 @@ MCP has two eras. Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result. -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. Most snippets here build a small Bookshop server inline and hand it to `Client`, the way a test does (**[Testing](get-started/testing.md)**), so they run as-is. `mode=` behaves identically when that argument is a URL or `StdioServerParameters`, and the reconnect example at the end connects by URL. +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 hl_lines="14-15" +```python title="client.py" hl_lines="7-8" --8<-- "docs_src/protocol_versions/tutorial001.py" ``` @@ -33,7 +41,7 @@ That is the whole feature. One `Client`, any era of server, no branching in your ## `mode="legacy"` -```python hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial002.py" ``` @@ -57,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 hl_lines="14" +```python title="client.py" hl_lines="7" --8<-- "docs_src/protocol_versions/tutorial003.py" ``` @@ -88,7 +96,7 @@ ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-0 The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. -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. This snippet connects by URL rather than in-process, because a round trip is only worth saving when there is a network to cross. Run it against the `server.py` from **[The Client](client/index.md)** (`uv run mcp run server.py --transport streamable-http`): +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="8 10" --8<-- "docs_src/protocol_versions/tutorial004.py" diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md index 300b1b6b40..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. They connect to it in-process here so the whole demonstration fits in one file, and clients arriving over HTTP or stdio get exactly the same treatment. `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 6815279c6c..14fb302e35 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -8,7 +8,13 @@ Several entries run against this one server. One tool and one templated resource --8<-- "docs_src/troubleshooting/tutorial001.py" ``` -The errors this page quotes are real: the SDK's own test suite reproduces every one of them. Most client snippets below hand `Client` the server object itself, the way a test does ([Testing](get-started/testing.md)). In your own program that argument is a URL or `StdioServerParameters`, and every error reads the same. +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() ``` @@ -284,7 +290,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 +315,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 +364,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 40f49e2a30..6627f794cd 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 6d7abc0554..e7645509c7 100644 --- a/docs_src/client/tutorial001.py +++ b/docs_src/client/tutorial001.py @@ -1,9 +1,56 @@ +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." + + +@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/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/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/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 1f7bfc1c2b..3608776955 100644 --- a/docs_src/protocol_versions/tutorial004.py +++ b/docs_src/protocol_versions/tutorial004.py @@ -1,13 +1,17 @@ -from mcp import Client +import anyio -SERVER_URL = "http://localhost:8000/mcp" +from mcp import Client async def main() -> None: - async with Client(SERVER_URL) as client: + async with Client("http://localhost:8000/mcp") as client: saved = client.session.discover_result - async with Client(SERVER_URL, 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/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 36ab2c08ee..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 in-process `main()` is the literal client program shown on the page; all six run clean.""" - await tutorial002.main() - await tutorial003.main() - await tutorial004.main() - await tutorial005.main() - await tutorial006.main() - await tutorial007.main() - assert "search_books" in capsys.readouterr().out - - async def test_connected_properties_are_populated_inside_the_block() -> None: - """tutorial001 is the page's `server.py`: connected to it, server_info, 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_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_protocol_versions.py b/tests/docs_src/test_protocol_versions.py index 6ea6dad2a1..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 +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,19 +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's flow, driven in-process against tutorial001's server: save `discover_result`, - reconnect with it, and the identity comes back.""" - async with Client(tutorial001.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(tutorial001.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 @@ -72,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(tutorial001.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(tutorial001.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" @@ -95,9 +105,9 @@ async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None: ) }, ) - async with Client(tutorial001.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(tutorial001.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" From 193b512b01df70d4e62c8ec79ef9073e2173e99f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:51:03 +0000 Subject: [PATCH 3/5] docs: name the server the -32021 troubleshooting fragment runs against No-Verification-Needed: doc-only wording change in docs/troubleshooting.md --- docs/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 14fb302e35..91ace5c091 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -286,7 +286,7 @@ An elicitation resolver refuses up front when the connected client did not decla } ``` -Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the capability declaration; there is no second switch: +The server here is the Bistro with the `book_table` resolver, the [`server.py` further down](#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests), served the same way. Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the capability declaration; there is no second switch: ```python async def main() -> None: From 26cf9c72c072fc2b75133030121ed41a5aa1cc61 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:19:35 +0000 Subject: [PATCH 4/5] docs: make the first-steps capabilities check a real client too The reader's first Client in the docs was still an inline Client(mcp) against the imported server object. Serve server.py over HTTP and connect by URL from a docs_src client file instead, like the rest of the docs now do, and move the in-memory mention into the pointer to Testing. --- docs/get-started/first-steps.md | 34 ++++++++++------------ docs_src/first_steps/tutorial001_client.py | 12 ++++++++ 2 files changed, 28 insertions(+), 18 deletions(-) create mode 100644 docs_src/first_steps/tutorial001_client.py diff --git a/docs/get-started/first-steps.md b/docs/get-started/first-steps.md index 345429f786..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`, the same class a host would use to reach a server by URL or launch it as a subprocess. On this page you'll use it in memory to inspect the server you just wrote, which is also how you'll test it. +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. For a quick check like this, `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,9 +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 how you'll test your servers, and it gets a whole page: **[Testing](testing.md)**. - To connect to a server that is actually running, you hand `Client` a URL or a - `StdioServerParameters` instead: **[The Client](../client/index.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 @@ -124,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. @@ -135,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_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) From 1d1d60bd0025adf0ffe0ae28f2a2b99998260da0 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:28:12 +0000 Subject: [PATCH 5/5] docs: show the resolver server in the -32021 troubleshooting entry The entry linked down to a section whose first server.py is the ctx.elicit variant, which produces a different error. Include the resolver-based Bistro directly so the entry has its own server. --- docs/troubleshooting.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 91ace5c091..bb7dfda4f9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -276,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 { @@ -286,7 +292,7 @@ An elicitation resolver refuses up front when the connected client did not decla } ``` -The server here is the Bistro with the `book_table` resolver, the [`server.py` further down](#mcperror-cannot-send-elicitationcreate-this-transport-context-has-no-back-channel-for-server-initiated-requests), served the same way. Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the capability declaration; there is no second switch: +Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the capability declaration; there is no second switch: ```python async def main() -> None: