Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/get-started/real-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

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.

Expand Down
7 changes: 4 additions & 3 deletions docs/handlers/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ 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 stray stdout to stderr so it can't corrupt the wire, but that leaves

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Windows processes launched with stderr merged into stdout can still send print() output to the protocol pipe, so this guarantee is misleading. Qualify the claim with the merged-stream caveat (or direct readers to the migration caveats).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/handlers/logging.md, line 37:

<comment>Windows processes launched with stderr merged into stdout can still send `print()` output to the protocol pipe, so this guarantee is misleading. Qualify the claim with the merged-stream caveat (or direct readers to the migration caveats).</comment>

<file context>
@@ -33,8 +33,9 @@ For a **stdio** server this question matters more than usual. The host launched
-    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 stray stdout to stderr so it can't corrupt the wire, but that leaves
+    your line interleaved raw among the log output -- no level, no logger name, no way to filter it.
 
</file context>

your line interleaved raw among the log output -- no level, no logger name, no way to filter it.

`logger.debug("got here")` is the same one line of effort and goes to the right place.

Expand Down Expand Up @@ -72,7 +73,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 a stray `print()` to stderr while serving, but it arrives unlabeled -- use `logging`.
* `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)**.
35 changes: 35 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1855,6 +1855,41 @@ 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 on the process's real stdin and stdout, the stdio server transport now
duplicates each protocol pipe to a private descriptor and points the standard
descriptors — with their Windows standard handles — away from the wire, restoring

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Windows processes launched with stderr merged into stdout can still send stray handler or child output onto the protocol pipe, but this migration note says fd 1 is always diverted away from the wire and that capturing child stdout is no longer needed. Please document this Windows merge exception here (and retain the capture workaround for that launch shape), so users do not rely on isolation that the implementation deliberately cannot provide.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 1862:

<comment>Windows processes launched with stderr merged into stdout can still send stray handler or child output onto the protocol pipe, but this migration note says fd 1 is always diverted away from the wire and that capturing child stdout is no longer needed. Please document this Windows merge exception here (and retain the capture workaround for that launch shape), so users do not rely on isolation that the implementation deliberately cannot provide.</comment>

<file context>
@@ -1855,31 +1855,40 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
+
+While serving on the process's real stdin and stdout, the stdio server transport now
+duplicates each protocol pipe to a private descriptor and points the standard
+descriptors — with their Windows standard handles — away from the wire, restoring
+both when the transport exits: fd 0 reads the null device, and fd 1 writes to stderr
+(the null device if stderr is unusable). Subprocesses started by handler code
</file context>

both when the transport exits: fd 0 reads the null device, and fd 1 writes to stderr
(the null device if stderr is unusable). Subprocesses started by handler code
therefore inherit the diversions instead of the protocol pipes, and a stray
`print()` lands on stderr rather than the wire. (The claim is best-effort: in the
rare process whose descriptor table cannot be rearranged, the transport serves in
place, exactly as v1 did.)

In v1 a child inheriting the pipes could consume protocol bytes or corrupt the
outgoing stream with its own output, and on Windows it hung inside interpreter
startup — behind the transport's pending read on the shared pipe
([CPython gh-78961](https://github.com/python/cpython/issues/78961)) — until
the next request arrived: the
[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) hang, for
which passing `stdin=subprocess.DEVNULL` on every spawn (and capturing the child's
stdout) was the required workaround. On v2 the workaround is no longer needed, for
any spawn API, redirected or not.

To migrate: nothing, unless handler code used the standard streams directly during a
stdio session. Reading `sys.stdin` (or calling `input()`) now sees end-of-file
instead of racing the transport for protocol bytes; there was never a meaningful
value to read there. `print()` and other `sys.stdout` writes reach stderr instead of
corrupting the wire — code that deliberately wrote protocol frames to `sys.stdout`
must send them through the transport's write stream instead. A child that streams
a lot of output to its inherited stdout now streams it into the client's stderr
channel; capture output you don't want in the client's logs. Likewise, anything
that read ahead from `sys.stdin` before the server started keeps those bytes; they
no longer reach the transport (they never reliably did). Passing an explicit `stdin=`/`stdout=` stream to
`stdio_server(...)` skips the descriptor changes for that stream, as does any
environment where the sys stream is not backed by the process's real descriptor.

### 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.
Expand Down
26 changes: 1 addition & 25 deletions docs/run/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Hosts that merge stderr into stdout can still receive diverted print() and child output on the protocol pipe. Qualify the guarantee here (and point readers to the merge caveat) so this does not promise isolation in that supported launch configuration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/run/index.md, line 42:

<comment>Hosts that merge stderr into stdout can still receive diverted `print()` and child output on the protocol pipe. Qualify the guarantee here (and point readers to the merge caveat) so this does not promise isolation in that supported launch configuration.</comment>

<file context>
@@ -39,7 +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)**.
+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)**.
 
 ### Try it
</file context>
Suggested change
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)**.
That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and normally diverts stray output -- a `print()`, a subprocess writing to its inherited stdout -- to stderr, where it can't corrupt the stream. Hosts that merge stderr into stdout can still expose that output on the wire. 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)**.


### Try it

Expand Down
37 changes: 1 addition & 36 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 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)**.

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.
Expand Down
18 changes: 17 additions & 1 deletion src/mcp/os/posix/utilities.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
"""POSIX-specific functionality for stdio client operations."""
"""POSIX-specific functionality for stdio transport operations."""

import logging
import os
import signal
import sys
from contextlib import suppress

import anyio
from anyio.abc import Process

logger = logging.getLogger(__name__)


def same_open_file(fd_a: int, fd_b: int) -> bool:
"""Whether two descriptors refer to the same open file.

False on Windows - anonymous pipe handles carry no identity to compare -
and False when either descriptor cannot be interrogated.
"""
if sys.platform == "win32":
return False
try:
return os.path.sameopenfile(fd_a, fd_b)
except OSError:
return False


# How often to probe for surviving group members between SIGTERM and SIGKILL.
_GROUP_POLL_INTERVAL = 0.01

Expand Down
27 changes: 26 additions & 1 deletion src/mcp/os/win32/utilities.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Windows-specific functionality for stdio client operations."""
"""Windows-specific functionality for stdio transport operations."""

import logging
import shutil
Expand All @@ -20,14 +20,39 @@
import pywintypes
import win32api
import win32con
import win32file
import win32job
else:
# Type stubs for non-Windows platforms
win32api = None
win32con = None
win32file = None
win32job = None
pywintypes = None


def rebind_std_handle_to_fd(fd: int) -> None:
"""Points the Win32 standard-handle slot for fd 0, 1, or 2 at fd's current OS handle.

os.dup2 updates only the C runtime's descriptor table; anything that resolves
GetStdHandle — subprocess standard-handle inheritance, native code — reads the
Win32 slot, so after retargeting a standard descriptor the slot must be
repointed too.

Raises:
OSError: The slot could not be set; callers treat descriptor
rearrangement as best-effort and fall back on failure.
"""
if sys.platform != "win32" or not win32api or not win32file or not pywintypes:
return
std_ids = {0: win32api.STD_INPUT_HANDLE, 1: win32api.STD_OUTPUT_HANDLE, 2: win32api.STD_ERROR_HANDLE}
try:
win32api.SetStdHandle(std_ids[fd], win32file._get_osfhandle(fd))
except pywintypes.error as exc:
# Normalized so callers' OSError-based best-effort handling covers it.
raise OSError(f"SetStdHandle failed for fd {fd}") from exc


# How often FallbackProcess polls the underlying Popen for exit.
_EXIT_POLL_INTERVAL = 0.01

Expand Down
Loading
Loading