Skip to content

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.

graphmind mcp-proxy@graphmind-ai/mcp
Code changesnonetwo lines
Server languageanyTypeScript / JavaScript
A server you did not writeyesno
Every request and responseyesyes
Server stderr, in the timelineyesyour own logging
Work inside a handler that never reaches the wirenoyes
Outbound sampling / elicitationas wire frameswith the handler’s context
abortanswers the caller with an errorcancels the handler’s own AbortSignal
Frames sent before your server finishes startingyesno
One run isthe whole sessionone 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 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:

Terminal window
# before
node build/server.js
# after
npx graphmind-ai mcp-proxy -- node build/server.js

Then, in another terminal, the debugger:

Terminal window
npx graphmind-ai
  1. Start the debugger. npx graphmind-ai opens the viewer on http://127.0.0.1:4747.

  2. 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
  3. 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.

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:

MethodNodeKind
tools/calltool:<name>tool
resources/readresource:<uri>resource
prompts/getprompt:<name>prompt
sampling/createMessage (server → host)llm:samplingllm
the session itselfmcp:sessionserver
everything else — initialize, tools/list, ping, logging/*, notificationsmcp:<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.

Breakpoints work the same way they do everywhere else in GraphMind: set one on the node, and the frame stops.

  • Break before a 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 after a 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.

This is the move the whole thing exists for. Hold a tools/call, then answer it yourself:

  1. Hold the request at its before gate.

  2. 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 CallToolResult with your object as the text content and as structuredContent, so it satisfies a tool that declares an outputSchema too.

  3. Inject & resume. The request is never forwarded — your server does not run — and the host receives your value as the JSON-RPC result.

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.

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)
continueForward it unchangedForward it unchanged
injectDo not forward; answer the sender with your valueForward a rewritten frame carrying your value
retrySame as continue — nothing has run yetDrop it and re-send the original request
abortDo not forward; answer the sender with a JSON-RPC errorDrop 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.

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.

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:

Terminal window
npx graphmind-ai # terminal 1: the debugger
# terminal 2: your MCP host, with the proxy wrapping the reference server
claude mcp add everything-debug -- npx -y graphmind-ai mcp-proxy -- \
npx -y @modelcontextprotocol/server-everything

Then 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.