Skip to content

fix(mcp): stop one command from hanging the MCP server permanently - #317

Open
Lazare-42 wants to merge 1 commit into
iOfficeAI:mainfrom
Lazare-42:mcp-stdin-deadlock
Open

fix(mcp): stop one command from hanging the MCP server permanently#317
Lazare-42 wants to merge 1 commit into
iOfficeAI:mainfrom
Lazare-42:mcp-stdin-deadlock

Conversation

@Lazare-42

Copy link
Copy Markdown

Under MCP, officecli mcp can be taken down permanently by a single tool call, and nothing about the failure is visible: no crash, no log line, the port stays open and the process stays alive.

The bug

batch with neither --commands nor --input (and --input -) falls through to reading stdin. Under MCP, stdin is not a payload channel — it is the JSON-RPC transport, a pipe the client holds open for the whole session, so ReadToEnd waits for an EOF that never arrives.

McpServer.RunAsync invokes each command inline on its single reader loop, so that one blocked read hangs the entire server. Every subsequent request queues behind it, unanswered, until the process is restarted.

An agent asking for batch doc.docx and intending to supply the array as a follow-up is enough to trigger it.

I hit this in production behind an mcp-proxy stdio→HTTP bridge. The server went dark for two days on one such call. Because nothing exited, Restart=always never fired and no supervisor noticed — the only symptom was requests timing out client-side.

Reproduces on main (v1.0.144). Hold stdin open the way a client does, then:

{"jsonrpc":"2.0","id":1,"method":"initialize", ...}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"officecli","arguments":{"command":"batch t.docx"}}}
{"jsonrpc":"2.0","id":3,"method":"ping"}

initialize is answered. Neither the tools/call nor the ping ever is.

The fix

  • CommandBuilder.ReadBatchStdIn refuses stdin when McpServer.InMcpMode, naming the two flags that do work. It keys off the mode, not the reader — StdIn bypasses Console.In by design, so a redirect-based guard alone would not cover it.
  • McpServer.RunCliRaw points Console.In at TextReader.Null as a second line of defence for anything reading stdin the ordinary way.
  • The batch stdin-warning Peek is skipped under MCP. StdIn is a shared buffered reader over the transport, so a successful Peek consumes a protocol byte into a buffer the reader loop never inspects. This one is a latent stream-corruption bug independent of the hang.
  • A 180s per-command cap (OFFICECLI_MCP_COMMAND_TIMEOUT_SECONDS, 0 restores unbounded), so a future hang of any origin costs one request instead of the server.

One subtlety worth flagging in review

The timeout requires output to be routed per thread. Console.Out resolves at call time, so the obvious approach — swap a fresh StringWriter in around each invocation — lets an abandoned command follow the swap and empty its output into the next command's response. I hit exactly that before adding the routing: a timed-out batch dumped its [1] Added paragraph… lines into an unrelated get reply.

ThreadRoutedWriter binds each command's buffers to its own thread, so an abandoned command writes only to its own garbage. The redirects are deliberately never restored: the protocol stream is RunAsync's private StreamWriter, so Console.Out is never the transport, and leaving it redirected stops a late write from landing mid-stream.

Verification

Built clean against main (0 errors). Against the patched build, all six requests above are answered: both stdin forms return an immediate actionable error, and --commands, add, get, tools/list and ping are unaffected. Separately verified with a 1s cap that a timed-out command returns its error, the server answers the next request normally, and no output bleeds across commands.

Not included: the mmdc sequential-ReadToEnd deadlock in MermaidImageRenderer, already fixed on main.

`batch` with neither --commands nor --input (and `--input -`) reads stdin.
Under MCP that is not a payload channel — it is the JSON-RPC transport, a
pipe the client holds open for the whole session, so ReadToEnd waits on an
EOF that never arrives. Because McpServer runs each command inline on its
single reader loop, that one call hangs the ENTIRE server: every later
request queues behind it unanswered until the process is restarted.

Seen in production behind an mcp-proxy stdio->HTTP bridge: one such call
took the server down for two days, and because nothing crashed and the port
stayed open, no supervisor noticed and nothing was logged. An agent asking
for `batch doc.docx` and supplying the array as a follow-up is enough.

  - CommandBuilder.ReadBatchStdIn refuses stdin when McpServer.InMcpMode,
    naming the two flags that do work. It keys off the mode, not the reader,
    because StdIn bypasses Console.In by design.
  - McpServer.RunCliRaw points Console.In at TextReader.Null as a second
    line of defence for anything reading stdin the ordinary way.
  - The batch stdin-warning Peek is skipped under MCP: StdIn is a shared
    buffered reader over the transport, so a successful Peek consumes a
    protocol byte into a buffer the reader loop never inspects.
  - RunCliRaw runs each command on a dedicated background thread with a
    180s cap (OFFICECLI_MCP_COMMAND_TIMEOUT_SECONDS, 0 restores the old
    unbounded behaviour), so a future hang of any origin costs one request
    rather than the server.

The timeout needs output routed per thread. Console.Out resolves at call
time, so swapping a fresh StringWriter in per invocation lets an abandoned
command follow the swap and empty its output into the next command's
response — verified before the routing was added. ThreadRoutedWriter binds
each command's buffers to its own thread, so an abandoned one writes only
to its own garbage. The redirects are never restored: the protocol stream
is RunAsync's private StreamWriter, so Console.Out is never the transport,
and leaving it redirected keeps a late write from landing mid-stream.
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