posit-dev / posit-dev/mcptools
Claude Code ↔ RStudio MCP bridge hangs silently — enterprise security blocks IPC sockets, TCP fix included
Nobody has claimed this yet.
- Dominant language
- R
- Stars
- 196
- Forks
- 21
- Avg merge
- 1h 14m
- Merged PRs (30d)
- 1
Description
Claude Code ↔ RStudio MCP bridge hangs silently — enterprise security blocks IPC sockets, TCP fix included
Problem
When session_tools = TRUE (the default), all tools/call requests (except list_r_sessions / select_r_session) are forwarded to a connected R session via forward_request(). The forwarded call never returns — the MCP server hangs indefinitely with no error or timeout.
Setting session_tools = FALSE and executing tools locally in the MCP server process works perfectly.
Environment
- macOS 26.4 (Tahoe), Apple Silicon
- R 4.5.2
- mcptools 0.2.1 (CRAN)
- btw 1.2.1 (CRAN)
- nanonext (current CRAN version)
- Claude Code 2.1.92 as MCP client
- Palo Alto Cortex XDR endpoint security extension active (may be relevant — see below)
Reproduction
- Register MCP server:
Rscript -e 'btw::btw_mcp_server()' - In RStudio, run
btw::btw_mcp_session() - From the MCP client, call any btw tool (e.g.
btw_tool_sessioninfo_platform) - The call hangs forever. No response on stdout, no error on stderr. User has to interrupt call.
list_r_sessions works because it is excluded from forwarding in handle_message_from_client().
Diagnostic methodology
1. Wrapper script to capture stdin/stdout
Created a bash wrapper that logs both sides of the MCP stdio transport:
#!/bin/bash
LOG="$HOME/Desktop/btw_debug.log"
# Capture stdin (what the client sends TO the server)
tee -a "${LOG}.stdin" < /dev/stdin > "$FIFO" &
# Run server, capture stdout (what the server sends BACK)
Rscript -e 'btw::btw_mcp_server()' \
< "$FIFO" \
> >(tee -a "${LOG}.stdout") \
2> >(tee -a "${LOG}.stderr" >&2)
Result: stdin log confirmed tool calls were reaching the server. stdout log showed responses for initialize (id=0), tools/list (id=1), prompts/list error (id=2), resources/list error (id=3), and list_r_sessions (id=4) — but zero output for btw_tool_sessioninfo_platform (id=5). The server received the request but never wrote a response.
2. FIFO-based MCP protocol simulation
Sent JSON-RPC messages manually to isolate the server from the MCP client:
FIFO=$(mktemp -u); mkfifo "$FIFO"
Rscript -e "btw::btw_mcp_server()" < "$FIFO" > out.txt 2> err.txt &
exec 3>"$FIFO"
# Send initialize
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' >&3
sleep 3
echo '{"jsonrpc":"2.0","method":"notifications/initialized"}' >&3
sleep 2
# Send tool call
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"btw_tool_sessioninfo_platform","arguments":{"_intent":"test"}}}' >&3
sleep 5
exec 3>&-
Result: Same behaviour — initialize responded, tool call produced no output.
3. Event loop liveness test
After the hung tool call, sent a tools/list request to check if the server was still alive:
# After tool call hangs...
echo '{"jsonrpc":"2.0","id":99,"method":"tools/list"}' >&3
Result: tools/list (id=99) responded immediately. The event loop is alive and processing messages — only the forwarded tool call is silently dropped.
4. Runtime patching to trace dispatch
Patched handle_message_from_client at startup to log the forwarding decision:
original_handler <- mcptools:::handle_message_from_client
assignInNamespace("handle_message_from_client", function(line) {
data <- tryCatch(jsonlite::parse_json(line), error = function(e) NULL)
if (!is.null(data) && identical(data$method, "tools/call")) {
env <- mcptools:::the
message("[CHECK] sessions_enabled: ", env$sessions_enabled)
pipes_val <- nanonext::stat(env$server_socket, "pipes")
message("[CHECK] pipes: ", pipes_val)
local_exec <- !env$sessions_enabled ||
data$params$name %in% c("list_r_sessions", "select_r_session") ||
!nanonext::stat(env$server_socket, "pipes")
message("[CHECK] would execute locally: ", local_exec)
}
original_handler(line)
}, "mcptools")
Result:
[CHECK] sessions_enabled: TRUE
[CHECK] pipes: 1
[CHECK] would execute locally: FALSE
Confirmed: the tool call is being forwarded (not executed locally) because sessions_enabled = TRUE and pipes = 1.
5. Tracing tool dispatch functions
Patched execute_tool_call, call_tool, mcp_request_tool_call, and as_tool_call_result — none were called during the forwarded tool call. This confirms the request goes into forward_request() -> nanonext::send_aio() and the response path via handle_message_from_session() never fires.
6. Direct function test
Called the tool function directly outside the MCP server:
x <- btw::btw_tool_sessioninfo_platform()
print(x)
# Works perfectly, returns in <1 second
The tool itself is fine — the issue is exclusively in the nanonext IPC forwarding path.
Analysis of the forwarding code
In mcp_server_stdio(), when sessions_enabled = TRUE:
the$server_socket <- nanonext::socket("poly")
nanonext::dial(the$server_socket, url = sprintf("%s%d", the$socket_url, 1L))
The server dials to ipc:///tmp/mcptools-socket1. On the RStudio side, mcp_session() listens on the same address. The connection is established (pipes = 1).
forward_request() calls append_tool_fn(data) to attach the R tool function to the request, then sends via nanonext::send_aio(the$server_socket, prepared, mode = "serial").
On the RStudio side, the receive handler is promise-based:
the$raio <- nanonext::recv_aio(the$session_socket, mode = "serial")
promises::as.promise(the$raio)$then(handle_message_from_server)$catch(function(e) {})
The empty .catch(function(e) {}) silently swallows any error.
Evidence pointing to nanonext IPC as the issue
As a control test, we installed ClaudeR — an alternative MCP server that connects to the same live R session but uses HTTP on localhost:8787 (via httpuv) instead of nanonext Unix domain sockets. ClaudeR's execute_r("ls()") immediately returned the objects in the R session's global environment. This confirms:
- The R session is responsive and accessible
- The MCP client (Claude Code) is working correctly
- The issue is specifically the nanonext IPC socket transport, not the R session, IDE, or MCP protocol
Possible causes
-
Endpoint security software (most likely) — This machine runs Palo Alto Cortex XDR with an Endpoint Security Extension (
TrapsSecurityExtension). This hooks into macOS's Endpoint Security Framework and monitors IPC. The nanonext socket files at/tmp/mcptools-socket*are created and the connection appears established, but data may not flow through. The fact that HTTP localhost works (ClaudeR) while Unix domain sockets don't (mcptools) is consistent with endpoint security selectively blocking IPC socket communication. I was unable to fully disable Cortex XDR to confirm. -
Promise resolution in RStudio — The
recv_aiopromise on the RStudio side may not be resolving. Iflateris not actively polling the nanonext aio, the message is never received. -
Silent error in
.catch— If deserialization of the forwarded request fails on the RStudio side, the empty catch handler drops the error andhandle_message_from_serveris never called, so no response is sent.
Suggestions
- Add a
transportoption tomcp_server()/mcp_session()— e.g.transport = c("ipc", "tcp", "http"). The current nanonext IPC transport (ipc:///tmp/mcptools-socket*) is blocked by some enterprise endpoint security software. A TCP (tcp://127.0.0.1:PORT) or HTTP (httpuv) alternative would let users on managed machines switch transports without abandoning session bridging entirely. This could be as simple as:mcp_server(session_tools = TRUE, transport = "tcp") # uses tcp://127.0.0.1:PORT instead of ipc:// mcp_session(transport = "tcp") - Add a timeout to
forward_request()— if no response comes back within N seconds, return an error to the MCP client instead of hanging forever. - Log errors in the
.catchhandler —function(e) { message("[mcptools] session error: ", conditionMessage(e)) }would make debugging much easier. - Add a diagnostic mode — e.g.
mcp_session(verbose = TRUE)to log when messages are received and responses are sent on the RStudio side.
Workaround (full fix — TCP transport)
The issue is specifically with Unix domain sockets (ipc://). Switching to TCP localhost restores full session bridging, including live environment inspection. The fix patches the$socket_url on both the server and session sides before startup.
MCP server side — save as e.g. ~/.claude/r-btw-server.R and register with Claude Code:
library(mcptools)
ns <- asNamespace("mcptools")
unlockBinding("the", ns)
the_env <- get("the", envir = ns)
the_env$socket_url <- "tcp://127.0.0.1:4777"
btw::btw_mcp_server()
claude mcp add -s "user" r-btw -- Rscript --vanilla ~/.claude/r-btw-server.R
R session side — add to .Rprofile so it runs automatically:
# Patch mcptools to use TCP instead of IPC (bypasses endpoint security blocking Unix sockets)
if (interactive() && requireNamespace("mcptools", quietly = TRUE)) {
ns <- asNamespace("mcptools")
unlockBinding("the", ns)
the_env <- get("the", envir = ns)
the_env$socket_url <- "tcp://127.0.0.1:4777"
btw::btw_mcp_session()
}
Confirmed working: After this patch, btw_tool_env_describe_data_frame and btw_tool_env_describe_environment successfully return objects from the live RStudio session. The TCP transport is not blocked by Palo Alto Cortex XDR.
Control test: ClaudeR, which uses HTTP on localhost:8787 (httpuv) instead of nanonext, also worked immediately — further confirming the issue is specific to Unix domain socket IPC, not the R session or MCP protocol.
Fallback workaround (no session bridging)
If TCP also doesn't work in your environment:
claude mcp add -s "user" r-btw -- \
Rscript -e 'mcptools::mcp_server(tools = btw:::btw_mcp_tools(), session_tools = FALSE)'
This executes all tools locally in the MCP server process. Loses live environment inspection but all other tools work correctly.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with mcp_server_stdio(), forward_request(), and the session-side recv_aio promise in mcp_session(); trace how requests and errors move across the nanonext socket. Compare the existing IPC setup with the documented TCP workaround, then verify that forwarded tool calls either return successfully or produce a visible timeout/error instead of hanging.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- r
- Domain
- backend, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100