posit-dev / posit-dev/mcptools

stdio transport deadlocks on Windows: responses block in nanonext::write_stdout and child-process-spawning tools hang

Open
#128 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
R
Stars
196
Forks
21
Avg merge
1h 14m
Merged PRs (30d)
1

Description

Title

stdio transport deadlocks on Windows: responses block in nanonext::write_stdout, and tool execution producing child processes hangs to the point where the server is unusable

Environment

  • Windows 11 (10.0.2xxxx), x64
  • R 4.6.1
  • mcptools 1.0.1 (CRAN), btw 1.4.0, ellmer 0.4.2, S7 0.2.2, nanonext 1.10.2
  • MCP client: MetaMCP (stdio spawn), but any stdio client should be affected

Summary

Running a server over the default stdio transport (type = "stdio") on Windows, every tools/call hangs: the client never receives a response, so it times out (typically -32001: Request timed out). I traced two distinct bugs, both in the nanonext-based stdio path. Switching to the HTTP transport (type = "http" using httpuv) makes the identical server work perfectly, which isolates the problem to the stdio transport.

Bug 1 — response writing blocks on the pipe

cat_json() sends responses via nanonext::write_stdout(). Short/empty payloads are fine, but a real response (a multi-line, ~6.7 kB help page) blocks indefinitely, so the client's timeout fires with no reply.

Supported by per-step instrumentation of handle_request():

append_tool_fn       -> ok (list)
execute_tool_call    -> ok (list)          # tool ran, result correct
to_json(result)      -> ok (~6.7 kB)       # serialization is fine
logcat               -> ok (file write)
cat_json(result)     -> HANGS              # nanonext::write_stdout on the pipe

cat_json(list(...)) with a small payload returns fine, and nanonext::write_stdout("plain") alone also returns fine — so the block is specific to writing the large/real string to an OS pipe. Overriding cat_json in the namespace to cat(to_json(x), "\n", sep = "") makes initialize and tools/call responses flow immediately — confirming the nanonext write path is the blocker, not the JSON or the tools.

Bug 2 — child-process spawning deadlocks once read_stdin() owns the stdio pipes

After the fix for Bug 1, initialize works, but tools/call then hangs inside execute_tool_call():

ETC start; tool class: ellmer::ToolDef
about to do.call
# never returns

The same do.call(tool, args) on the same registered ellmer::ToolDef returns in ~2.7 s in an isolated R process; it only hangs when called from inside the mcp_server_stdio() event loop after nanonext::read_stdin() has taken over the stdio pipes. The tool in question (btw_tool_docs_help_page) renders help via pandoc in a child process — once nanonext owns the stdio pipes, that child-process spawn deadlocks. (logcat() itself is fine: it writes to a log file, not stdout.)

Note discover_session_slot() returns NULL on Windows, so this hang is fully in-process, not in the session-forwarding path.

Working reproduction sketch

# Server
Rscript -e "options(repos=c(CRAN='https://cloud.r-project.org')); btw::btw_mcp_server()"
# Client: spawn the process, send line-delimited JSON-RPC over stdin:
# initialize -> responds fine
# tools/call btw_tool_docs_help_page {package: 'terra', topic: 'extract'} -> never responds

Fixing Bug 1 alone (cat_json override) reproduces Bug 2; fixing nothing, Bug 1 ships as the client-observed hang. With the HTTP transport (mcp_server(type = "http")), both initialize and the same tools/call return correct 200 responses with the full payload — no patches needed. That points at the nanonext stdio integration (read/write on OS pipes) as the component needing rework, consistent in spirit with #120's request for non-blocking stdio handling.

Workaround in use

Serve over HTTP and point the MCP client at http://127.0.0.1:<port> (Streamable HTTP, validated working: initialize + tools/call, also at / and /mcp). For a session_tools-free in-process server I call mcptools::mcp_server(tools = btw:::btw_mcp_tools() |> btw:::flatten_and_check_tools(), type = "http", session_tools = FALSE).

Happy to provide more detail, and thanks for a great package — the HTTP transport is rock solid.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with mcp_server_stdio(), read_stdin(), cat_json(), and the nanonext read/write calls described in the report. Reproduce the Windows stdio server with the supplied Rscript and line-delimited JSON-RPC sketch, then compare it with the working HTTP transport. Done means initialize and tools/call return complete responses without hangs, including tools that spawn child processes.

Written by the indexing model from the issue text.

Assessment

Tech stack
r
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.