Skip to content

Commit f781f0e

Browse files
committed
Let Client take StdioServerParameters directly
Client picks its transport from the type of its argument: a server object connects in-process, a str is a Streamable HTTP URL, and anything else is entered as a Transport. stdio was the odd one out, needing Client(stdio_client(StdioServerParameters(...))). Add the missing arm so Client(StdioServerParameters(...)) launches the command via stdio_client; wrapping it yourself remains the way to redirect the child's stderr. Docs: the transports page's stdio section and recap, the "what you can pass" list, and the two other places that enumerate the connection forms. The stories harness drops the TODO that anticipated this.
1 parent 959569b commit f781f0e

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; 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)