Skip to content

fix(mcp): survive stdio host disconnect and silence handled MCP noise#79

Merged
lukeocodes merged 1 commit into
mainfrom
fix/mcp-stdio-stream-safety
May 12, 2026
Merged

fix(mcp): survive stdio host disconnect and silence handled MCP noise#79
lukeocodes merged 1 commit into
mainfrom
fix/mcp-stdio-stream-safety

Conversation

@lukeocodes
Copy link
Copy Markdown
Member

Summary

dg mcp --transport stdio was crashing when the AI host (Claude Desktop, Codex, etc.) closed the JSON-RPC stdio channel on disconnect. Anything that wrote to the global rich Console after that raised ValueError: I/O operation on closed file and surfaced as an unhandled exception (DX-CLI-4, DX-CLI-5 — 88 events in 14d, 27 in the last hour alone, still firing as of writing).

This PR adds three layered fixes:

1. dg mcp writes to stderr and returns None for stdio

packages/deepctl-cmd-mcp/src/deepctl_cmd_mcp/command.py

  • Module console is now stderr_console from deepctl_core.output. Writing anything to stdout while the MCP host owns it would corrupt the JSON-RPC stream anyway.
  • For transport=stdio the handler returns None instead of MCPServerResult. That skips BaseCommand.output_result entirely so there is nothing to write to the closed protocol channel after the proxy exits.
  • The other branches (sse, errors, KeyboardInterrupt) keep returning MCPServerResult. Those have a real terminal to print to.

2. BaseCommand.execute traps closed-stream writes around output_result

packages/deepctl-core/src/deepctl_core/base_command.py

Defense in depth so any future stdio-mode command inherits the same safety. Catches BrokenPipeError, OSError, and ValueError("...closed file..."). Unrelated ValueErrors still propagate as ClickException so real bugs aren't hidden.

3. deepctl-telemetry drops handled MCP SDK log noise

packages/deepctl-telemetry/src/deepctl_telemetry/client.py

_scrub_event now drops events whose logger is in:

  • mcp.client.streamable_http (the upstream MCP returned 5xx, recovered by the client — DX-CLI-1)
  • mcp.server.lowlevel.server (stdio peer closed mid-message, recovered by the server — DX-CLI-3)

…provided no unhandled exception is attached. Unhandled crashes from those loggers (e.g. a real bug in the embedded SDK) are still surfaced.

Sentry issues addressed

Issue Title Outcome
DX-CLI-4 ValueError: I/O operation on closed file. (rich/console) killed at source: stdio handler returns None + execute traps closed-stream writes
DX-CLI-5 ValueError: I/O operation on closed file. (base_command:124) same
DX-CLI-1 HTTPStatusError: 503 from /kapa/mcp dropped by _scrub_event (handled by MCP SDK)
DX-CLI-3 Received exception from stream: 1 validation error for JSONRPCMessage dropped by _scrub_event (peer-closed noise)
DX-CLI-2 AttributeError: 'Request' object has no attribute 'send' not in this PR — bug lives in the upstream deepgram-mcp package's proxy.py:handle_sse. Needs a separate fix there.

Companion PR

This is the client side of deepgram/dx-api#67, which fixed the same architectural family (handle upstream/downstream stream disconnects cleanly) on the server end.

Test plan

  • make test (full suite): 965 passed, 0 failed
  • make lint: clean
  • make format-check: 115 files already formatted

New tests:

  • TestExecuteStreamSafety in packages/deepctl-core/tests/unit/test_base.py
    • swallows ValueError("I/O operation on closed file")
    • swallows BrokenPipeError
    • still propagates unrelated ValueErrors (negative invariant guard)
  • TestMcpNoiseFilter in packages/deepctl-telemetry/tests/unit/test_telemetry.py
    • drops 5xx HTTPStatusError from mcp.client.streamable_http
    • drops message-only event from mcp.server.lowlevel.server
    • keeps unhandled exceptions even from MCP loggers
    • keeps events from non-MCP loggers
    • integration with _scrub_event: returns None for noise, still scrubs headers/cookies/user data for normal events
  • Updated test_handle_stdio_transport_returns_none and test_handle_api_key_from_auth_manager to expect None for stdio (the new contract)

Backwards compatibility

McpCommand.handle for stdio mode now returns None instead of MCPServerResult(status="success", ...). Anything programmatically calling handle() for stdio and reading .status will need to treat None as success. In practice this is internal to BaseCommand.execute, which already handles result is None.

Related

`dg mcp --transport stdio` runs under an MCP host (Claude Desktop,
Codex, etc.) which owns stdout for the JSON-RPC protocol and typically
closes it on disconnect. After that, anything that writes to the global
rich Console raises "ValueError: I/O operation on closed file" and the
CLI exits with an unhandled crash (DX-CLI-4, DX-CLI-5; 88 events in the
last 14 days, still firing).

Three layered fixes:

1. dg mcp now uses stderr_console as its module console and returns
   None for stdio transport so output_result is never invoked with
   content destined for the closed protocol channel.

2. BaseCommand.execute traps BrokenPipeError, OSError, and the
   specific closed-file ValueError around output_result so any other
   stdio-mode command in the future inherits the same safety.
   Unrelated ValueErrors still propagate as ClickException so real
   bugs aren't hidden.

3. deepctl-telemetry's _scrub_event now drops handled log events from
   the upstream mcp SDK (mcp.client.streamable_http,
   mcp.server.lowlevel.server). These are recovered internally by the
   SDK when an upstream MCP server returns 5xx or a stdio peer closes
   mid-message. They were ending up as Sentry issues (DX-CLI-1,
   DX-CLI-3) with no actionable signal for the DX team. Unhandled
   exceptions from those loggers are still kept.

Fixes DX-CLI-1, DX-CLI-3, DX-CLI-4, DX-CLI-5 in the dx-cli Sentry
project. DX-CLI-2 lives in the upstream deepgram-mcp package and needs
a separate fix there.
@lukeocodes lukeocodes merged commit e95d511 into main May 12, 2026
38 checks passed
@lukeocodes lukeocodes deleted the fix/mcp-stdio-stream-safety branch May 12, 2026 17:36
@github-actions github-actions Bot mentioned this pull request May 12, 2026
lukeocodes pushed a commit that referenced this pull request May 12, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>0.2.25</summary>

## [0.2.25](v0.2.24...v0.2.25)
(2026-05-12)


### Bug Fixes

* **mcp:** survive stdio host disconnect and silence handled MCP noise
([#79](#79))
([e95d511](e95d511))
</details>

<details><summary>deepctl-core: 0.2.13</summary>

##
[0.2.13](deepctl-core-v0.2.12...deepctl-core-v0.2.13)
(2026-05-12)


### Bug Fixes

* **mcp:** survive stdio host disconnect and silence handled MCP noise
([#79](#79))
([e95d511](e95d511))
</details>

<details><summary>deepctl-telemetry: 0.0.5</summary>

##
[0.0.5](deepctl-telemetry-v0.0.4...deepctl-telemetry-v0.0.5)
(2026-05-12)


### Bug Fixes

* **mcp:** survive stdio host disconnect and silence handled MCP noise
([#79](#79))
([e95d511](e95d511))
</details>

<details><summary>deepctl-cmd-mcp: 0.1.14</summary>

##
[0.1.14](deepctl-cmd-mcp-v0.1.13...deepctl-cmd-mcp-v0.1.14)
(2026-05-12)


### Bug Fixes

* **mcp:** survive stdio host disconnect and silence handled MCP noise
([#79](#79))
([e95d511](e95d511))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant