Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/client/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ The server at the top is only there so you have something to connect to. The cli

* 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.
* A `StdioServerParameters`: the command to launch as a **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.

Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**.
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)**.

### What's on a connected client

Expand Down
14 changes: 7 additions & 7 deletions docs/client/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,15 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A

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: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file.

Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`:
Describe the process with `StdioServerParameters` and hand it to `Client`:

```python title="client.py" hl_lines="4-8 12"
```python title="client.py" hl_lines="3-7 11"
--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.
Entering the block spawns the process. Leaving it shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.

Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.
The child's stderr goes to yours. To send it somewhere else, build the transport yourself with `stdio_client` (from `mcp`) and pass that instead: `Client(stdio_client(server, errlog=log_file))`.

!!! warning
The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`,
Expand All @@ -108,16 +108,16 @@ Leaving the `async with` block also shuts the subprocess down: close stdin, wait

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

## 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(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
* 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.
* 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.
* 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.

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](../protocol-versions.md)** is the page.
2 changes: 1 addition & 1 deletion docs/get-started/real-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ It is also the command `mcp install` writes into Claude Desktop's config for you

And a host is nothing more than an application with an MCP client inside it, so your own
Python can play the host's part: **[Client transports](../client/transports.md)** launches
this same file as a subprocess with `stdio_client(...)`, and **[Testing](testing.md)**
this same file as a subprocess with `Client(StdioServerParameters(...))`, and **[Testing](testing.md)**
connects to it in memory with no process at all.

## Claude Desktop
Expand Down
4 changes: 2 additions & 2 deletions docs/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ v1 handed you three nested layers: a transport context manager yielding raw stre
--8<-- "docs_src/client/tutorial001.py"
```

`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_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 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.

**[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three 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.
**[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.

### The low-level `Server` was rebuilt, not renamed

Expand Down
3 changes: 1 addition & 2 deletions docs_src/client_transports/tutorial004.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client

server = StdioServerParameters(
command="uv",
Expand All @@ -9,6 +8,6 @@


async def main() -> None:
async with Client(stdio_client(server)) as client:
async with Client(server) as client:
result = await client.list_tools()
print([tool.name for tool in result.tools])
8 changes: 4 additions & 4 deletions examples/stories/_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import anyio
import httpx2

from mcp import StdioServerParameters, stdio_client
from mcp import StdioServerParameters
from mcp.client import Transport
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
Expand All @@ -32,8 +32,8 @@
else:
import tomli as tomllib

Target: TypeAlias = "Server[Any] | MCPServer | Transport | str"
"""Anything ``Client(...)`` accepts: an in-process server, a ``Transport``, or an HTTP URL."""
Target: TypeAlias = "Server[Any] | MCPServer | Transport | StdioServerParameters | str"
"""Anything ``Client(...)`` accepts: an HTTP URL, stdio launch parameters, a ``Transport``, or an in-process server."""

TargetFactory = Callable[[], Target]
"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories)."""
Expand Down Expand Up @@ -63,7 +63,7 @@ def target_from_args(file: str, url: str | None) -> TargetFactory:
# stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now.
server = Path(file).parent / f"{argv_after('--server', default='server')}.py"
params = StdioServerParameters(command=sys.executable, args=[str(server)])
return lambda: stdio_client(params) # becomes Client(params) once that overload lands
return lambda: params


def _explicit_http_url() -> str | None:
Expand Down
13 changes: 9 additions & 4 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
MessageHandlerFnT,
SamplingFnT,
)
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.client.subscriptions import ServerEvent, Subscription
from mcp.client.subscriptions import listen as _listen
Expand Down Expand Up @@ -261,8 +262,9 @@ def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExt
class Client:
"""A high-level MCP client for connecting to MCP servers.

Supports in-memory transport for testing (pass a Server or MCPServer instance),
Streamable HTTP transport (pass a URL string), or a custom Transport instance.
Pass a URL string (Streamable HTTP), a `StdioServerParameters` (launch the command as a
subprocess and talk over its stdin/stdout), any `Transport`, or - in tests - a `Server` or
`MCPServer` instance to connect to it in-process.

Example:
```python
Expand All @@ -283,12 +285,13 @@ async def main():
```
"""

server: Server[Any] | MCPServer | Transport | str
server: Server[Any] | MCPServer | Transport | StdioServerParameters | str
"""The MCP server to connect to.

If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport.
If the server is a `StdioServerParameters`, the command is launched with `stdio_client`.
If the server is a `Transport` instance, it will be used directly.
If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
"""

_: KW_ONLY
Expand Down Expand Up @@ -394,6 +397,8 @@ def __post_init__(self) -> None:
self._connect = _connect_inproc(srv)
elif isinstance(srv, str):
self._connect = _connect_transport(streamable_http_client(srv))
elif isinstance(srv, StdioServerParameters):
self._connect = _connect_transport(stdio_client(srv))
else:
self._connect = _connect_transport(srv)

Expand Down
21 changes: 20 additions & 1 deletion tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import contextvars
import sys
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from unittest.mock import patch
Expand Down Expand Up @@ -35,7 +36,7 @@
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from pydantic import FileUrl

from mcp import MCPDeprecationWarning, MCPError
from mcp import MCPDeprecationWarning, MCPError, StdioServerParameters
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
Expand Down Expand Up @@ -414,6 +415,24 @@ async def test_client_uses_transport_directly(app: MCPServer):
)


async def test_client_with_stdio_parameters_launches_the_server_as_a_subprocess() -> None:
"""SDK-defined: `Client` routes a `StdioServerParameters` through `stdio_client`, so entering it
spawns the command and negotiates over the child's stdin/stdout. The process boundary is the
behaviour, hence a real child interpreter running a one-line `MCPServer`."""
params = StdioServerParameters(
command=sys.executable,
args=["-c", "from mcp.server import MCPServer; MCPServer('stdio-demo').run()"],
)
# Wider than the standard 5: a cold interpreter start plus `import mcp.server` in the child takes
# seconds on a loaded Windows runner, and exit may wait out stdio_client's terminate/kill
# escalation (PROCESS_TERMINATION_TIMEOUT + FORCE_KILL_TIMEOUT + reap, ~6s) if the child is slow.
with anyio.fail_after(20):
async with Client(params) as client:
assert client.server_info is not None
assert client.server_info.name == "stdio-demo"
assert (await client.list_tools()).tools == []


_TEST_CONTEXTVAR = contextvars.ContextVar("test_var", default="initial")


Expand Down
8 changes: 4 additions & 4 deletions tests/docs_src/test_client_transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from docs_src.client_transports import tutorial001, tutorial004
from mcp import Client
from mcp.client.stdio import get_default_environment, stdio_client
from mcp.client.stdio import get_default_environment
from mcp.client.streamable_http import streamable_http_client

# See test_index.py for why this is a per-module mark and not a conftest hook.
Expand Down Expand Up @@ -41,9 +41,9 @@ async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None
assert list(inspect.signature(streamable_http_client).parameters) == ["url", "http_client", "terminate_on_close"]


async def test_stdio_parameters_are_wrapped_by_stdio_client() -> None:
"""tutorial004: `stdio_client(params)` is the transport, and `Client` takes it like any other."""
client = Client(stdio_client(tutorial004.server))
async def test_stdio_parameters_go_straight_to_client() -> None:
"""tutorial004: `Client` takes the `StdioServerParameters` directly, and nothing is spawned until you enter it."""
client = Client(tutorial004.server)
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
client.session

Expand Down
Loading