You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Isolate the stdio server's stdout from handler code and subprocesses
While serving on the process's real stdout, stdio_server now moves the
protocol pipe to a private descriptor and points fd 1 - and, on
Windows, the standard output handle - at stderr, restoring it when the
transport exits. A stray print() in handler code or a child process
writing to its inherited stdout lands in the client's log instead of
corrupting the JSON-RPC stream. The null device stands in when stderr
is unusable or, on POSIX, is detected as merged into stdout (2>&1).
The stdin claim generalizes into the shared _claim_fd mechanism: one
lock-guarded sentinel table covers both descriptors, private wire
duplicates are forced above the standard descriptor range so a process
started with a standard descriptor closed cannot hand the wire out as
its "stderr", and a failed claim degrades to serving the sys stream's
buffer in place exactly as v1 did.
Docs now describe the guarded behavior with its remaining gaps (output
flushed before serving begins, injected streams, merged stderr on
Windows), and the transport:stdio:stream-purity divergence narrows
accordingly.
Copy file name to clipboardExpand all lines: docs/get-started/real-host.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -165,7 +165,7 @@ Once that command sits and waits, what's left is almost always one of three thin
165
165
166
166
***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.
167
167
***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.
168
-
***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.
168
+
***Something reached stdout before serving began.** On stdio, stdout *is* the protocol. The SDK diverts stray output to stderr while serving, but anything that reaches stdout before then -- a wrapper script echoing, an import-time `print()`in an unbuffered process -- hands the host a corrupt message and it drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
169
169
170
170
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.
Copy file name to clipboardExpand all lines: docs/handlers/logging.md
+4-3Lines changed: 4 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -33,8 +33,9 @@ For a **stdio** server this question matters more than usual. The host launched
33
33
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.
34
34
35
35
!!! tip
36
-
Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
37
-
line and the client is trying to parse it as JSON-RPC.
36
+
Don't `print()` in a stdio server. `print` writes to **stdout**, and stdout belongs to the protocol:
37
+
while serving, the SDK diverts stray stdout to stderr so it can't corrupt the wire, but that leaves
38
+
your line interleaved raw among the log output -- no level, no logger name, no way to filter it.
38
39
39
40
`logger.debug("got here")` is the same one line of effort and goes to the right place.
40
41
@@ -72,7 +73,7 @@ went to standard error: the terminal, not the wire.
72
73
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
73
74
*`logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern.
74
75
* Log output never reaches the model. Only the value you `return` does.
75
-
* Standard error is yours; stdout belongs to the protocol. Never `print()`in a stdio server.
76
+
* Standard error is yours; stdout belongs to the protocol. The SDK diverts a stray `print()`to stderr while serving, but it arrives unlabeled -- use `logging`.
76
77
*`MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
77
78
78
79
Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**.
Copy file name to clipboardExpand all lines: docs/run/index.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -39,7 +39,7 @@ python server.py
39
39
40
40
Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first.
41
41
42
-
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)**.
42
+
That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and diverts stray output -- a `print()`, a subprocess writing to its inherited stdout -- to stderr, where it can't corrupt the stream. Output that reaches stdout *before* serving begins (a wrapper script echoing, an unbuffered import-time print) still lands on the wire. For output you actually want, the `logging` module is the right tool. That story is in **[Logging](../handlers/logging.md)**.
Copy file name to clipboardExpand all lines: docs/troubleshooting.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -137,7 +137,7 @@ There is no error string for this, which is exactly why it is hard to search. Th
137
137
***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.
138
138
***Did two tools share a name?** Then one of them is gone. Look for `Tool already exists:` in the server log.
139
139
***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.
140
-
***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)**.
140
+
***Did something write to `stdout` before the server started serving?**While serving, the SDK diverts stray stdout to stderr (best-effort: an environment that replaces `sys.stdout`, or merges stderr into stdout on Windows, is served as-is), but output flushed to stdout earlier -- a wrapper script echoing, an import-time `print()`in an unbuffered process -- 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)**.
141
141
142
142
An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway.
0 commit comments