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.
graphmind mcp-proxy -- node build/server.jsgraphmind mcp-proxy -- python -m my_servergraphmind mcp-proxy -- ./target/debug/my-server --flaggraphmind mcp-proxy --trace -- npx -y @modelcontextprotocol/server-everythingEverything after -- is your server’s command line, run unchanged as a child process.
Wiring it into a host
Section titled “Wiring it into a host”Move your existing command inside the proxy. The host still spawns one process; that process just happens to be the proxy now.
claude mcp add my-server-debug -- npx -y graphmind-ai mcp-proxy -- node my-server.jsAdding it under a second name lets you keep the direct one and switch by enabling whichever you want.
// before{ "command": "node", "args": ["my-server.js"] }
// after{ "command": "npx", "args": ["-y", "graphmind-ai", "mcp-proxy", "--", "node", "my-server.js"] }Use absolute paths — the host does not spawn the child in your project directory.
Then run graphmind in another terminal and restart the host so it re-spawns the server.
Options
Section titled “Options”| Flag | Meaning |
|---|---|
--trace | One line per frame on stderr. Works with no GraphMind running at all — a tee for the protocol. |
--wait-for-attach | Hold the first frame until the debugger attaches (up to 3s), so even initialize can be gated. |
--inherit-stderr | Give 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.
What it reports
Section titled “What it reports”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| Method | nodeId | Kind | Parent |
|---|---|---|---|
tools/call (client → server) | tool:<name> | tool | mcp:session |
resources/read (client → server) | resource:<uri> | resource | mcp:session |
prompts/get (client → server) | prompt:<name> | prompt | mcp:session |
completion/complete (client → server) | mcp:completion/complete | custom | mcp:session |
sampling/createMessage (server → client) | llm:sampling | llm | mcp:session |
elicitation/* (server → client) | mcp:elicitation/… | custom | mcp:session |
| the session | mcp:session | server | — |
everything else: initialize, server/discover, */list, ping, logging/setLevel, subscriptions, tasks/*, notifications, and any method GraphMind does not recognise | mcp:<method> | custom | mcp: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.
Gate points and what the actions mean
Section titled “Gate points and what the actions mean”The proxy holds frames, so the four actions map onto protocol actions:
Held request (before) | Held response (after, or error) | |
|---|---|---|
continue | Forward unchanged | Forward unchanged |
inject | Do not forward; answer the sender with your value as the JSON-RPC result | Forward a rewritten frame carrying your value |
retry | Same as continue — nothing has run yet | Drop it and re-send the original request bytes; the node stays open until the new answer |
abort | Do 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 inject | At a tools/call gate, the client receives |
|---|---|
| A string | { "content": [{ "type": "text", "text": "…" }] } |
| An object | A 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.
When the server misbehaves
Section titled “When the server misbehaves”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 configThe 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.
MCP SDK v2 and the 2026-07-28 protocol
Section titled “MCP SDK v2 and the 2026-07-28 protocol”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: trueresult, not a JSON-RPC error. The error gate catches both. - A 2026-07-28 session opens with
server/discoverinstead ofinitialize. That call is folded under protocol. Only servers started withserveStdio(...)speak this era. A plainMcpServer.connect(new StdioServerTransport())answersserver/discoverwith-32601, the default error gate holds that probe, and after you press Continue the client falls back toinitialize. 'auto'negotiation with the SDK’s base stdio transport probes on a separate, short-lived server process. Through the proxy that is a secondgraphmind mcp-proxyand a second, short run holding only theserver/discoverprobe, 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.
Loop hold and recording less
Section titled “Loop hold and recording less”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 freshprogressToken, is ignored when comparing arguments. For a tool the client polls on purpose, setGRAPHMIND_LOOP_ALLOW. - Hiding values: the MCP client and server still exchange the real bytes; only what
GraphMind records is hidden.
GRAPHMIND_HIDE_TOOL_RESULTSalso keeps a failed result’s content out of its error message.GRAPHMIND_HIDE_INPUTSalso 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.
Fail-open
Section titled “Fail-open”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-bytesdegrades 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.
--tracegoes 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.
Bytes, exactly
Section titled “Bytes, exactly”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.
Limits
Section titled “Limits”See also
Section titled “See also”- Debugging MCP servers — the workflow, end to end.
@graphmind-ai/mcp— instrument a TypeScript server from the inside.