Skip to content

Commit 8c46858

Browse files
committed
Keep a still-diverted fd claimed when its restore fails
A failed exit-time restore is still swallowed so it never masks what ended the transport, but the fd now stays in the claimed set: a later stdio_server() in that process is refused instead of claiming the diversion and serving the null device as its wire.
1 parent 722ff77 commit 8c46858

2 files changed

Lines changed: 20 additions & 6 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,23 @@ def restore() -> None:
101101
# Drain text buffered during the claim (a stray print) to the diversion.
102102
with suppress(OSError, ValueError):
103103
stream.flush()
104-
_restore_fd(fd, private_fd)
105-
unclaim()
104+
# A failed restore leaves fd diverted; keep it claimed so later transports are refused.
105+
if _restore_fd(fd, private_fd):
106+
unclaim()
106107

107108
# closefd=False: a blocked worker thread may still read this descriptor after exit.
108109
return os.fdopen(private_fd, mode, closefd=False), restore
109110

110111

111-
def _restore_fd(fd: int, private_fd: int) -> None:
112-
with suppress(OSError):
112+
def _restore_fd(fd: int, private_fd: int) -> bool:
113+
"""Point fd back at the protocol pipe; a failure never masks the transport's exit."""
114+
try:
113115
os.dup2(private_fd, fd)
114116
if sys.platform == "win32": # pragma: no cover
115117
rebind_std_handle_to_fd(fd)
118+
except OSError:
119+
return False
120+
return True
116121

117122

118123
@asynccontextmanager

tests/server/test_stdio.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,11 +272,14 @@ def failing_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
272272
async def test_stdio_server_exits_cleanly_when_the_stdin_restore_fails(
273273
monkeypatch: pytest.MonkeyPatch,
274274
) -> None:
275-
"""A failed fd 0 restore on exit is swallowed, not raised.
275+
"""A failed fd 0 restore on exit is swallowed, not raised, and the fd stays claimed.
276276
277-
SDK-defined behavior: the restore in the finally must never mask what ended the transport.
277+
SDK-defined behavior: the restore must never mask what ended the transport, and a
278+
still-diverted fd must refuse later transports rather than serve them the diversion.
278279
"""
279280
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
281+
fresh_claims: set[int] = set()
282+
monkeypatch.setattr("mcp.server.stdio._claimed", fresh_claims) # this test leaves fd 0 claimed
280283
with _pipe_planted_on_fd0(monkeypatch) as (_, in_w):
281284
os.write(in_w, _frame(request))
282285
os.close(in_w)
@@ -310,6 +313,12 @@ def flaky_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
310313
finally:
311314
os.close(devnull_probe)
312315

316+
# The still-diverted fd stays claimed: a later transport is refused,
317+
# not handed the null device as its wire.
318+
with pytest.raises(RuntimeError, match="already claimed fd 0"):
319+
async with stdio_server():
320+
pytest.fail("unreachable") # pragma: no cover
321+
313322

314323
@pytest.mark.anyio
315324
async def test_stdio_server_takes_stdout_off_the_descriptor_table_while_serving(

0 commit comments

Comments
 (0)