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
{{ message }}
Repository navigation
stdio_server: text a tool prints still reaches the client, because sys.stdout is flushed after fd 1 is restored #3821
Since #3117, stdio_server() points fd 1 at stderr while it serves, so that stray output from handlers misses the wire. Text written with print() still reaches the client. It goes into sys.stdout's own buffer, which is block-buffered when stdout is a pipe. That buffer is flushed when the interpreter exits, after stdio_server() has given fd 1 back to the client's pipe. The client then reads the text as a JSON-RPC message.
Reproduction
mcp 2.3.0, Python 3.13.16, macOS 26.3.
importasyncioimportsysfrommcpimportClientSession, StdioServerParametersfrommcp.client.stdioimportstdio_clientfrommcp.server.mcpserverimportMCPServerifsys.argv[1:] == ["server"]:
server=MCPServer("repro")
@server.tool()defnoisy() ->str:
print("stray output from a tool") # e.g. a library that printsreturn"done"server.run("stdio")
else:
asyncdefmain():
params=StdioServerParameters(command=sys.executable, args=[__file__, "server"])
asyncwithstdio_client(params) as (read, write):
asyncwithClientSession(read, write) assession:
awaitsession.initialize()
result=awaitsession.call_tool("noisy", {})
print("result:", result.content[0].text)
asyncio.run(main())
Running python repro.py, the call succeeds, and the client also logs:
pydantic_core._pydantic_core.ValidationError: 1 validation error for union[JSONRPCRequest,JSONRPCNotification,JSONRPCResponse,JSONRPCError]
Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='stray output from a tool', input_type=str]
Cause
While fd 1 is diverted, the printed text never reaches it: it sits in sys.stdout's buffer. The buffer is written out at interpreter exit, when fd 1 is the protocol pipe again. Adding sys.stdout.flush() right after the print() in the tool removes the error, and the text appears on stderr, which confirms this.
Suggestion
Flush sys.stdout before restoring fd 1, so that pending text goes to the diversion. Making sys.stdout line-buffered while serving (sys.stdout.reconfigure(line_buffering=True)) would also send printed text to stderr when it is printed, rather than at exit.
As a workaround we call sys.stdout.reconfigure(line_buffering=True) before server.run("stdio") and flush after each tool.
I looked through the implementation in src/mcp/server/stdio.py and I think I understand the root cause.
The _claim_fd() release callback restores fd 1 without first flushing the original sys.stdout wrapper. Buffered output can therefore be written to the protocol pipe after the descriptor has been restored.
I think a targeted fix would be to flush the captured stdout stream before restoring fd 1, while ensuring descriptor restoration still happens even if flushing fails.
I'd also like to add a regression test in tests/server/test_stdio.py that uses print() without flush=True and verifies that the buffered output reaches stderr rather than the JSON-RPC stream.
Does this approach align with the intended behavior? I'd be interested in contributing the implementation and tests.
AI assistance disclosure: I used ChatGPT to help with initial code-path analysis and drafting. I'll verify the behavior and take responsibility for the implementation.
Confirmed at the fd level on current main, and the distinction matters for where the fix goes.
_claim_fd() in src/mcp/server/stdio.py does os.dup2(diversion_fd, 1) to point fd 1 at a dup of fd 2, and returns release(). That closure calls _restore_fd(fd, claim.private_fd), which is os.dup2(private_fd, 1). Nothing in the release path touches sys.stdout, so every byte sitting in the interpreter's own wrapper is still pending at the moment fd 1 becomes the wire again. stdio_server()'s finally only calls restore_stdout(). The await stdout.flush() at line 205 cannot help: that flushes the private _UnownedTextWrapper the transport serves, not sys.stdout.
I reproduced the mechanism with a control on a fresh fixture instead of a real server, so the three cases are separable:
wire=os.dup(1) # private dup of the real stdout == the wireos.dup2(diversion_fd, 1) # _claim_fd()print("stray output from a tool") # a library prints inside a handlersys.stdout.flush() # mode B onlyos.dup2(wire, 1) # release(): _restore_fd()# interpreter exits, sys.stdout flushes at shutdown
Measured, Python 3.13, stdout a pipe:
mode
bytes on the wire
bytes on the diversion
A, current main
'stray output from a tool\n'
''
B, sys.stdout.flush() before restore
''
'stray output from a tool\n'
C, reconfigure(line_buffering=True) at claim
''
'stray output from a tool\n'
One more measurement that explains why this reads as intermittent. The wrapper is block-buffered at 8192 bytes, so output at or above that size flushes into the diversion mid-serve and never reaches the wire. Writing "S" * n:
print of 4096 bytes -> wire 4096 diversion 0
print of 8000 bytes -> wire 8000 diversion 0
print of 8192 bytes -> wire 0 diversion 8192
print of 40000 bytes -> wire 0 diversion 40000
So a one-line warning from a library always corrupts the stream, and a large debug blob usually does not. The small prints are the ones people believe are harmless, and they are the guaranteed failures.
For the fix, _claim_fd() already receives stream: TextIO, which is sys.stdout, so the object is in scope inside the release() closure. Flushing stream there, before _restore_fd(), covers both the diverted and the no-diversion path (claim.private_fd is None) with one code path. It also cannot interleave with a protocol frame, because the writer serializes to the private descriptor rather than to sys.stdout.
Your line-buffering suggestion is the better of the two for latency, but it changes buffering for the rest of the process unless the previous state is saved and put back on release. Flush-on-release is the smaller change if the maintainers want one line.
Activity
ima-d-ice commentedon Nov 5, 2026
I looked through the implementation in
src/mcp/server/stdio.pyand I think I understand the root cause.The
_claim_fd()release callback restores fd 1 without first flushing the originalsys.stdoutwrapper. Buffered output can therefore be written to the protocol pipe after the descriptor has been restored.I think a targeted fix would be to flush the captured stdout stream before restoring fd 1, while ensuring descriptor restoration still happens even if flushing fails.
I'd also like to add a regression test in
tests/server/test_stdio.pythat usesprint()withoutflush=Trueand verifies that the buffered output reaches stderr rather than the JSON-RPC stream.Does this approach align with the intended behavior? I'd be interested in contributing the implementation and tests.
AI assistance disclosure: I used ChatGPT to help with initial code-path analysis and drafting. I'll verify the behavior and take responsibility for the implementation.
media-ctrl commentedon Nov 5, 2026
Confirmed at the fd level on current main, and the distinction matters for where the fix goes.
_claim_fd()insrc/mcp/server/stdio.pydoesos.dup2(diversion_fd, 1)to point fd 1 at a dup of fd 2, and returnsrelease(). That closure calls_restore_fd(fd, claim.private_fd), which isos.dup2(private_fd, 1). Nothing in the release path touchessys.stdout, so every byte sitting in the interpreter's own wrapper is still pending at the moment fd 1 becomes the wire again.stdio_server()'sfinallyonly callsrestore_stdout(). Theawait stdout.flush()at line 205 cannot help: that flushes the private_UnownedTextWrapperthe transport serves, notsys.stdout.I reproduced the mechanism with a control on a fresh fixture instead of a real server, so the three cases are separable:
Measured, Python 3.13, stdout a pipe:
'stray output from a tool\n'''sys.stdout.flush()before restore'''stray output from a tool\n'reconfigure(line_buffering=True)at claim'''stray output from a tool\n'One more measurement that explains why this reads as intermittent. The wrapper is block-buffered at 8192 bytes, so output at or above that size flushes into the diversion mid-serve and never reaches the wire. Writing
"S" * n:So a one-line warning from a library always corrupts the stream, and a large debug blob usually does not. The small prints are the ones people believe are harmless, and they are the guaranteed failures.
For the fix,
_claim_fd()already receivesstream: TextIO, which issys.stdout, so the object is in scope inside therelease()closure. Flushingstreamthere, before_restore_fd(), covers both the diverted and the no-diversion path (claim.private_fd is None) with one code path. It also cannot interleave with a protocol frame, because the writer serializes to the private descriptor rather than tosys.stdout.Your line-buffering suggestion is the better of the two for latency, but it changes buffering for the rest of the process unless the previous state is saved and put back on release. Flush-on-release is the smaller change if the maintainers want one line.