Let Client take StdioServerParameters directly (#3321)

This commit is contained in:
Max 2026-08-17 14:31:21 +01:00 • committed by GitHub
commit e473cca6a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 51 additions and 27 deletions

View file

@ -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**. * An `MCPServer` (or low-level `Server`) instance: connected **in-process**.
* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path. * 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 ### What's on a connected client

View file

@ -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. 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" --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 !!! warning
The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`, 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. 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 ## Recap
* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. * `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. * `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. * 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. * 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. * 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. 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.

View file

@ -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 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 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. connects to it in memory with no process at all.
## Claude Desktop ## Claude Desktop

View file

@ -41,9 +41,9 @@ v1 handed you three nested layers: a transport context manager yielding raw stre
--8<-- "docs_src/client/tutorial001.py" --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 ### The low-level `Server` was rebuilt, not renamed

View file

@ -1,5 +1,4 @@
from mcp import Client, StdioServerParameters from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
server = StdioServerParameters( server = StdioServerParameters(
command="uv", command="uv",
@ -9,6 +8,6 @@ server = StdioServerParameters(
async def main() -> None: async def main() -> None:
async with Client(stdio_client(server)) as client: async with Client(server) as client:
result = await client.list_tools() result = await client.list_tools()
print([tool.name for tool in result.tools]) print([tool.name for tool in result.tools])

View file

@ -20,7 +20,7 @@ from urllib.parse import urlsplit
import anyio import anyio
import httpx2 import httpx2
from mcp import StdioServerParameters, stdio_client from mcp import StdioServerParameters
from mcp.client import Transport from mcp.client import Transport
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server from mcp.server import Server
@ -32,8 +32,8 @@ if sys.version_info >= (3, 11):
else: else:
import tomli as tomllib import tomli as tomllib
Target: TypeAlias = "Server[Any] | MCPServer | Transport | str" Target: TypeAlias = "Server[Any] | MCPServer | Transport | StdioServerParameters | str"
"""Anything ``Client(...)`` accepts: an in-process server, a ``Transport``, or an HTTP URL.""" """Anything ``Client(...)`` accepts: an HTTP URL, stdio launch parameters, a ``Transport``, or an in-process server."""
TargetFactory = Callable[[], Target] TargetFactory = Callable[[], Target]
"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories).""" """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. # 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" server = Path(file).parent / f"{argv_after('--server', default='server')}.py"
params = StdioServerParameters(command=sys.executable, args=[str(server)]) 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: def _explicit_http_url() -> str | None:

View file

@ -58,6 +58,7 @@ from mcp.client.session import (
MessageHandlerFnT, MessageHandlerFnT,
SamplingFnT, SamplingFnT,
) )
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from mcp.client.subscriptions import ServerEvent, Subscription from mcp.client.subscriptions import ServerEvent, Subscription
from mcp.client.subscriptions import listen as _listen from mcp.client.subscriptions import listen as _listen
@ -261,8 +262,9 @@ def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExt
class Client: class Client:
"""A high-level MCP client for connecting to MCP servers. """A high-level MCP client for connecting to MCP servers.
Supports in-memory transport for testing (pass a Server or MCPServer instance), Pass a URL string (Streamable HTTP), a `StdioServerParameters` (launch the command as a
Streamable HTTP transport (pass a URL string), or a custom Transport instance. subprocess and talk over its stdin/stdout), any `Transport`, or - in tests - a `Server` or
`MCPServer` instance to connect to it in-process.
Example: Example:
```python ```python
@ -283,12 +285,13 @@ class Client:
``` ```
""" """
server: Server[Any] | MCPServer | Transport | str server: Server[Any] | MCPServer | Transport | StdioServerParameters | str
"""The MCP server to connect to. """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 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 `Transport` instance, it will be used directly.
If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
""" """
_: KW_ONLY _: KW_ONLY
@ -394,6 +397,8 @@ class Client:
self._connect = _connect_inproc(srv) self._connect = _connect_inproc(srv)
elif isinstance(srv, str): elif isinstance(srv, str):
self._connect = _connect_transport(streamable_http_client(srv)) self._connect = _connect_transport(streamable_http_client(srv))
elif isinstance(srv, StdioServerParameters):
self._connect = _connect_transport(stdio_client(srv))
else: else:
self._connect = _connect_transport(srv) self._connect = _connect_transport(srv)

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import contextvars import contextvars
import sys
from collections.abc import AsyncIterator, Iterator from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager from contextlib import asynccontextmanager, contextmanager
from unittest.mock import patch from unittest.mock import patch
@ -35,7 +36,7 @@ from mcp_types import (
from mcp_types.version import LATEST_HANDSHAKE_VERSION from mcp_types.version import LATEST_HANDSHAKE_VERSION
from pydantic import FileUrl from pydantic import FileUrl
from mcp import MCPDeprecationWarning, MCPError from mcp import MCPDeprecationWarning, MCPError, StdioServerParameters
from mcp.client._memory import InMemoryTransport from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams from mcp.client._transport import TransportStreams
from mcp.client.client import Client 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") _TEST_CONTEXTVAR = contextvars.ContextVar("test_var", default="initial")

View file

@ -6,7 +6,7 @@ import pytest
from docs_src.client_transports import tutorial001, tutorial004 from docs_src.client_transports import tutorial001, tutorial004
from mcp import Client 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 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. # 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"] 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: async def test_stdio_parameters_go_straight_to_client() -> None:
"""tutorial004: `stdio_client(params)` is the transport, and `Client` takes it like any other.""" """tutorial004: `Client` takes the `StdioServerParameters` directly, and nothing is spawned until you enter it."""
client = Client(stdio_client(tutorial004.server)) client = Client(tutorial004.server)
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
client.session client.session