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..afb33caf38 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..914067c7a0 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, 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