Skip to content

Commit 6a6c544

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 87dda97 commit 6a6c544

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
@@ -273,11 +273,14 @@ def failing_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
273273
async def test_stdio_server_exits_cleanly_when_the_stdin_restore_fails(
274274
monkeypatch: pytest.MonkeyPatch,
275275
) -> None:
276-
"""A failed fd 0 restore on exit is swallowed, not raised.
276+
"""A failed fd 0 restore on exit is swallowed, not raised, and the fd stays claimed.
277277
278-
SDK-defined behavior: the restore in the finally must never mask what ended the transport.
278+
SDK-defined behavior: the restore must never mask what ended the transport, and a
279+
still-diverted fd must refuse later transports rather than serve them the diversion.
279280
"""
280281
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
282+
fresh_claims: set[int] = set()
283+
monkeypatch.setattr("mcp.server.stdio._claimed", fresh_claims) # this test leaves fd 0 claimed
281284
with _pipe_planted_on_fd0(monkeypatch) as (_, in_w):
282285
os.write(in_w, _frame(request))
283286
os.close(in_w)
@@ -311,6 +314,12 @@ def flaky_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
311314
finally:
312315
os.close(devnull_probe)
313316

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

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

0 commit comments

Comments
 (0)