·MCP Servers
MCP protocol-level debugging — drive servers with MCP Inspector, capture JSON-RPC traffic, stdio vs HTTP differences, five real bug reproductions, plus preventive observability and spec-upgrade (2026-07-28) troubleshooting.
Debugging MCP Servers: Inspector, Packet Capture, and Failure Modes (2026)
MCP debugging is protocol-level debugging, not application-level: an MCP server is a long-running process that speaks JSON-RPC 2.0 over stdio (standard input/output) or Streamable HTTP — and the failure is almost never in the place you expect. A server can start cleanly, advertise its tools, and still return nothing useful because an environment variable is empty, because stdout leaked onto the protocol stream, or because OAuth silently failed deep in the remote call chain. This guide, current as of the MCP 2025-11-25 spec, covers: the MCP Inspector for raw JSON-RPC capture, five real bug reproductions, the stdio vs HTTP debugging divide, and a production-migration checklist.
TL;DR
- An MCP server is a JSON-RPC process; the leverage in debugging is separating protocol layer (schema/handshake) from application layer (handler logic)
- On stdio transport stdout is the protocol channel —
console.logpollutes the stream; logs must go to stderr- Remote debugging centres on Streamable HTTP packet capture: watch
Mcp-Session-Idandtools/callresponses- The five most common bugs: schema drift, protocol version mismatch, OAuth expiry, unhandled handler exceptions, unpropagated env vars
- Coverage: Inspector capture, five bug reproductions, packet-capture tools, stdio/HTTP divide, production checklist
Start with the Inspector
The single best debugging tool for MCP is the official MCP Inspector, a browser UI that talks the protocol directly to a server with no host in the way. You launch it against any server command:
npx @modelcontextprotocol/inspector node path/to/server.js
The Inspector opens a local web page. From there you can:
- See the connection handshake and the capabilities the server advertises.
- List the server's tools, resources, and prompts with their full JSON schemas.
- Call a tool with hand-typed arguments and inspect the raw response.
- Watch the JSON-RPC traffic between the Inspector and the server, message by message.
This is the fastest possible feedback loop. If a tool works in the Inspector but not in Claude Desktop, the problem is in the host config or environment, not your server. If it fails in the Inspector too, you can iterate on the server code without waiting for a full app restart on every change.
The Inspector works with any stdio server. For HTTP servers, point it at the URL instead. It is also a great way to explore someone else's server before you commit to integrating it — you learn the tool names and argument shapes before writing any config.
Read the logs the host already writes
When a server runs inside a host, the host captures its output. The two logs worth knowing:
Claude Desktop writes one log file per server on macOS at ~/Library/Logs/Claude/mcp-server-<name>.log. On Windows the equivalent lives under %USERPROFILE%\AppData\Roaming\Claude\logs\. Anything your server writes to stdout or stderr lands here, interleaved with the host's own protocol messages. Tail the file while you reproduce the bug:
tail -n 100 -f ~/Library/Logs/Claude/mcp-server-filesystem.log
Claude Code surfaces server status interactively through the /mcp command, which lists every configured server, its connection state, and any tools it has registered. For deeper detail it writes logs alongside its other session output; the /mcp panel is usually enough to see whether a server connected at all.
A common pattern is to add structured logging to your own server during development and strip it for release. Write each log line as a single JSON object with a timestamp and a level — it makes the interleaved log far easier to scan than free-form text.
Protocol-level capture: how to watch JSON-RPC traffic
The real leverage in MCP debugging is seeing the raw JSON-RPC frames, not just the symptom of "the server returned nothing." Three layered methods:
Method 1: the MCP Inspector (most common). Inspector ships with a JSON-RPC panel: the tools/list tab shows the full schema the server registers; the tools/call tab lets you construct a request by hand and see the raw response frame. Inspector defaults to stdio transport — start it with npx @modelcontextprotocol/inspector node ./server.js.
Method 2: socat forking on stdio. When you want to see exactly what bytes the Host sends the server, drop socat between them:
socat -v TCP-LISTEN:7000,fork,reuseaddr EXEC:"node ./server.js"
Then point the Host at localhost:7000 instead of running node ./server.js directly. The -v flag makes socat dump all bytes to stderr — this is the real way to see protocol flow. More work than Inspector, but it captures the Host's added handshake behaviour: Claude Desktop and Claude Code send a notifications/initialized frame right after initialize — missing that notification is one of the most common causes of OAuth context loss on the next call.
Method 3: mcp-proxy on the HTTP side. For Streamable HTTP transport, run a forwarding proxy (mcp-proxy) between server and client, recording both directions to disk. Watch both endpoints: POST carries client → server RPC; GET carries server → client SSE (used to push notifications/resources/updated and similar events).
Three handshakes you must see:
| Handshake | Expected | Failure symptom |
|---|---|---|
| initialize | returns protocolVersion: "2025-11-25", capabilities includes tools | Client reports "Unsupported protocol version" |
| notifications/initialized | Sent by client; server must not reply | OAuth context lost; subsequent calls report "Missing scope" |
| tools/list | returns tools: [] array, each with name/description/inputSchema | Client shows "no tools" while server logs say it registered them |
The initialize response's protocolVersion mismatch is the most common 2026 handshake failure — the client upgraded, the server didn't (or vice versa). UnsupportedProtocolVersionError's data.supported field lists what the server actually supports; align and upgrade (see MCP spec — Transports).
stdio vs Streamable HTTP: fundamental debugging difference
Moving a stdio server to a remote endpoint rewrites almost every debugging technique. The difference isn't just "port vs pipe" — it's the shift of trust boundary and session state.
| Dimension | stdio (local) | Streamable HTTP (remote) |
|---|---|---|
| Auth | Usually none (OS process boundary is the trust boundary) | OAuth 2.1 required (see authorization spec and RFC 8707 Resource Indicators) |
| Session state | In-process variables; process death = loss | Maintained across requests via Mcp-Session-Id header |
| Debugging tools | Inspector + socat + console.error | mcp-proxy + browser DevTools Network + OAuth debug page |
| Error visibility | stderr is immediate | HTTP status code + JSON-RPC error object; 401 = re-OAuth |
| Performance focus | Process start-up latency (cold start UX) | Connection pool, TLS handshake, time-to-first-byte |
Three pitfalls when migrating stdio → HTTP:
- OAuth scope drift. stdio servers usually receive tokens via env vars; HTTP servers must go through OAuth. The common "it worked locally, fails when the Host calls it" complaint is that the token's scope doesn't include the resource the tool needs.
Mcp-Session-Idreuse causing state bleed. When multiple windows connect to the same remote server, improper session isolation lets window A'sresources/readcache be invalidated by window B'sresources/subscribeupdates.- CORS and Origin checking. Browser-based Hosts (the Claude.ai web client, for example) require servers to handle the
Originheader correctly (security best practices covers this in detail) — otherwise the browser refuses the connection.
Five real bug reproductions (with minimal code and fixes)
These five bugs account for roughly 80% of debug-session time. Each gives symptom → minimal reproduction → root cause → fix.
Bug A: Zod schema vs JSON Schema drift. .optional() doesn't always propagate to JSON Schema, so the model field is silently dropped.
// Bug: model sends { name: "x" } and server sees {}
const Schema = z.object({ name: z.string().optional() });
// Fix: declare .nullish() explicitly, or patch during conversion
const Schema = z.object({ name: z.string().nullish() });
const jsonSchema = zodToJsonSchema(Schema);
// Verify: name must appear in the schema; missing it triggers "Missing required argument"
Bug B: console.log leaks a byte onto stdio. Claude Desktop intermittently reports "Unexpected token" or "Message parsing failed". Removing the log fixes it.
// Bug: pollutes the protocol stream
console.log("debug:", args);
// Fix: route everything to stderr, wrapped in JSON for downstream parsing
console.error(JSON.stringify({ level: "debug", msg: "called", args }));
Bug C: handler throws an uncaught exception. The model sees "Tool failed" with no details — because the MCP protocol doesn't serialise thrown exceptions by default. Fix with a structured isError: true return:
// Bug: model only sees "Tool failed"
async function handler(args) { throw new Error("DB down"); }
// Fix: MCP-standard error return
async function handler(args) {
try {
return await realHandler(args);
} catch (e) {
return { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true };
}
}
Bug D: env vars not propagated to the child process. stdio servers are children of the Host, and process.env doesn't automatically include the Host's environment (the Host filters sensitive vars for safety). Symptom: "works locally, returns 401 from the Host."
// Bug: GITHUB_TOKEN is undefined when Host spawns the server
const token = process.env.GITHUB_TOKEN; // undefined
// Fix 1: explicitly whitelist the var in the Host config — see [MCP spec's servers[].env definition](https://modelcontextprotocol.io/docs/2026-07-28/develop/build-server#environment-variables)
// Fix 2: use OAuth instead — safer
Bug E: UnsupportedProtocolVersionError. The client expects 2025-11-25, the server is still on 2024-11-05. Look at data.supported:
{"code": -32000, "message": "Unsupported protocol version", "data": {"supported": ["2024-11-05"], "attempted": "2025-11-25"}}
Fix: upgrade the server SDK to the matching version, or explicitly negotiate down in the initialize handler (last resort).
The failure modes that account for most bugs
After you rule out "the server doesn't run at all," almost every remaining MCP bug falls into one of these buckets.
The server crashes on startup
The host spawns the process, the process exits immediately, and the server shows up as failed. The cause is almost always visible in the log: an unhandled exception, a missing dependency, or a syntax error in your entry file. The fix is to run the exact command from your config in a terminal:
node /absolute/path/to/server.js
If it exits non-zero there, it will exit non-zero in the host too. Fix it standalone first.
The connection opens but no tools appear
The server is alive but the host shows zero tools. Two usual suspects: the server never registered its tools (you forgot to call server.tool(...) before server.connect(...)), or the server is speaking a protocol version the host does not understand. The Inspector will tell you instantly — if it lists the tools, the registration code is fine and the issue is version negotiation. Pin your SDK version and check the host's supported protocol version if you suspect a mismatch.
Tools appear but return nothing
The model decides to call a tool and gets an empty or error response. This is where the tool handler itself is the prime suspect. Log the incoming arguments at the top of every handler — you would be surprised how often the schema validation passes but the argument shape is not what the handler assumed. Return errors as proper MCP content rather than throwing:
server.tool("get_user", { id: z.string() }, async ({ id }) => {
const user = await db.findUser(id);
if (!user) {
return {
isError: true,
content: [{ type: "text", text: `No user with id ${id}` }],
};
}
return { content: [{ type: "text", text: JSON.stringify(user) }] };
});
Returning isError: true tells the model the call failed in a structured way, which lets it recover gracefully. Throwing an unhandled exception, by contrast, often surfaces to the model as a generic "tool failed" with no detail.
stderr leaking onto the protocol
This is the subtlest one. Over the stdio transport, stdout is the protocol channel and stderr is the log channel. If your server writes anything to console.log (which goes to stdout) instead of console.error, those bytes corrupt the JSON-RPC stream and the host sees malformed messages. The symptom is intermittent protocol errors that vanish when you remove logging. Rule of thumb: in a stdio server, send every diagnostic to console.error, never console.log. For HTTP servers this constraint does not apply.
Permissions and prompts
Some hosts surface a permission prompt the first time a tool is invoked. If you decline it, or if it fires in a context where it cannot be shown (a headless CI run, a background agent), the tool silently does nothing. In Claude Code, check the permission mode in your session and grant the tool explicitly if needed. In automated environments, pre-approve tools in config so no interactive prompt is required.
A debugging workflow that works
When a server misbehaves, work outward from the simplest possible reproduction:
- Run the server command standalone. Paste the exact
commandandargsfrom your config into a terminal. Does it start and stay alive? - Drive it with the Inspector. Connect the Inspector and call the failing tool with the same arguments the host was using. Does it return the right thing?
- Check the host log. Reproduce the failure in the host and read
mcp-server-<name>.log. What did the server print? - Verify the environment. Print
process.envfrom inside the server during startup and compare it to what you expect. Missing tokens are the most common cause of "works in terminal, fails in host." - Narrow the protocol. If you suspect a version or capability mismatch, compare what the Inspector reports against what the host sees.
This order matters. Skipping straight to reading host logs when the server does not even start is a waste of time.
Production considerations
Moving an MCP server from your laptop to a shared environment changes the debugging story.
Transport shifts from stdio to HTTP. In production you usually run the server as a remote HTTP+SSE or streamable-HTTP endpoint rather than a subprocess. That means you lose the per-process log file and gain network failures, timeouts, and authentication to worry about. Add health-check and structured logging from day one.
Concurrency. A local stdio server serves one user. A remote HTTP server may serve many. Make sure your handlers are stateless or that any shared state is protected. Database connection pools, in-memory caches, and rate limiters all need to be safe under concurrent access.
Authentication and authorization. A remote server needs to authenticate the host before trusting its requests, and the tools it exposes need to respect per-user permissions. Do not ship a remote MCP server that lets any caller run any tool — that is the production equivalent of leaving the database write user in your config.
Observability. At scale, log every tool call with the tool name, arguments (redacting secrets), latency, and outcome. This is the only way to answer "why is the agent slow today?" when the server is shared across many sessions.
Versioning. Pin your server and SDK versions explicitly. MCP is still maturing, and a transitive dependency bump can quietly change protocol behavior. Treat the @modelcontextprotocol/sdk version the same way you treat a database driver version — upgrade on purpose, not by accident.
Preventive Observability: Designing Servers to Be Debuggable
Everything above is about investigating after the fact; the cheaper move is laying out the evidence before anyone asks. Four low-cost habits:
Structured logs with a request ID. Generate a requestId for every incoming JSON-RPC request and attach it to every log line that follows — tool execution, database queries, upstream calls. When something breaks, the user gives you a timestamp; filtering by ID reconstructs the whole chain. Plain-text logs are worth a tenth of structured ones during an incident.
Return errors as isError content AND keep detailed logs. What the model sees is the short isError: true message (that's the protocol), but your own logs should carry the full stack and inputs. Logs only: the model retries blindly. isError only: you're flying blind. You need both.
Self-check and print a capability list at startup. On boot, log one structured line: which tools registered, parameter schema versions, protocol version, upstream connections reached. Over half of "the tool shows up but does nothing" problems are visible right there — schema mismatch or an upstream that never connected.
Health checks and an Inspector smoke script. Beyond a /healthz liveness probe on remote servers, add a tools/list probe (protocol alive); wire the MCP Inspector connection test into CI — after each deploy, list tools and call one read-only tool automatically, roll back on failure. Protocol-level regressions are cheapest at deploy time.
Spec-Upgrade Incidents: Debugging Through the 2026-07-28 Migration
Major spec upgrades create a new failure class — code unchanged, environment unchanged, the other side upgraded one day. Four common symptoms during the 2026-07-28 window (transport details in the transports guide):
Symptom 1: UnsupportedProtocolVersionError. New client against old server or vice versa: the new spec carries the protocol version in every request's _meta and errors on mismatch. Fix: upgrade both SDKs; implement the server/discover probe endpoint so clients can detect before selecting.
Symptom 2: handshake code suddenly redundant or harmful. Hand-written initialize / notifications/initialized sequences no longer exist in the new protocol; leftovers can desynchronize from the new flow. Fix: delete hand-rolled handshakes, defer to the SDK.
Symptom 3: multi-step flows break that relied on Mcp-Session-Id. The session header is gone (SEP-2567); cross-call state is no longer hosted by the transport. Fix: move business state to explicit server-minted handles passed as tool arguments.
Symptom 4: clients hang after an SSE disconnect, waiting for redelivery. Last-Event-ID resumability was removed — a broken stream voids the in-flight request. Fix: clients re-issue a fresh request on disconnect; never wait for a redelivery that isn't coming.
General posture during an upgrade window: run the full Inspector chain in staging first (list tools + call a read-only tool + one OAuth-authenticated call) before touching production.
Frequently asked questions
What is the MCP Inspector and when should I use it?
The MCP Inspector is an official browser UI that speaks the MCP protocol directly to a server, with no host in between. Use it as your first debugging step: launch it against your server command and you can list tools, call them with typed arguments, and watch the raw JSON-RPC traffic. If a tool works in the Inspector but fails in a host, the problem is in the host config or environment, not your server.
Why do my MCP tools appear but return nothing useful?
The server connected and registered its tools, but the tool handlers themselves are failing. Log the incoming arguments at the top of each handler to confirm the shape your code assumes matches what the model sends, and return errors as structured MCP content with isError: true rather than throwing. An unhandled exception usually surfaces to the model as a generic failure with no detail.
Where does Claude Desktop write MCP server logs?
On macOS, Claude Desktop writes one log file per server at ~/Library/Logs/Claude/mcp-server-<name>.log, containing everything the server writes to stdout and stderr interleaved with the host's protocol messages. On Windows the equivalent lives under %USERPROFILE%\AppData\Roaming\Claude\logs\. Tail the file while reproducing the bug.
Can I use console.log to debug a stdio MCP server?
No — on the stdio transport, stdout is the JSON-RPC protocol channel. Anything you write to console.log pollutes the protocol stream and causes intermittent "Message format error" failures. Route all diagnostics to console.error (stderr), which is the designated log channel. HTTP servers don't have this constraint.
What's a sensible timeout for a remote MCP tool call?
Three tiers by use case: pure query (database read, search) — 5–10 seconds is plenty; write operations (with a transaction commit) — 30 seconds; batch sync or external trigger chains can reach 60–120 seconds, but anything over 30s should return progress over SSE streaming rather than making the client wait. The MCP spec's Streamable HTTP transport has no hard timeout — it's set by the server framework. With FastAPI or Express, configure timeout=30 explicitly to prevent clients from hanging forever.
How do I tell whether a failure is the protocol, my server, or the host config?
Two steps: (1) restart the Host with claude --mcp-debug or claude desktop --enable-mcp-logs so it dumps handshake bytes to the log; (2) compare the initialize response Inspector sees with the one the Host sees. If they match but the Host's subsequent call fails, the problem is host configuration (OAuth scope, allowed paths, permissions). If they don't match, it's a protocol-version mismatch or the server isn't returning capabilities correctly.
Official references
- MCP Inspector repository (modelcontextprotocol/inspector)
- MCP spec — Transports (stdio + Streamable HTTP)
- MCP spec — Authorization (OAuth 2.1 + Dynamic Client Registration)
- MCP spec — Security Best Practices
- MCP docs — Concepts/Architecture
- MCP docs — Concepts/Transports
- MCP docs — Tools/Debugging
- MCP docs — Build Server
This article is current as of the MCP 2025-11-25 specification (August 2026). The spec evolves; check the version number every six months.