Repository navigation
test: don't start a real stdio server in test_cli - #606
Conversation
test_cli() called the unmocked cli(), which reached main() -> mcp.run(transport="stdio") and started a real stdio MCP server against pytest's captured stdin/stdout. mcp/server/stdio.py wraps sys.stdout.buffer in a TextIOWrapper and deliberately never closes it, on the theory that leaving it alone keeps the process handle open. But TextIOWrapper.__del__ closes its underlying buffer, so when that wrapper is finalized it closes pytest's capture file and every subsequent test fails with "ValueError: I/O operation on closed file". Until now the wrapper was never finalized during a test session: it is reachable from the stdio task closures, and anyio <= 4.14.2 cached the root task in a RunVar for the lifetime of the process. anyio 4.15.1 removed that cache (agronholm/anyio#1203), so the whole graph becomes cyclic garbage and an arbitrary later gc pass closes stdout. That is the dependency-canary failure in #588, where the first casualty happened to be tests/test_audit.py and 914 errors cascaded from there. The test was not earning its keep anyway. It took ~1.4s to start a real server, and it only passed because main() raised an ExceptionGroup (pytest's fake stdin is not readable) which cli() turned into sys.exit(1) - so pytest.raises(SystemExit) was asserting the crash path. Run with -s, against a real terminal stdin, it hangs forever. Mock main() instead and assert cli() calls it. Add test_cli_fatal_error to cover the except Exception branch that the old test was covering by accident; __main__.py stays at 100% coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests.
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 3 files with indirect coverage changes 🚀 New features to boost your workflow:
|
|
The upstream bug seems to have been fixed as part of modelcontextprotocol/python-sdk#3117 - so no need to report it. I considered here whether it would be good to actually start a stdio server as part of the test suite - sort of like a mini functional test. That would go well past what was tested here previously - which as is pointed was just the stdio server crashing because stdin is closed. Since we run the real functional tests as part of CI, I don't think it is necessary, though if we repeatedly see problems where we break server startup and it isn't caught until CI runs the functional tests, we could definitely add it later. |
panyamkeerthana
left a comment
There was a problem hiding this comment.
non blocking but should we add autospec=True to this patch call to follow the testing guidance in AGENTS.md?
When I looked at the (llm-created) patch with autospec in one branch but not the others, I thought about that too ... but I decided there was no benefit in |
Fixes the
anyio4.14.2 → 4.15.1 half of the dependency-canary failure in #588.Root cause
tests/test__main__.py::test_clicalled the unmockedcli(), which reachedmain()→mcp.run(transport="stdio")and started a real stdio MCP server against pytest's captured stdin/stdout.mcp/server/stdio.pydoes:That comment is wrong:
TextIOWrapper.__del__closes its underlying buffer. Under pytestsys.stdout.bufferis the capture plugin's temp file, so finalizing that wrapper closes pytest's global capture and every later test fails withValueError: I/O operation on closed file. Reproducible with no MCP involved:anyioonly controls the timing. The wrapper is reachable from thestdin_reader/stdout_writerclosures → tasks → root task, and anyio ≤ 4.14.2 cached the root task in aRunVarfor the life of the process, so it was never finalized during a session. anyio 4.15.1 removed that cache (agronholm/anyio#1203), the graph becomes cyclic garbage, and an arbitrary later gc pass closes stdout. In the canary run the first casualty happened to betests/test_audit.py, with 914 errors cascading from there.The test wasn't earning its keep
main()raised anExceptionGroup(pytest's fake stdin isn't readable), whichcli()turned intosys.exit(1)— sopytest.raises(SystemExit)was asserting the crash path, not the intended behavior.-s, against real terminal stdin, it hangs forever.Change
Mock
main()and assertcli()calls it.test_cli_fatal_erroris added to cover theexcept Exceptionbranch the old test was covering by accident, so__main__.pystays at 100% coverage.Verification
With
anyio==4.15.1, the full suite goes from245 passed, 914 errorsto green, across threepytest-randomlyseeds.ruff check/formatclean. The two remaining failures intests/tools/test_services.pyare pre-existing and environment-dependent (they query the real local systemd/sshd) — identical on the 4.14.2 baseline.The
TextIOWrapperfinalizer is an upstream bug; I'll file it againstmodelcontextprotocol/python-sdkseparately. It also bites any embedder that runsstdio_server()and then keeps working — they lose stdout.🤖 Generated with Claude Code