plugin/client: add mcp_stdio plugin#5301
Conversation
Adds a client plugin that lets you expose a local stdio-based MCP (Model Context Protocol) server -- a child process speaking JSON-RPC over stdin/stdout -- as a Streamable HTTP endpoint through frp. This removes the need for a separate stdio-to-HTTP translator (such as mcp-proxy) when making local stdio MCP servers (Apple Notes, filesystem, etc.) reachable by remote MCP clients like Claude Desktop or the Anthropic API. frpc spawns the configured command lazily on the first request and optionally kills it after a configurable idle window (idleTimeoutSeconds). The MCP `initialize` handshake is cached and replayed when the child is respawned, so sessions stay healthy across reaps and the new child also picks up any updated package version on the next spawn (useful with `@latest` style npx commands). Example: [[proxies]] name = "apple-notes" type = "tcp" remotePort = 3101 [proxies.plugin] type = "mcp_stdio" command = ["npx", "-y", "@modelcontextprotocol/server-everything"] idleTimeoutSeconds = 300
|
Hi, Severity: action required | Category: reliability How to fix: Add timeouts and cancellation Agent prompt to fix - you can give this to your LLM of choice:
We noticed a couple of other issues in this PR as well - happy to share if helpful. Found by Qodo code review. FYI, Qodo is free for open-source. |
Replace the mutex-protected dispatch approach with a single worker goroutine that owns the child process and all its I/O. This eliminates the risk of the global lock being held indefinitely during a blocked read, ensures Close() can always terminate promptly, and plumbs r.Context() through dispatch so client cancellations are respected. A 30-second per-request read timeout is also added to fail fast and respawn the child on a stalled response. Idle reaping is merged into the worker's select loop, removing the separate reapLoop goroutine. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for the review. The concerns are valid — the original implementation held the global mutex across blocking I/O, which could stall all requests if the child process hung, and prevented Addressed in the latest commit by refactoring to a worker goroutine pattern:
|
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||
| return | ||
| } | ||
| body, err := io.ReadAll(r.Body) |
There was a problem hiding this comment.
WARNING: Request bodies are read without a size limit
This endpoint can be exposed through frps, so a client can send an arbitrarily large POST body and force frpc to buffer it all in memory before any validation. MCP stdio messages are single JSON-RPC frames, so bound the reader (and ideally the body read duration) before io.ReadAll to avoid memory exhaustion from large or slow requests.
| killChild() | ||
| return fmt.Errorf("replay initialize: %w", err) | ||
| } | ||
| if _, err := childOut.ReadBytes('\n'); err != nil { |
There was a problem hiding this comment.
WARNING: Handshake replay can block the worker indefinitely
The per-request timeout only wraps the later response read, but a respawn with cachedInitReq can block forever here if the new child never returns an initialize response. While blocked in ensureChild, the worker cannot process closeCh, new requests, or idle reaping, so the plugin can hang during shutdown or after a bad child spawn; apply the same timeout/cancellation behavior to replay reads.
| cachedInitNote = append(cachedInitNote[:0], req.body...) | ||
| } | ||
|
|
||
| if _, err := fmt.Fprintf(childIn, "%s\n", req.body); err != nil { |
There was a problem hiding this comment.
WARNING: Valid pretty-printed JSON is split into multiple stdio frames
The HTTP transport accepts normal JSON whitespace, but this writes the raw body directly to an MCP stdio transport where each message is newline-delimited. A valid pretty-printed JSON-RPC request with internal newlines will be interpreted by the child as several malformed frames; compact or re-marshal the JSON before writing and caching it.
| req.replyCh <- dispatchResp{err: fmt.Errorf("read stdout: %w", r.err)} | ||
| } else { | ||
| lastUsedAt = time.Now() | ||
| req.replyCh <- dispatchResp{data: bytes.TrimRight(r.line, "\r\n")} |
There was a problem hiding this comment.
WARNING: Stdout responses are not matched to the request id
MCP servers can emit JSON-RPC notifications or progress messages before the response to a request. Returning the first stdout line can send an unrelated notification as the HTTP response and leave the real response queued for the next caller; parse stdout messages and return only the response whose id matches the dispatched request.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Resolved Previous Findings
Files Reviewed (2 files)
Reviewed by gpt-5.5-2026-04-23 · 873,693 tokens |
- Body size: wrap r.Body with MaxBytesReader (1 MiB) to prevent memory exhaustion from unbounded client payloads - JSON framing: compact request bodies to a single line before forwarding to the child, so pretty-printed JSON does not split into multiple stdio frames - Replay timeout: extract readLine helper closure that wraps ReadBytes in a goroutine with a 30s timeout; use it for the initialize replay read in ensureChild so a hung child cannot wedge the worker indefinitely - ID matching: add readResponse helper that loops discarding server notifications/progress until a line with a matching JSON-RPC id is found; the MCP spec explicitly allows servers to send notifications before the response (e.g. notifications/progress for long operations) so reading only the next line was incorrect; a single deadline shared across iterations prevents chatty servers from extending the timeout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Thanks for the detailed review. All four warnings addressed in the latest commit: 1. Body size limit — wrapped 2. Replay handshake blocking — extracted a 3. Pretty-printed JSON splitting frames — added 4. Response not matched by id — added a |
WHY
Most MCP (Model Context Protocol) servers in the wild ship as stdio
binaries —
npx -y @org/mcp-...,uvx ...and similar — and onlyknow how to speak JSON-RPC over stdin/stdout. To make them reachable
by remote MCP clients like Claude Desktop or the Anthropic API today,
you have to run a separate stdio↔HTTP translator (e.g.
mcp-proxy)locally alongside frpc.
This PR adds an
mcp_stdioclient plugin that folds that translationinto frpc itself, so a single frpc process is enough to expose any
number of stdio MCP servers as remote Streamable HTTP endpoints — no
extra translator process and no new runtime dependencies.
What it does
the child's stdin and returns the next stdout line as the HTTP
response. Notifications (frames without an
id) are accepted withHTTP 202 and produce no stdio output.
idleTimeoutSeconds: kill the child after N seconds ofinactivity. The MCP
initializehandshake is cached and replayedwhen the child is respawned, so sessions stay healthy across reaps
and the next spawn picks up any updated package version on its own
(useful with
@lateststyle npx commands).Configuration
```toml
[[proxies]]
name = "apple-notes"
type = "tcp"
remotePort = 3101
[proxies.plugin]
type = "mcp_stdio"
command = ["npx", "-y", "@modelcontextprotocol/server-everything"]
idleTimeoutSeconds = 300
```
Tested with
public frps, reachable from Claude Desktop and claude.ai.
and the respawn-with-replay path.
passes.