From f781f0ebb8dec9c3940e526cb4c8e71766d69701 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:26:40 +0000 Subject: [PATCH 1/2] 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. --- docs/client/index.md | 5 +++-- docs/client/transports.md | 14 +++++++------- docs/get-started/real-host.md | 2 +- docs/whats-new.md | 4 ++-- docs_src/client_transports/tutorial004.py | 3 +-- examples/stories/_harness.py | 8 ++++---- src/mcp/client/client.py | 13 +++++++++---- tests/client/test_client.py | 21 ++++++++++++++++++++- tests/docs_src/test_client_transports.py | 8 ++++---- 9 files changed, 51 insertions(+), 27 deletions(-) diff --git a/docs/client/index.md b/docs/client/index.md index 1e1df3c01b..b1a1dbc234 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -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 diff --git a/docs/client/transports.md b/docs/client/transports.md index b453100655..f8cd496bb7 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -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`, @@ -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. diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md index d31fb5caea..f01c6eb451 100644 --- a/docs/get-started/real-host.md +++ b/docs/get-started/real-host.md @@ -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 diff --git a/docs/whats-new.md b/docs/whats-new.md index bc1bfd6c56..0a4ed4c35f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 diff --git a/docs_src/client_transports/tutorial004.py b/docs_src/client_transports/tutorial004.py index 8e07e09741..a521d12a21 100644 --- a/docs_src/client_transports/tutorial004.py +++ b/docs_src/client_transports/tutorial004.py @@ -1,5 +1,4 @@ from mcp import Client, StdioServerParameters -from mcp.client.stdio import stdio_client server = StdioServerParameters( command="uv", @@ -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]) diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 700d2b57c1..6a9670f207 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -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 @@ -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).""" @@ -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: diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index ed7c40f123..11574e1c33 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -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 @@ -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 @@ -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 @@ -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) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 697383e8ae..d7278e3a81 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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 @@ -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 @@ -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") diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py index 4da0da1f42..71cb5409f3 100644 --- a/tests/docs_src/test_client_transports.py +++ b/tests/docs_src/test_client_transports.py @@ -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. @@ -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; 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 From f13b1941491f44298ababed1f79e751a416ef667 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:07:15 +0000 Subject: [PATCH 2/2] docs: split two semicolon-joined sentences on the transports page No-Verification-Needed: doc and docstring wording only --- docs/client/transports.md | 4 ++-- tests/docs_src/test_client_transports.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/client/transports.md b/docs/client/transports.md index f8cd496bb7..afb33caf38 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -88,7 +88,7 @@ Describe the process with `StdioServerParameters` and hand it to `Client`: --8<-- "docs_src/client_transports/tutorial004.py" ``` -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. +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. 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))`. @@ -115,7 +115,7 @@ A **transport** is any async context manager that yields a `(read, write)` pair * `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(StdioServerParameters(...))`; wrap it in `stdio_client(...)` yourself only to redirect the child's stderr. +* 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, a URL or `StdioServerParameters` straight to that protocol. * Constructing a `Client` picks the transport. `async with` opens it. diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py index 71cb5409f3..914067c7a0 100644 --- a/tests/docs_src/test_client_transports.py +++ b/tests/docs_src/test_client_transports.py @@ -42,7 +42,7 @@ async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None async def test_stdio_parameters_go_straight_to_client() -> None: - """tutorial004: `Client` takes the `StdioServerParameters` directly; nothing is spawned until you enter it.""" + """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