Skip to content

Commit 116a95f

Browse files
committed
Re-point fd at the wire when a divert dup2 fails
A Windows dup2 (UCRT before Windows 11) closes its target before duplicating, so an OSError from the divert can leave the standard descriptor closed rather than still carrying the wire. Run the same dup2(private_fd, fd) restore the exit path uses before serving in place: an idempotent no-op when fd is already on the wire, and a re-materialization from the private duplicate when the target was closed. Adds a regression variant that closes the target as UCRT does. Also scopes the real-host logging note to the default stderr handler.
1 parent 885c6aa commit 116a95f

3 files changed

Lines changed: 12 additions & 6 deletions

File tree

docs/get-started/real-host.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Once that command sits and waits, what's left is almost always one of three thin
165165

166166
* **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.
167167
* **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 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 `logging` module, which flushes each record to stderr. **[Logging](../handlers/logging.md)** has the whole story.
168+
* **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.
169169

170170
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.
171171

src/mcp/server/stdio.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,12 @@ def release() -> None:
140140
try:
141141
os.dup2(diversion_fd, fd)
142142
except OSError:
143-
# The divert did not land; fd still carries the wire, so serve it in
144-
# place through the shared buffer (two writers on one pipe tear frames).
143+
# The divert did not land; ensure fd carries the wire (a Windows dup2 can
144+
# close its target before failing), then serve it in place through the
145+
# shared buffer, since two writers on one pipe would tear frames.
145146
with suppress(OSError):
146147
os.close(diversion_fd)
148+
_restore_fd(fd, private_fd)
147149
return stream.buffer, release
148150
with suppress(OSError):
149151
os.close(diversion_fd)

tests/server/test_stdio.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,14 +214,15 @@ async def test_stdio_server_takes_stdin_off_the_descriptor_table_while_serving(
214214

215215

216216
@pytest.mark.anyio
217-
@pytest.mark.parametrize("failing_call", ["dup", "dup2"])
217+
@pytest.mark.parametrize("failing_call", ["dup", "dup2", "dup2_destroys_target"])
218218
async def test_stdio_server_reads_stdin_in_place_when_descriptor_isolation_fails(
219219
failing_call: str, monkeypatch: pytest.MonkeyPatch
220220
) -> None:
221221
"""A descriptor failure while claiming stdin degrades to reading sys.stdin in place.
222222
223223
SDK-defined behavior: isolation is best-effort; when duplicating fd 0 or diverting
224-
it fails, the transport serves over the original stdin exactly as v1 did.
224+
it fails, the transport serves over the original stdin exactly as v1 did. A dup2
225+
that closes its target before failing (Windows UCRT) still leaves fd 0 on the wire.
225226
"""
226227
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
227228
with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
@@ -237,13 +238,16 @@ def failing_dup_above_std(fd: int) -> int:
237238
monkeypatch.setattr("mcp.server.stdio._dup_above_std", failing_dup_above_std)
238239
else:
239240
# Fires once at the divert, then passes through: pytest's capture
240-
# machinery also calls os.dup2 at phase transitions.
241+
# machinery also calls os.dup2 at phase transitions. The destroying
242+
# variant closes the target first, as Windows UCRT dup2 does.
241243
real_dup2 = os.dup2
242244
armed = [True]
243245

244246
def failing_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
245247
if armed[0]:
246248
armed[0] = False
249+
if failing_call == "dup2_destroys_target":
250+
os.close(fd2)
247251
raise OSError("injected descriptor failure")
248252
return real_dup2(fd, fd2, inheritable)
249253

0 commit comments

Comments
 (0)