Skip to content

Commit e473cca

Browse files
authored
Let Client take StdioServerParameters directly (#3321)
1 parent fb443cc commit e473cca

9 files changed

Lines changed: 51 additions & 27 deletions

File tree

docs/client/index.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ The server at the top is only there so you have something to connect to. The cli
2222

2323
* An `MCPServer` (or low-level `Server`) instance: connected **in-process**.
2424
* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path.
25-
* A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess.
25+
* A `StdioServerParameters`: the command to launch as a **subprocess**, spoken to over its stdin and stdout.
26+
* A **transport**: anything you can `async with ... as (read, write)`, such as `streamable_http_client(url, http_client=...)` around your own HTTP client.
2627

27-
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)**.
28+
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)**.
2829

2930
### What's on a connected client
3031

docs/client/transports.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,15 @@ environment variables or pass an explicit `verify=ssl_context` to your `httpx2.A
8282

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

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

87-
```python title="client.py" hl_lines="4-8 12"
87+
```python title="client.py" hl_lines="3-7 11"
8888
--8<-- "docs_src/client_transports/tutorial004.py"
8989
```
9090

91-
`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.
91+
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.
9292

93-
Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself.
93+
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))`.
9494

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

109109
To `Client`, all of the above are the same thing.
110110

111-
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.
111+
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.
112112

113113
## Recap
114114

115115
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding.
116116
* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport.
117117
* Headers, auth, proxies and timeouts belong on an `httpx2.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword.
118-
* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone.
118+
* stdio is `Client(StdioServerParameters(...))`. Wrap it in `stdio_client(...)` yourself only to redirect the child's stderr.
119119
* The subprocess gets an allow-listed environment, not yours; `env=` adds to it.
120-
* 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.
120+
* 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.
121121
* Constructing a `Client` picks the transport. `async with` opens it.
122122

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

docs/get-started/real-host.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ It is also the command `mcp install` writes into Claude Desktop's config for you
4545

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

5151
## Claude Desktop

docs/whats-new.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ v1 handed you three nested layers: a transport context manager yielding raw stre
4141
--8<-- "docs_src/client/tutorial001.py"
4242
```
4343

44-
`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.
44+
`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.
4545

46-
**[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.
46+
**[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.
4747

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from mcp import Client, StdioServerParameters
2-
from mcp.client.stdio import stdio_client
32

43
server = StdioServerParameters(
54
command="uv",
@@ -9,6 +8,6 @@
98

109

1110
async def main() -> None:
12-
async with Client(stdio_client(server)) as client:
11+
async with Client(server) as client:
1312
result = await client.list_tools()
1413
print([tool.name for tool in result.tools])

examples/stories/_harness.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import anyio
2121
import httpx2
2222

23-
from mcp import StdioServerParameters, stdio_client
23+
from mcp import StdioServerParameters
2424
from mcp.client import Transport
2525
from mcp.client.streamable_http import streamable_http_client
2626
from mcp.server import Server
@@ -32,8 +32,8 @@
3232
else:
3333
import tomli as tomllib
3434

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

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

6868

6969
def _explicit_http_url() -> str | None:

src/mcp/client/client.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
MessageHandlerFnT,
5959
SamplingFnT,
6060
)
61+
from mcp.client.stdio import StdioServerParameters, stdio_client
6162
from mcp.client.streamable_http import streamable_http_client
6263
from mcp.client.subscriptions import ServerEvent, Subscription
6364
from mcp.client.subscriptions import listen as _listen
@@ -261,8 +262,9 @@ def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExt
261262
class Client:
262263
"""A high-level MCP client for connecting to MCP servers.
263264
264-
Supports in-memory transport for testing (pass a Server or MCPServer instance),
265-
Streamable HTTP transport (pass a URL string), or a custom Transport instance.
265+
Pass a URL string (Streamable HTTP), a `StdioServerParameters` (launch the command as a
266+
subprocess and talk over its stdin/stdout), any `Transport`, or - in tests - a `Server` or
267+
`MCPServer` instance to connect to it in-process.
266268
267269
Example:
268270
```python
@@ -283,12 +285,13 @@ async def main():
283285
```
284286
"""
285287

286-
server: Server[Any] | MCPServer | Transport | str
288+
server: Server[Any] | MCPServer | Transport | StdioServerParameters | str
287289
"""The MCP server to connect to.
288290
289-
If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
290291
If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport.
292+
If the server is a `StdioServerParameters`, the command is launched with `stdio_client`.
291293
If the server is a `Transport` instance, it will be used directly.
294+
If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
292295
"""
293296

294297
_: KW_ONLY
@@ -394,6 +397,8 @@ def __post_init__(self) -> None:
394397
self._connect = _connect_inproc(srv)
395398
elif isinstance(srv, str):
396399
self._connect = _connect_transport(streamable_http_client(srv))
400+
elif isinstance(srv, StdioServerParameters):
401+
self._connect = _connect_transport(stdio_client(srv))
397402
else:
398403
self._connect = _connect_transport(srv)
399404

tests/client/test_client.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import contextvars
6+
import sys
67
from collections.abc import AsyncIterator, Iterator
78
from contextlib import asynccontextmanager, contextmanager
89
from unittest.mock import patch
@@ -35,7 +36,7 @@
3536
from mcp_types.version import LATEST_HANDSHAKE_VERSION
3637
from pydantic import FileUrl
3738

38-
from mcp import MCPDeprecationWarning, MCPError
39+
from mcp import MCPDeprecationWarning, MCPError, StdioServerParameters
3940
from mcp.client._memory import InMemoryTransport
4041
from mcp.client._transport import TransportStreams
4142
from mcp.client.client import Client
@@ -414,6 +415,24 @@ async def test_client_uses_transport_directly(app: MCPServer):
414415
)
415416

416417

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

419438

tests/docs_src/test_client_transports.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from docs_src.client_transports import tutorial001, tutorial004
88
from mcp import Client
9-
from mcp.client.stdio import get_default_environment, stdio_client
9+
from mcp.client.stdio import get_default_environment
1010
from mcp.client.streamable_http import streamable_http_client
1111

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

4343

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

0 commit comments

Comments
 (0)