Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978)

This commit is contained in:
Max 2026-06-26 12:49:19 +02:00 • committed by GitHub
commit 411a6d3980
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 9510 additions and 2639 deletions

143
docs/client/callbacks.md Normal file
View 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
View 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**.

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