Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978)
This commit is contained in:
parent
4caa41f6d5
commit
411a6d3980
4 changed files with 9510 additions and 2639 deletions
143
docs/client/callbacks.md
Normal file
143
docs/client/callbacks.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Client callbacks
|
||||
|
||||
So far every request has gone one way: client to server.
|
||||
|
||||
A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`.
|
||||
|
||||
## A server that asks
|
||||
|
||||
Here is a server whose tool can't finish on its own:
|
||||
|
||||
```python title="server.py" hl_lines="16"
|
||||
--8<-- "docs_src/client_callbacks/tutorial001.py"
|
||||
```
|
||||
|
||||
* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits.
|
||||
* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`.
|
||||
|
||||
That is the server half, and the **Elicitation** chapter owns it. This chapter is the other end of the wire.
|
||||
|
||||
## The elicitation callback
|
||||
|
||||
```python title="client.py" hl_lines="7-11 17-18"
|
||||
--8<-- "docs_src/client_callbacks/tutorial002.py"
|
||||
```
|
||||
|
||||
* An elicitation callback is `async (context, params) -> ElicitResult`.
|
||||
* `params.message` is the question. `params.requested_schema` is the JSON Schema of the answer the server wants. A real client renders a form from it; this one auto-fills.
|
||||
* You return `ElicitResult(action="accept", content={...})`, or `action="decline"`, or `action="cancel"`. The only other option is `ErrorData(...)`, which refuses the request and fails the whole call.
|
||||
* `context` is a `ClientRequestContext`: the live `session`, the server's `request_id`, and any `meta` it attached.
|
||||
|
||||
!!! tip
|
||||
`params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request
|
||||
carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`.
|
||||
**Elicitation** shows the full pattern.
|
||||
|
||||
### Try it
|
||||
|
||||
Call `issue_card` and watch both ends.
|
||||
|
||||
Your callback receives the server's question, already parsed:
|
||||
|
||||
```python
|
||||
params.mode # 'form'
|
||||
params.message # 'What name should go on the card?'
|
||||
params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}},
|
||||
# 'required': ['name'], 'title': 'CardHolder', 'type': 'object'}
|
||||
```
|
||||
|
||||
It answers, `ctx.elicit(...)` resumes inside the tool, and the tool finishes:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')]
|
||||
```
|
||||
|
||||
One `tools/call` from you, one `elicitation/create` back from the server, answered by your function, all inside a single tool call.
|
||||
|
||||
!!! info
|
||||
`mode="legacy"` on line 17 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
|
||||
to answer one; every test behind this page does. **Protocol versions** has the whole story.
|
||||
|
||||
## A callback is a capability
|
||||
|
||||
You never told the server that your client can answer elicitation requests. The SDK did.
|
||||
|
||||
When a client connects it declares its `capabilities`, the mirror image of the server's. You don't write that object. **Registering a callback is the declaration.**
|
||||
|
||||
| you pass | the client declares |
|
||||
| --- | --- |
|
||||
| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` |
|
||||
| `sampling_callback=` | `"sampling": {}` |
|
||||
| `list_roots_callback=` | `"roots": {"listChanged": true}` |
|
||||
| none of them | `{}` |
|
||||
|
||||
`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability.
|
||||
|
||||
The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does:
|
||||
|
||||
```python title="server.py" hl_lines="23-31"
|
||||
--8<-- "docs_src/client_callbacks/tutorial003.py"
|
||||
```
|
||||
|
||||
Connect with only `elicitation_callback` and call it:
|
||||
|
||||
```python
|
||||
result.structured_content # {'result': ['elicitation']}
|
||||
```
|
||||
|
||||
Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pass none and you get `[]`.
|
||||
|
||||
!!! check
|
||||
Now do the wrong thing: connect **without** `elicitation_callback` and call `issue_card` anyway.
|
||||
|
||||
The server's `elicitation/create` request still reaches your client, and the SDK answers it for
|
||||
you, with an error, because you never said you could handle it. That error sinks the whole call.
|
||||
`call_tool` doesn't return an `is_error` result; it raises:
|
||||
|
||||
```text
|
||||
MCPError: Elicitation not supported
|
||||
```
|
||||
|
||||
That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for
|
||||
the model to read and retry. It's why `client_features` is worth having: a well-behaved server
|
||||
checks before it asks.
|
||||
|
||||
## The deprecated pair
|
||||
|
||||
`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in.
|
||||
|
||||
Both work. Both follow the rule above. And both serve features the **2026-07-28 spec deprecates**: a modern server doesn't call back into your model mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**), and roots give way to plain tool arguments and resource URIs. The whole list is in **Deprecated features**.
|
||||
|
||||
You still need the callbacks to talk to servers that haven't moved. The signatures:
|
||||
|
||||
```python title="client.py"
|
||||
--8<-- "docs_src/client_callbacks/tutorial004.py"
|
||||
```
|
||||
|
||||
* A sampling callback receives the full `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) and returns a `CreateMessageResult`. *You* run the model, however you like; the SDK only carries the request.
|
||||
* A roots callback takes no params at all and returns a `ListRootsResult`.
|
||||
* Either one may return `ErrorData(...)` instead, to refuse.
|
||||
|
||||
Pass them to `Client(...)` exactly like `elicitation_callback`.
|
||||
|
||||
## The notification callbacks
|
||||
|
||||
Two more. Neither declares anything.
|
||||
|
||||
`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**Logging** has what to do instead), so this callback exists for the servers that still emit it.
|
||||
|
||||
`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing.
|
||||
|
||||
## Recap
|
||||
|
||||
* A server can send requests to the client. You answer them with callbacks passed to `Client(...)`.
|
||||
* The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode.
|
||||
* **Registering a callback is declaring the capability.** Without it, the SDK refuses the server's request on your behalf and the whole call fails with `MCPError`.
|
||||
* A server finds out before asking with `ctx.session.check_client_capability(...)`.
|
||||
* `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.
|
||||
|
||||
Next: the first argument you've been passing to `Client(...)` all along, **Client transports**.
|
||||
212
docs/client/index.md
Normal file
212
docs/client/index.md
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# The Client
|
||||
|
||||
A **`Client`** is how a Python program talks to an MCP server.
|
||||
|
||||
It is one object with one lifecycle: construct it, enter `async with`, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an `async` method on it that returns a typed result.
|
||||
|
||||
## Your first client
|
||||
|
||||
```python title="client.py" hl_lines="14-18"
|
||||
--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.
|
||||
|
||||
* `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 in this chapter, and every test you write, connects.
|
||||
* `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.
|
||||
|
||||
### What you can pass to `Client`
|
||||
|
||||
`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 **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess.
|
||||
|
||||
Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **Client transports**.
|
||||
|
||||
### What's on a connected client
|
||||
|
||||
Four read-only properties, populated the moment you enter the block:
|
||||
|
||||
* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports.
|
||||
* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`.
|
||||
* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`.
|
||||
* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one.
|
||||
|
||||
You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **Protocol versions** has the whole story.
|
||||
|
||||
!!! tip
|
||||
`client.session` is the underlying `ClientSession`, the low-level escape hatch.
|
||||
You won't need it for anything on this page.
|
||||
|
||||
## Listing tools
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--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:
|
||||
|
||||
```python
|
||||
tool.name # 'search_books'
|
||||
tool.title # 'Search the catalog'
|
||||
tool.description # 'Search the catalog by title or author.'
|
||||
```
|
||||
|
||||
and `tool.input_schema` is the JSON Schema the server derived from the function's type hints:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"default": 10, "title": "Limit", "type": "integer"}
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments"
|
||||
}
|
||||
```
|
||||
|
||||
That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments.
|
||||
|
||||
!!! 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,
|
||||
for tools, resources, resource templates and prompts.
|
||||
|
||||
## Calling a tool
|
||||
|
||||
`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`.
|
||||
|
||||
```python title="client.py" hl_lines="26-33"
|
||||
--8<-- "docs_src/client/tutorial003.py"
|
||||
```
|
||||
|
||||
The server's `lookup_book` returns a Pydantic `Book`. Here is what the client sees:
|
||||
|
||||
```python
|
||||
result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')]
|
||||
result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965}
|
||||
result.is_error # False
|
||||
```
|
||||
|
||||
One return value, three things to read. Each has a different consumer.
|
||||
|
||||
### `content`: what the model reads
|
||||
|
||||
`content` is a `list` of **content blocks**, and a content block is a union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, or `EmbeddedResource`. A tool can return several, of different kinds.
|
||||
|
||||
That is why `main` narrows with `isinstance(block, TextContent)` before touching `block.text`. Notice there is no `.text` outside the `isinstance`: the type checker won't allow it, because `ImageContent` has `.data`, not `.text`. The union is honest about what a tool is allowed to send you; your code should be too.
|
||||
|
||||
### `structured_content`: what your application reads
|
||||
|
||||
`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing.
|
||||
|
||||
When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **Structured Output** chapter.
|
||||
|
||||
### `is_error`: whether the tool failed
|
||||
|
||||
A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`.
|
||||
|
||||
!!! check
|
||||
Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises
|
||||
`ValueError`. The call still returns normally:
|
||||
|
||||
```python
|
||||
result.is_error # True
|
||||
result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")]
|
||||
result.structured_content # None
|
||||
```
|
||||
|
||||
The exception's message landed in `content`, where the **model** can read it and try again. That
|
||||
is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error`
|
||||
before you trust `structured_content`.
|
||||
|
||||
!!! warning
|
||||
`is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have
|
||||
(`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back,
|
||||
`is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises
|
||||
`MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and
|
||||
**Handling errors** covers when a server produces which.
|
||||
|
||||
## Resources
|
||||
|
||||
The resource verbs come in pairs: two ways to list, one way to read.
|
||||
|
||||
```python title="client.py" hl_lines="23-32"
|
||||
--8<-- "docs_src/client/tutorial004.py"
|
||||
```
|
||||
|
||||
* `list_resources()` returns the **concrete** resources, the ones with a fixed URI. Here: `['catalog://genres']`.
|
||||
* `list_resource_templates()` returns the **parameterised** ones. Here: `['catalog://genres/{genre}']`. They are two different lists because a template isn't readable until you fill it in.
|
||||
* `read_resource(uri)` takes a plain `str` URI and works on both: pass `"catalog://genres/poetry"` and the server matches it to the template.
|
||||
|
||||
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
|
||||
|
||||
A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**).
|
||||
|
||||
## Prompts
|
||||
|
||||
```python title="client.py" hl_lines="15-20"
|
||||
--8<-- "docs_src/client/tutorial005.py"
|
||||
```
|
||||
|
||||
`list_prompts()` tells you what the server offers and what each prompt needs:
|
||||
|
||||
```python
|
||||
prompt.name # 'recommend'
|
||||
prompt.title # 'Recommend a book'
|
||||
prompt.arguments # [PromptArgument(name='genre', required=True)]
|
||||
```
|
||||
|
||||
`get_prompt(name, arguments)` renders it. The arguments dict is `str -> str`: prompt arguments are always strings. The result is `messages`, a list of `PromptMessage`, each with a `role` and a `content` block:
|
||||
|
||||
```python
|
||||
message.role # 'user'
|
||||
message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.')
|
||||
```
|
||||
|
||||
A host hands those messages straight to the model. That is the whole feature.
|
||||
|
||||
## Completions
|
||||
|
||||
A server with a completion handler can autocomplete prompt and resource-template arguments as the user types.
|
||||
|
||||
```python title="client.py" hl_lines="28-32"
|
||||
--8<-- "docs_src/client/tutorial006.py"
|
||||
```
|
||||
|
||||
* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`.
|
||||
* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far.
|
||||
|
||||
The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **Completions** chapter.
|
||||
|
||||
## Pagination
|
||||
|
||||
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="23-31"
|
||||
--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**.
|
||||
|
||||
## In tests
|
||||
|
||||
`Client(mcp)` with no process and no port is already a test harness for your server.
|
||||
|
||||
There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **Testing** is the chapter 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.
|
||||
* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated.
|
||||
* `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.
|
||||
* `content` is a union of block types; narrow with `isinstance` before reading.
|
||||
* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs.
|
||||
* Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`.
|
||||
|
||||
Next: the things a server can ask the *client* for, and how you answer, in **Client callbacks**.
|
||||
127
docs/client/protocol-versions.md
Normal file
127
docs/client/protocol-versions.md
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# Protocol versions
|
||||
|
||||
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 haven't had to care, because `Client` negotiates for you. This chapter is about the one constructor argument that controls it, `mode=`, and the three times you change it.
|
||||
|
||||
## `mode="auto"`
|
||||
|
||||
```python title="client.py" hl_lines="14-15"
|
||||
--8<-- "docs_src/protocol_versions/tutorial001.py"
|
||||
```
|
||||
|
||||
You didn't pass `mode`, so you got the default: `"auto"`. Entering `async with` sends a single `server/discover` probe at the newest version this SDK speaks. Then:
|
||||
|
||||
* A **modern server** answers it. The client adopts the result. One round trip, done.
|
||||
* An **older server** has never heard of `server/discover` and returns an error. The client falls back to the classic `initialize` handshake and takes whatever that negotiates.
|
||||
|
||||
Either way you come out connected, and `client.protocol_version` tells you which it was:
|
||||
|
||||
```text
|
||||
2026-07-28
|
||||
```
|
||||
|
||||
That is the whole feature. One `Client`, any era of server, no branching in your code.
|
||||
|
||||
!!! info
|
||||
`MCPServer` answers `server/discover`, so against your own in-memory 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"
|
||||
--8<-- "docs_src/protocol_versions/tutorial002.py"
|
||||
```
|
||||
|
||||
`mode="legacy"` never probes. It runs the `initialize` handshake, the same connection a pre-2026 client opens.
|
||||
|
||||
```text
|
||||
2025-11-25
|
||||
```
|
||||
|
||||
Same server. It speaks `2026-07-28` perfectly well; you told the client not to ask.
|
||||
|
||||
You want this for the **push-style** features.
|
||||
|
||||
A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session.
|
||||
|
||||
At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**Multi-round-trip requests**).
|
||||
|
||||
`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **Client callbacks** goes through each.
|
||||
|
||||
## Pinning a version
|
||||
|
||||
`mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`.
|
||||
|
||||
```python title="client.py" hl_lines="14"
|
||||
--8<-- "docs_src/protocol_versions/tutorial003.py"
|
||||
```
|
||||
|
||||
A pin sends **nothing**. No probe, no handshake. The client adopts `2026-07-28` locally and the connection is live the instant `async with` returns.
|
||||
|
||||
A pin is a promise *you* make: you already know the server speaks that version. The client doesn't check.
|
||||
|
||||
!!! check
|
||||
A pin is not a discovery. Print `client.server_info` and the price is right there:
|
||||
|
||||
```text
|
||||
name='' title=None version='' description=None website_url=None icons=None
|
||||
```
|
||||
|
||||
The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities`
|
||||
is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it);
|
||||
code that reads `server_capabilities` to decide what to offer does not.
|
||||
|
||||
The next section is the fix.
|
||||
|
||||
Only modern versions are pinnable. A handshake-era string is rejected at construction, before any I/O, and the error tells you what to write instead:
|
||||
|
||||
```text
|
||||
ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')
|
||||
```
|
||||
|
||||
## Reconnecting with `prior_discover`
|
||||
|
||||
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 `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time:
|
||||
|
||||
```python title="client.py" hl_lines="15 17"
|
||||
--8<-- "docs_src/protocol_versions/tutorial004.py"
|
||||
```
|
||||
|
||||
```text
|
||||
2026-07-28
|
||||
Bookshop
|
||||
```
|
||||
|
||||
The second connection made **zero** negotiation round trips and still knows exactly who it is talking to. That is the pinned mode done properly: `mode=` names the version, `prior_discover=` supplies the identity. ✨
|
||||
|
||||
`DiscoverResult` is a Pydantic model. `saved.model_dump_json()` goes into a file or a cache; `DiscoverResult.model_validate_json(...)` brings it back in the next process.
|
||||
|
||||
!!! tip
|
||||
`prior_discover=` only does anything when `mode` is a version pin. Under `"auto"` the client
|
||||
probes the server anyway, and under `"legacy"` it is ignored.
|
||||
|
||||
## The four modes
|
||||
|
||||
| You write | Negotiation traffic | You get |
|
||||
| --- | --- | --- |
|
||||
| `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era |
|
||||
| `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work |
|
||||
| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` |
|
||||
| `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time |
|
||||
|
||||
## Recap
|
||||
|
||||
* MCP has a handshake era (up to `2025-11-25`, the `initialize` handshake) and a modern era (`2026-07-28`, `server/discover`). `Client` bridges them.
|
||||
* `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you.
|
||||
* `client.protocol_version` is always the answer to "what did I get?".
|
||||
* `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`.
|
||||
* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`.
|
||||
* `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both.
|
||||
|
||||
A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **Multi-round-trip requests**.
|
||||
115
docs/client/transports.md
Normal file
115
docs/client/transports.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Client transports
|
||||
|
||||
Every `Client` talks to its server over a **transport**: the thing that actually carries the messages.
|
||||
|
||||
You never configure one separately. `Client` takes a single positional argument and works the transport out from its type.
|
||||
|
||||
The *server* side of each (what `mcp.run()` does and what you deploy) is **Running your server**.
|
||||
|
||||
## 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** chapter 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:
|
||||
|
||||
```python title="client.py" hl_lines="5"
|
||||
--8<-- "docs_src/client_transports/tutorial002.py"
|
||||
```
|
||||
|
||||
That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open.
|
||||
|
||||
!!! check
|
||||
A `Client` you have constructed is **not** connected. Construction only picks the transport;
|
||||
`async with` is what opens it. Reach for the connection before entering and the SDK tells you so:
|
||||
|
||||
```text
|
||||
RuntimeError: Client must be used within an async context manager
|
||||
```
|
||||
|
||||
Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free.
|
||||
|
||||
### Bring your own `httpx.AsyncClient`
|
||||
|
||||
The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`:
|
||||
|
||||
```python title="client.py" hl_lines="8-14"
|
||||
--8<-- "docs_src/client_transports/tutorial003.py"
|
||||
```
|
||||
|
||||
Two things to notice:
|
||||
|
||||
* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create.
|
||||
* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else.
|
||||
|
||||
!!! warning
|
||||
`streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more:
|
||||
its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out
|
||||
of habit and you get:
|
||||
|
||||
```text
|
||||
TypeError: streamable_http_client() got an unexpected keyword argument 'headers'
|
||||
```
|
||||
|
||||
Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in.
|
||||
|
||||
!!! info
|
||||
If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection
|
||||
limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in:
|
||||
`httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **OAuth clients**.
|
||||
|
||||
## stdio
|
||||
|
||||
A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine.
|
||||
|
||||
Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`:
|
||||
|
||||
```python title="client.py" hl_lines="4-8 12"
|
||||
--8<-- "docs_src/client_transports/tutorial004.py"
|
||||
```
|
||||
|
||||
`Client` does not accept the parameters object on its own. `StdioServerParameters` is configuration; `stdio_client(server)` is the transport that knows how to spawn a process from it. Always wrap.
|
||||
|
||||
Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.
|
||||
|
||||
!!! warning
|
||||
The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`,
|
||||
`PATH`, `SHELL`, `TERM` and `USER` on POSIX) so nothing sensitive leaks into a process you may
|
||||
not have written.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## The `Transport` protocol
|
||||
|
||||
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)`, 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 `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
|
||||
* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
|
||||
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
|
||||
* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol.
|
||||
* Constructing a `Client` picks the transport. `async with` opens it.
|
||||
|
||||
Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **Protocol versions** is the page.
|
||||
Loading…
Reference in a new issue