Skip to content

MCP proxy

graphmind mcp-proxy is the one integration that needs nothing from you but a change to how the server is spawned. It speaks stdio JSON-RPC to your MCP host on one side and to your server on the other, relays every frame byte for byte, and reports the conversation to GraphMind as a live run you can watch, hold and steer.

Because it sits at the protocol boundary rather than inside a process, it works on a server written in any language — TypeScript, Python, Go, Rust, a binary somebody handed you — and on servers you did not write and cannot modify.

It ships in graphmind-ai, so there is nothing to install beyond the CLI. It is new, so make sure you are on a release that has it — npx graphmind-ai@latest, or check that graphmind mcp-proxy --help prints something.

Terminal window
graphmind mcp-proxy -- node build/server.js
graphmind mcp-proxy -- python -m my_server
graphmind mcp-proxy -- ./target/debug/my-server --flag
graphmind mcp-proxy --trace -- npx -y @modelcontextprotocol/server-everything

Everything after -- is your server’s command line, run unchanged as a child process.

Move your existing command inside the proxy. The host still spawns one process; that process just happens to be the proxy now.

Terminal window
claude mcp add my-server-debug -- npx -y graphmind-ai mcp-proxy -- node my-server.js

Adding it under a second name lets you keep the direct one and switch by enabling whichever you want.

Then run graphmind in another terminal and restart the host so it re-spawns the server.

FlagMeaning
--traceOne line per frame on stderr. Works with no GraphMind running at all — a tee for the protocol.
--wait-for-attachHold the first frame until the debugger attaches (up to 3s), so even initialize can be gated.
--inherit-stderrGive the server the real stderr fd instead of piping it. Keeps isatty(2) true for servers that care; the server’s log then does not appear on the session node.
--max-frame-bytes <n>Frame-assembly ceiling, default 64 MiB. Above it the proxy stops parsing and becomes a raw byte pipe.
--port <n>GraphMind port to report to (default 4747).

Run graphmind mcp-proxy with no command and it prints a working recipe rather than a synopsis.

One run per session. Under the session node, the calls that do work sit directly; everything else is folded under one node named protocol:

mcp:session kind server — the command you ran
├─ protocol (mcp:protocol) kind custom — opens folded in the viewer
│ ├─ mcp:initialize, mcp:notifications/initialized, mcp:tools/list, mcp:ping, …
│ └─ mcp:stdout-noise only if the server writes non-JSON-RPC lines to stdout
├─ tool:<name> tools/call
├─ resource:<uri> resources/read
├─ prompt:<name> prompts/get
├─ llm:sampling sampling/createMessage (server → client)
└─ mcp:elicitation/…, mcp:completion/complete
MethodnodeIdKindParent
tools/call (client → server)tool:<name>toolmcp:session
resources/read (client → server)resource:<uri>resourcemcp:session
prompts/get (client → server)prompt:<name>promptmcp:session
completion/complete (client → server)mcp:completion/completecustommcp:session
sampling/createMessage (server → client)llm:samplingllmmcp:session
elicitation/* (server → client)mcp:elicitation/…custommcp:session
the sessionmcp:sessionserver—
everything else: initialize, server/discover, */list, ping, logging/setLevel, subscriptions, tasks/*, notifications, and any method GraphMind does not recognisemcp:<method>custommcp:protocol

The direction matters. A tools/call that travels from the server to the client, as an echoing or hostile server might send, is protocol traffic, not a second tool call. It used to be recorded as one.

protocol is created on the first protocol frame, so a session with only work has no empty card. It closes before the session with output: { calls, errors, stdoutNoise }. Folding is only how it is drawn. Every call inside is still a node: a breakpoint on initialize still holds, and a failing handshake call still pauses on the error gate and turns the folded card red. See the viewer.

nodeId is stable per logical node, so ten calls to one tool are ten instances of one node on the canvas rather than ten boxes — the same rule every GraphMind adapter follows. instanceId is a per-session counter (mcp_1, mcp_2, …) rather than the JSON-RPC request id, because notifications have no id at all and two connections would otherwise collide.

Two behaviours are worth knowing:

  • An unanswered request keeps its node open. That is a real server bug and the graph should show it as one. It only becomes an error when the child process exits, which is the first moment it is honest to say no answer is coming.
  • The server’s stderr is captured and streamed onto the session node, so the logging you are allowed to write finally sits in the timeline next to the request it was about. Turn it off with --inherit-stderr.

The proxy holds frames, so the four actions map onto protocol actions:

Held request (before)Held response (after, or error)
continueForward unchangedForward unchanged
injectDo not forward; answer the sender with your value as the JSON-RPC resultForward a rewritten frame carrying your value
retrySame as continue — nothing has run yetDrop it and re-send the original request bytes; the node stays open until the new answer
abortDo not forward; answer the sender with a JSON-RPC error (-32099)Drop it and answer the requester with -32099

A notification has no id and no answer, so it only has a before gate: continue forwards, inject forwards your object in its place, abort swallows it.

The error gate fires on a JSON-RPC error and on an MCP tool result carrying isError: true — the second is the common one, because that is how a failing tool reports itself. Both are armed by default by graphmind serve, so a failing tool call pauses without any configuration.

What you inject, and what the client receives

Section titled “What you inject, and what the client receives”

MCP results are typed, and the client’s SDK validates them. So the proxy lifts a bare value into the shape the method has to return, with the same rules as @graphmind-ai/mcp (a parity test keeps the two implementations identical):

You injectAt a tools/call gate, the client receives
A string{ "content": [{ "type": "text", "text": "…" }] }
An objectA text block with its JSON, plus structuredContent, so a tool with an outputSchema accepts it
Something already shaped like the result (content or structuredContent present)Exactly that
A whole JSON-RPC frame (it has a jsonrpc key)That frame, verbatim. This is how you answer with an error instead of a result.

resources/read, prompts/get and sampling/createMessage are lifted the same way into their own result shapes. A method GraphMind does not model is relayed as you typed it.

On a session that negotiated the 2026-07-28 protocol, a lifted value also gets resultType: "complete", plus ttlMs: 0 and cacheScope: "private" for resources/read, because a 2026 client rejects a result without them. A whole frame is never changed. See MCP SDK v2 below.

Three failures used to be silent. Each now prints one plain line on stderr, where MCP hosts show server logs, and leaves a record on the graph.

The server writes to stdout. Stdout is the MCP wire, so a console.log, or a logger writing one JSON object per line, corrupts it. On the first such line:

graphmind mcp-proxy: the MCP server wrote a line to stdout that is not JSON-RPC: "debug: loading config from ./config.json" — stdout is the MCP wire; log to stderr instead (console.error, or your logger pointed at stderr). Relayed verbatim; the client may reject it. Further lines are counted, not printed.

At the end of the session:

graphmind mcp-proxy: 5 line(s) the server wrote to stdout were not JSON-RPC and were relayed verbatim — stdout is the MCP wire; log to stderr (see "stdout noise" on the graph)

The bytes are still relayed unchanged; the proxy does not edit the wire. The first 25 such lines each become a red execution of stdout noise under protocol, with the quoted text (up to 200 bytes) as its input. Noise nodes are not gated. A line glued to a JSON frame without a newline cannot be told apart from the frame and is not detected.

The command cannot be started.

graphmind mcp-proxy: cannot run "no-such-mcp-server": spawn no-such-mcp-server ENOENT — the command was not found on PATH; check the "command" in your MCP client config

The proxy exits with code 127, the session node carries a SpawnError, and the run ends with status error. On Windows, pointing at a .cmd or .bat (such as npx) fails with EINVAL, and the hint says to use "command": "cmd" with ["/c", "npx", …] or to point at the .exe or the script itself. An EACCES hint says to make the file executable. Unless you passed --wait-for-attach, the proxy waits up to one second for GraphMind to attach before it exits, so the failure reaches the graph when a server is running.

The server exits before answering anything. A missing environment variable or a crash on boot:

graphmind mcp-proxy: the MCP server exited with code 2 before answering anything — its last stderr output was:
crash-server: line 1 of 3
crash-server: line 2 of 3
crash-server: line 3 of 3
Error: FATAL: DATABASE_URL is not set
at boot (crash-server.mjs:9:11)

The terminal shows the last 40 lines of the server’s stderr. The session node’s McpServerExitedEarly error carries the last 200 lines (32 KB), and the run ends with status error. With --inherit-stderr, the line tells you to look above instead, because the proxy did not capture it. Windows crash codes such as 3221225477 are named (STATUS_ACCESS_VIOLATION). A server that answered at least once and failed later keeps run status ok: that conversation did happen.

The three examples above are real output from graphmind mcp-proxy against the test fixtures in packages/cli/test/fixtures/mcp-proxy/, run on 2026-09-14. The Windows cases are covered by an injected spawn failure on every platform; the real .cmd test runs only on Windows CI.

The proxy is wire-level, so it needed no change for the TypeScript MCP SDK v2 (@modelcontextprotocol/server 2.0.0). It is tested against v2 servers with both the 1.x and the 2.0.0 clients. What you will see:

  • Default posture: nothing new. A v2 client still opens with initialize (2025-11-25) unless it opts into version negotiation. Inject, retry, abort and every gate work as above.
  • A v2 tool handler that throws arrives as an isError: true result, not a JSON-RPC error. The error gate catches both.
  • A 2026-07-28 session opens with server/discover instead of initialize. That call is folded under protocol. Only servers started with serveStdio(...) speak this era. A plain McpServer.connect(new StdioServerTransport()) answers server/discover with -32601, the default error gate holds that probe, and after you press Continue the client falls back to initialize.
  • 'auto' negotiation with the SDK’s base stdio transport probes on a separate, short-lived server process. Through the proxy that is a second graphmind mcp-proxy and a second, short run holding only the server/discover probe, next to the real session.
  • Inject is stamped for the era the peers negotiated, as described above, so a bare value is accepted by a 2026 client too.

The proxy reports through the same client runtime as every adapter, so the loop hold and the GRAPHMIND_HIDE_* switches apply. The proxy takes no session options, so configure them with environment variables on the proxy’s command:

{
"command": "npx",
"args": ["-y", "graphmind-ai", "mcp-proxy", "--", "node", "server.js"],
"env": { "GRAPHMIND_LOOP_ALLOW": "poll_job", "GRAPHMIND_HIDE_TOOL_RESULTS": "1" }
}
  • Loop hold: a client calling one tool three times back-to-back with identical arguments — no other tool call in between — is held before the third request reaches the server. The whole host session is one run, so a tool the agent calls now and then across a long session (a list_issues({}) at minute 1, 20 and 45 with other work between) is never held. The client gets its answer when you continue, the GraphMind abort error (-32099) if you abort, and your lifted value if you inject. MCP’s per-request _meta, where clients put a fresh progressToken, is ignored when comparing arguments. For a tool the client polls on purpose, set GRAPHMIND_LOOP_ALLOW.
  • Hiding values: the MCP client and server still exchange the real bytes; only what GraphMind records is hidden. GRAPHMIND_HIDE_TOOL_RESULTS also keeps a failed result’s content out of its error message. GRAPHMIND_HIDE_INPUTS also drops the server’s command-line arguments from the run’s metadata and label, which keep only the command name and the file names of path arguments.

The proxy is in the path of your editor’s tool traffic, so this matters more here than anywhere else in GraphMind:

  • No GraphMind running: it is a plain pipe. Frames are relayed, nothing is held, the session behaves exactly as it did before you added the proxy.
  • A debugger that disconnects mid-hold releases every held frame with continue.
  • If the reporter throws, the frame is forwarded unchanged. Observation is best-effort; delivery is not.
  • A frame larger than --max-frame-bytes degrades the relay to a raw byte pipe for the rest of that stream. You lose the debugger, never the session.
  • Nothing is ever written to stdout except the protocol, including the proxy’s own diagnostics. --trace goes to stderr.
  • Order is preserved. Frames go through a single FIFO queue, and while one is held the source is paused — so a held request means execution really is stopped, and the server cannot see frame N+1 before frame N.

A forwarded frame is written back as it arrived. Nothing is re-serialised, so key order, number formatting, unicode escaping and stray \rs all survive the round trip. This is deliberate: a debugger that silently normalises the protocol is a debugger that hides protocol bugs.