Isolate the stdio server's stdin and stdout from handler subprocesses (#3117)
This commit is contained in:
parent
00a70148bc
commit
629ca297d2
5 changed files with 811 additions and 169 deletions
|
|
@ -165,7 +165,7 @@ Once that command sits and waits, what's left is almost always one of three thin
|
|||
|
||||
* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too.
|
||||
* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* (not just its window closed) and reopened before an edit to `claude_desktop_config.json` takes effect.
|
||||
* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
|
||||
* **Something reached stdout outside the diverted window.** On stdio, stdout *is* the protocol. The SDK diverts flushed stray output to stderr while serving, but output flushed to stdout before then (a wrapper script echoing, an import-time `print()` in an unbuffered process), or a buffered `print()` drained at interpreter exit, hands the host a corrupt message and it drops the connection. Log with the default `logging` configuration, whose stderr handler flushes each record; custom handlers must also avoid stdout. **[Logging](../handlers/logging.md)** has the whole story.
|
||||
|
||||
Claude Desktop keeps a log per server: `mcp-server-<NAME>.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows.
|
||||
|
||||
|
|
|
|||
|
|
@ -33,8 +33,11 @@ For a **stdio** server this question matters more than usual. The host launched
|
|||
The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean.
|
||||
|
||||
!!! tip
|
||||
Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
|
||||
line and the client is trying to parse it as JSON-RPC.
|
||||
Don't `print()` in a stdio server. `print` writes to **stdout**, and stdout belongs to the protocol.
|
||||
While serving, the SDK diverts stdout that is actually *flushed* to stderr, so it can't corrupt the
|
||||
wire, but a `print()` in a block-buffered process usually sits unflushed in `sys.stdout`'s buffer
|
||||
until the interpreter drains it at exit, straight onto the protocol stream. Even when it is diverted,
|
||||
the line lands raw among the log output, with no level, no logger name, and no way to filter it.
|
||||
|
||||
`logger.debug("got here")` is the same one line of effort and goes to the right place.
|
||||
|
||||
|
|
@ -72,7 +75,7 @@ went to standard error: the terminal, not the wire.
|
|||
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
|
||||
* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern.
|
||||
* Log output never reaches the model. Only the value you `return` does.
|
||||
* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server.
|
||||
* Standard error is yours; stdout belongs to the protocol. The SDK diverts flushed stray stdout to stderr while serving, but an unflushed `print()` can still drain onto the wire at exit, and diverted lines arrive unlabeled; use `logging`, whose handler flushes every record.
|
||||
* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
|
||||
|
||||
Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**.
|
||||
|
|
|
|||
|
|
@ -1863,6 +1863,36 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
|
|||
per-process terminate/kill fallback are gone. The win32 utilities logger is now
|
||||
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
|
||||
|
||||
### `stdio_server` keeps the protocol streams on private descriptors
|
||||
|
||||
While serving, the stdio transport moves the wire to private descriptors and points
|
||||
fd 0 at the null device and fd 1 at stderr, restoring both on exit. Subprocesses and
|
||||
handler code can no longer read protocol bytes or write into the stream (the
|
||||
[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) fix). Ordinary
|
||||
servers have nothing to do, and code that inspects or manipulates fd 0/1 directly
|
||||
during a session now sees the diversions, not the wire.
|
||||
|
||||
One pattern needs migrating: watchdog threads that watch fd 0 to detect a vanished
|
||||
client (a POSIX-specific pattern; `select.poll` does not exist on Windows). The null
|
||||
device does not behave like the old pipe: it never reports `POLLHUP` or `POLLERR`,
|
||||
and it reports readable immediately and permanently (`POLLIN` from `poll()` on Linux,
|
||||
plus `POLLOUT` under the default event mask; ready from `select()`; and macOS can
|
||||
report `POLLNVAL` for devices). A watcher waiting for `POLLHUP` or `POLLERR` is
|
||||
silently disarmed; a watcher that treats any event as "client gone" now fires at
|
||||
startup instead of never. Watch the parent process instead: on POSIX, exit
|
||||
when `os.getppid()` changes, which happens when the client dies because orphaned
|
||||
processes are reparented. That works on both v1 and v2 and does not depend on
|
||||
descriptor layout.
|
||||
|
||||
Also new: a second concurrent `stdio_server()` on the process's default streams now
|
||||
raises `RuntimeError` instead of silently contending for stdin, a configuration that
|
||||
never worked (there is one stdin).
|
||||
|
||||
Also worth knowing: a child process that streams large output to its inherited
|
||||
stdout now streams it into the client's stderr channel. Capture output you do not
|
||||
want in the client's logs, and be aware that a client which never drains its stderr
|
||||
pipe applies back-pressure to the server (true of stderr logging on v1 as well).
|
||||
|
||||
### WebSocket transport removed
|
||||
|
||||
The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP.
|
||||
|
|
|
|||
|
|
@ -39,31 +39,7 @@ python server.py
|
|||
|
||||
Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first.
|
||||
|
||||
That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../handlers/logging.md)**.
|
||||
|
||||
On Windows, the same rule applies to child processes your tools start. A child
|
||||
that inherits the stdio server's stdin can block behind the server's protocol
|
||||
reader. If your tool starts a subprocess and you do not intend to feed it input,
|
||||
redirect the child's stdin:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
async def run_script() -> tuple[bytes, bytes]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"script.py",
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return await process.communicate()
|
||||
```
|
||||
|
||||
The matching troubleshooting entry is **[My stdio tool hangs when it starts a subprocess on Windows](../troubleshooting.md#my-stdio-tool-hangs-when-it-starts-a-subprocess-on-windows)**.
|
||||
That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and diverts output that is *flushed* to stdout (a subprocess writing to its inherited stdout, a flushed `print()`) to stderr, where it can't corrupt the stream. Output flushed to stdout *before* serving begins (a wrapper script echoing, an unbuffered import-time print) still lands on the wire, and so does a `print()` that stays buffered until the interpreter drains it at exit. For output you actually want, the `logging` module is the right tool: its handler flushes each record to stderr as it happens. That story is in **[Logging](../handlers/logging.md)**.
|
||||
|
||||
### Try it
|
||||
|
||||
|
|
|
|||
|
|
@ -137,45 +137,10 @@ There is no error string for this, which is exactly why it is hard to search. Th
|
|||
* **Is the tool on the `mcp` the host is running?** A second `MCPServer(...)` in another module is a different, empty server. Check which object the host's command actually imports.
|
||||
* **Did two tools share a name?** Then one of them is gone. Look for `Tool already exists:` in the server log.
|
||||
* **Is the host's list stale?** Adding a tool after startup only reaches clients that handle `notifications/tools/list_changed`. Restarting the host is the blunt fix.
|
||||
* **Did something write to `stdout`?** On a stdio transport, stdout *is* the protocol: one stray `print()` and the host drops the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**.
|
||||
* **Did something write to `stdout` outside the diverted window?** While serving, the SDK diverts *flushed* stray stdout to stderr (best-effort: an environment that replaces the standard streams is served as-is), but output flushed to stdout earlier (a wrapper script echoing, an import-time `print()` in an unbuffered process) or a buffered `print()` drained at interpreter exit lands on the protocol stream, and one junk line can make the host drop the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**.
|
||||
|
||||
An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway.
|
||||
|
||||
## My stdio tool hangs when it starts a subprocess on Windows
|
||||
|
||||
Your server is running over `stdio`, and a tool starts another process with
|
||||
`asyncio.create_subprocess_exec`, `asyncio.create_subprocess_shell`, or
|
||||
`subprocess.Popen`. The tool call never returns on Windows, while the same code
|
||||
works over an HTTP transport.
|
||||
|
||||
The child inherited the server's stdin. In a stdio server, stdin is the protocol
|
||||
pipe and the server is already waiting on it for the next JSON-RPC message. A
|
||||
Python child process on Windows can block during startup when it inherits that
|
||||
same pipe.
|
||||
|
||||
If you do not intend to send input to the child, redirect its stdin:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
async def run_script() -> tuple[bytes, bytes]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"script.py",
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
return await process.communicate()
|
||||
```
|
||||
|
||||
Use the same idea with `subprocess.Popen(..., stdin=subprocess.DEVNULL)`. Also
|
||||
capture or redirect the child's stdout. The stdio server's stdout is the MCP
|
||||
wire, so a child that writes there can corrupt the connection.
|
||||
|
||||
## `MCPError: Server returned an error response`
|
||||
|
||||
The server refused the HTTP request outright, with a body that is not JSON-RPC, so the python `Client` has nothing better to show you than this stand-in.
|
||||
|
|
|
|||
Loading…
Reference in a new issue