Skip to content

plugin/client: add mcp_stdio plugin#5301

Open
ailenshen wants to merge 4 commits intofatedier:devfrom
ailenshen:feature/mcp-stdio-plugin
Open

plugin/client: add mcp_stdio plugin#5301
ailenshen wants to merge 4 commits intofatedier:devfrom
ailenshen:feature/mcp-stdio-plugin

Conversation

@ailenshen
Copy link
Copy Markdown

WHY

Most MCP (Model Context Protocol) servers in the wild ship as stdio
binaries — npx -y @org/mcp-..., uvx ... and similar — and only
know 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_stdio client plugin that folds that translation
into 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

  • Spawns the configured stdio command lazily on the first request.
  • For each inbound HTTP POST, writes the body as a JSON-RPC line to
    the child's stdin and returns the next stdout line as the HTTP
    response. Notifications (frames without an id) are accepted with
    HTTP 202 and produce no stdio output.
  • Optional idleTimeoutSeconds: kill the child after N seconds of
    inactivity. The MCP initialize handshake is cached and replayed
    when 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 @latest style 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

  • Real apple-notes and filesystem MCP servers end-to-end through a
    public frps, reachable from Claude Desktop and claude.ai.
  • Unit tests cover JSON-RPC classification, dispatch, notifications,
    and the respawn-with-replay path.
  • `golangci-lint run ./pkg/plugin/client/... ./pkg/config/v1/...`
    passes.

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
@Qodo-Free-For-OSS
Copy link
Copy Markdown

Hi, dispatch() performs blocking stdin/stdout I/O while holding p.mu and has no timeouts/cancellation, so a stalled child can hang one request indefinitely and block all subsequent requests; it can also prevent Close() from acquiring the mutex to kill the child during shutdown.

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:

Issue description

dispatch() holds a global mutex while doing unbounded blocking reads from the child’s stdout. If the child never produces a newline, requests hang indefinitely and all subsequent requests to the plugin block behind the mutex; Close() can also block waiting for the same mutex.

Issue Context

The HTTP handler currently does not use r.Context(), and the child process is started without a context/deadline. http.Server only sets ReadHeaderTimeout, so body reads and backend stdio waits can last forever.

Fix Focus Areas

  • pkg/plugin/client/mcp_stdio.go[111-181]
  • pkg/plugin/client/mcp_stdio.go[203-255]
  • pkg/plugin/client/mcp_stdio.go[313-321]

What to change

  • Stop holding p.mu across blocking I/O (e.g., implement a single worker goroutine that owns the child pipes and processes requests via a channel, or lock only to swap/check child state and do I/O outside the lock).
  • Plumb request cancellation into dispatch (e.g., accept a context.Context from handle() and use it to bound waiting for stdout).
  • Add read/write deadlines for the child I/O and/or a per-request timeout to fail fast and respawn/kill the child on timeout.
  • Ensure Close() can always terminate quickly (e.g., kill the child before waiting on anything that could require acquiring p.mu, or make the worker goroutine observe a close signal).

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>
@ailenshen
Copy link
Copy Markdown
Author

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 Close() from acquiring the lock during shutdown.

Addressed in the latest commit by refactoring to a worker goroutine pattern:

  • A single worker() goroutine exclusively owns the child process and all its I/O — no mutexes needed
  • dispatch() communicates with the worker via channels and respects r.Context() for cancellation throughout
  • A 30-second per-request read timeout kills and respawns the child on a stalled response (the goroutine spawned for the read never leaks since the buffered channel absorbs its result after the pipe closes)
  • Close() simply signals closeCh and waits on workerDone — no lock contention possible
  • Idle reaping is merged into the worker's select loop, removing the separate reapLoop goroutine

http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/plugin/client/mcp_stdio.go Outdated
killChild()
return fmt.Errorf("replay initialize: %w", err)
}
if _, err := childOut.ReadBytes('\n'); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread pkg/plugin/client/mcp_stdio.go Outdated
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")}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kilo-code-bot
Copy link
Copy Markdown

kilo-code-bot Bot commented May 1, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Resolved Previous Findings
File Line Issue Resolution
pkg/plugin/client/mcp_stdio.go 120 Request bodies were read without a size limit. Resolved by wrapping the body in http.MaxBytesReader.
pkg/plugin/client/mcp_stdio.go 237 Handshake replay could block the worker indefinitely. Resolved by using a timed readLine helper for replay reads.
pkg/plugin/client/mcp_stdio.go 289 Pretty-printed JSON could split into multiple stdio frames. Resolved by compacting request JSON before dispatch.
pkg/plugin/client/mcp_stdio.go 323 Stdout responses were not matched to the request id. Resolved by reading until a matching JSON-RPC id is found.
Files Reviewed (2 files)
  • pkg/plugin/client/mcp_stdio.go
  • pkg/plugin/client/mcp_stdio_test.go

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>
@ailenshen
Copy link
Copy Markdown
Author

Thanks for the detailed review. All four warnings addressed in the latest commit:

1. Body size limit — wrapped r.Body with http.MaxBytesReader(1 MiB) before io.ReadAll.

2. Replay handshake blocking — extracted a readLine helper closure (goroutine + buffered channel + time.After select) and replaced the bare childOut.ReadBytes in the ensureChild replay path.

3. Pretty-printed JSON splitting frames — added json.Compact in handle() after reading the body; multi-line input is collapsed to a single line before being forwarded to the child's stdin.

4. Response not matched by id — added a readResponse helper that loops, discarding lines whose JSON-RPC id does not match the request's id, until the correct response arrives. The MCP spec (transports) explicitly permits servers to send notifications (e.g. notifications/progress) before the response to a request, so reading only the next line was a protocol-level bug. A single time.After deadline is shared across iterations so chatty servers cannot extend the timeout.

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.

2 participants