Let Client take StdioServerParameters directly (#3321)
This commit is contained in:
parent
fb443cc4b2
commit
e473cca6a1
9 changed files with 51 additions and 27 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from mcp import Client, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
server = StdioServerParameters(
|
||||
command="uv",
|
||||
|
|
@ -9,6 +8,6 @@ server = StdioServerParameters(
|
|||
|
||||
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from urllib.parse import urlsplit
|
|||
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 @@ if sys.version_info >= (3, 11):
|
|||
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:
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ from mcp.client.session import (
|
|||
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 @@ class Client:
|
|||
```
|
||||
"""
|
||||
|
||||
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 @@ class Client:
|
|||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 import (
|
|||
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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import pytest
|
|||
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue