Debugging MCP servers
You wrote an MCP server. Your assistant calls a tool and gets back something wrong. Now find out why.
The usual first move is not available to you. On a stdio server, stdout is the protocol.
A single console.log in a handler puts a non-JSON line into the JSON-RPC stream, the host’s
parser gives up, and the session dies — so the debugging tool everyone reaches for first is the
one thing you must never do. What is left is stderr, which the host may or may not show you,
which arrives with no request attached to it, and which cannot stop time so you can look at
something properly.
And the loop is slow. Add a line, rebuild, restart the host — because the host owns the child process, so you cannot restart the server without restarting the thing that spawned it — ask the question again, read the log, discover you logged the wrong variable.
Two ways in
Section titled “Two ways in”graphmind mcp-proxy | @graphmind-ai/mcp | |
|---|---|---|
| Code changes | none | two lines |
| Server language | any | TypeScript / JavaScript |
| A server you did not write | yes | no |
| Every request and response | yes | yes |
| Server stderr, in the timeline | yes | your own logging |
| Work inside a handler that never reaches the wire | no | yes |
| Outbound sampling / elicitation | as wire frames | with the handler’s context |
abort | answers the caller with an error | cancels the handler’s own AbortSignal |
| Frames sent before your server finishes starting | yes | no |
| One run is | the whole session | one incoming request |
That last row is the difference you will notice first in the run list, and it follows from where each one sits. The proxy is the pipe, so it can see a session begin and end, and it gives you one run containing the whole conversation — the handshake, the discovery calls, and every request, on one canvas. The adapter lives inside a long-running server that does not own its transport and may serve several clients at once, so it scopes a run to the thing it can honestly delimit: one incoming request. Neither is a workaround; they are answers to different questions (“what did this client do?” versus “what happened in this call?”).
Start with the proxy. It costs nothing and it is the honest reproduction: it debugs the server your host is really running. Reach for the adapter when you need what only the inside can tell you.
The proxy in one command
Section titled “The proxy in one command”The proxy is a program that speaks MCP on both sides. Your host spawns it, and it spawns your server. Put it in front of whatever command you already have:
# beforenode build/server.js
# afternpx graphmind-ai mcp-proxy -- node build/server.jsThen, in another terminal, the debugger:
npx graphmind-ai-
Start the debugger.
npx graphmind-aiopens the viewer onhttp://127.0.0.1:4747. -
Point your host at the proxy instead of at your server.
Terminal window claude mcp add tracker -- npx graphmind-ai mcp-proxy -- node /abs/path/build/server.js{"mcpServers": {"tracker": {"command": "npx","args": ["graphmind-ai", "mcp-proxy", "--", "node", "/abs/path/build/server.js"]}}}Use absolute paths: the host does not spawn the child in your project directory.
{"mcpServers": {"tracker": {"command": "npx","args": ["graphmind-ai", "mcp-proxy", "--", "uv", "run", "python", "-m", "tracker"]}}}The proxy does not care what is on the other side of the pipe — it is JSON-RPC either way.
-
Ask your assistant the question that goes wrong. The session appears in the viewer as it happens: the handshake, the listings, then a node per request.
What the graph shows
Section titled “What the graph shows”Every JSON-RPC request becomes a node under one session node. The three that matter get their own kinds, so a tool call looks like a tool call:
| Method | Node | Kind |
|---|---|---|
tools/call | tool:<name> | tool |
resources/read | resource:<uri> | resource |
prompts/get | prompt:<name> | prompt |
sampling/createMessage (server → host) | llm:sampling | llm |
| the session itself | mcp:session | server |
everything else — initialize, tools/list, ping, logging/*, notifications | mcp:<method> | custom |
Node identity follows the same rule as every other adapter: nodeId is stable per logical
node, so one tool:list_tasks box lights up on every call, and instanceId separates the
executions. Ten calls to one tool are ten instances of one node, not ten boxes.
Two things fall out of this that are hard to get any other way:
A request that never gets an answer stays open. The node does not resolve, and you can see exactly which request your server forgot to reply to. It only turns into an error when the child process dies, because that is the first moment anyone can honestly say no answer is coming.
Your server’s stderr lands in the run, attached to the session and interleaved with the frames — so the logging you are allowed to write finally sits next to the request it was about.
Holding a request
Section titled “Holding a request”Breakpoints work the same way they do everywhere else in GraphMind: set one on the node, and the frame stops.
- Break
beforea request and it stops at the proxy. Your server never receives it. Nothing is in flight, nothing is burning a timeout, and the host is simply waiting for an answer. - Break
aftera response and it stops on the way back. Your server has done the work; the host has not seen the result yet. - Errors are armed by default. A JSON-RPC error, and an MCP tool result carrying
isError: true, both hold — you do not have to configure anything to catch the failing case.
Injecting a response
Section titled “Injecting a response”This is the move the whole thing exists for. Hold a tools/call, then answer it yourself:
-
Hold the request at its
beforegate. -
Click
Inject…and type the result the tool should have produced. Type the answer, not the envelope — a bare value is lifted into the result shape the method has to return:{ "tasks": [{ "id": "PAY-102", "status": "blocked" }] }…arrives at the host as a valid
CallToolResultwith your object as the text content and asstructuredContent, so it satisfies a tool that declares anoutputSchematoo. -
Inject & resume. The request is never forwarded — your server does not run — and the host receives your value as the JSON-RPCresult.
You have now answered the question “if this tool returned the right thing, would the rest work?” without editing a line, rebuilding, or restarting the host. If the assistant’s answer comes out right, the bug is in that handler. If it still comes out wrong, it never was.
Injecting on the way back works too — hold the response and rewrite it. Same effect, one step later, and useful when you want the server’s real work to happen and only the answer to change.
What each action means on the wire
Section titled “What each action means on the wire”Because the proxy sits on a protocol rather than inside a function call, the four actions map onto protocol actions:
Held request (before) | Held response (after / error) | |
|---|---|---|
continue | Forward it unchanged | Forward it unchanged |
inject | Do not forward; answer the sender with your value | Forward a rewritten frame carrying your value |
retry | Same as continue — nothing has run yet | Drop it and re-send the original request |
abort | Do not forward; answer the sender with a JSON-RPC error | Drop it and answer the requester with an error |
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.
When the proxy is not enough
Section titled “When the proxy is not enough”The proxy sees the wire. That is a complete account of what your host and your server said to
each other, and it is usually all you need — but it stops at the boundary of your process. It
cannot see a slow database call inside a handler, or which of three branches your handler took,
and abort can only answer the caller, not cancel work your handler is already doing.
@graphmind-ai/mcp instruments the server from the inside instead. Two
lines, and the run’s AbortSignal becomes the one your handler already receives.
You can run both at once — the adapter for what happens inside, the proxy for the exact bytes — and they will show up as two runs of the same session.
Try it on a server you have not written
Section titled “Try it on a server you have not written”You do not need your own server to see what this looks like. The MCP project publishes a reference server that exercises the whole protocol — tools, resources, prompts, sampling — so point the proxy at that:
npx graphmind-ai # terminal 1: the debugger
# terminal 2: your MCP host, with the proxy wrapping the reference serverclaude mcp add everything-debug -- npx -y graphmind-ai mcp-proxy -- \ npx -y @modelcontextprotocol/server-everythingThen ask your assistant to use one of its tools. The session appears in the viewer as it happens: the handshake, the catalogue the server advertises, and every call your assistant actually makes — a server whose source you have never opened, debugged with no code changes. That is the whole claim, and it is the fastest way to check it.