Debugging · 2026-06-27 · Debugging arc
The MCP Server Debugging Toolkit: Structured Logs, VS Code Breakpoints, Cursor's Output Panel, and the Inspector
MCP servers fail in ways REST APIs don't. The transport is a raw JSON-RPC pipe over stdio or HTTP — there's no framework middleware logging requests, no browser DevTools to open, no curl command that replicates what the AI model sent. When something breaks, the only indication is often that Claude stops using a tool, or returns a vague "I couldn't call that tool" message, with no server-side context visible anywhere. Debugging requires five distinct tools, each covering a different phase of the failure lifecycle: structured logging for continuous production visibility, stdio transport debugging for connection failures, VS Code breakpoints for understanding handler logic, TypeScript-specific patterns for language-layer issues, and Cursor's MCP Output panel for seeing the problem from the AI client's perspective. This post synthesizes all five into a unified debugging lifecycle.
The five debugging tools at a glance
Each tool covers a different phase and reveals a different class of problem. Knowing which tool to reach for first is itself a skill:
| Tool | Phase | What it reveals | What it misses |
|---|---|---|---|
| Structured logging (pino/winston) | Continuous / production | Tool call duration, argument shape, error messages, correlation IDs across multi-tool workflows | The value of arguments and results (PII-safe truncation means you see shape, not content) |
| Stdio transport debugging | Connection / startup | Stdout corruption, initialize handshake failures, config.json mismatches, missing env vars | Handler-level logic — the connection must work before handlers are reachable |
| VS Code debugger | Development / interactive | Full variable state in handlers, async call stacks, exact argument values as the model sent them | Production failures — breakpoints require an active developer session |
| TypeScript-specific patterns | Language layer | Zod validation errors, async stack truncation, source map mismatches, as const type issues |
Runtime data quality — TypeScript types are erased, Zod validates what arrives at runtime |
| Cursor MCP Output panel | AI client perspective | Initialization failures, the exact JSON the model sent, isError vs JSON-RPC error distinction | Server-side state — the Output panel shows what Cursor saw, not what the server logged |
AliveMCP adds a sixth layer that none of the five cover: production monitoring that fires automatically when a developer isn't watching, detecting outages at 3am, in CI environments, and on users' machines.
The debugging lifecycle arc
MCP server failures follow a consistent sequence. A request goes through five phases before it produces a result, and failures at each phase look different from failures at any other phase. Mapping your symptoms to the correct phase immediately tells you which tool to use:
- Connection — the MCP client spawns the server process and attempts to open the stdio or HTTP transport. Failures here: wrong command in config, missing env vars, stdout corruption, process crash at startup.
- Initialize handshake — the client sends an
initializerequest; the server responds with its capabilities; the client sendsinitialized. Failures here: server throws during capability setup, protocol version mismatch, async initialization that isn't complete before the first request. - Tool registration — the client sends
tools/list; the server returns its tool manifest. Failures here: tools registered asynchronously after the server connects, registration errors silently caught, wrong tool definitions that fail Zod schema parsing. - Handler execution — the model asks the client to call a specific tool; the client sends
tools/call; the server runs the handler. Failures here: Zod validation rejects the arguments, handler throws, external API call fails, TypeScript runtime errors. - Result quality — the handler returns successfully but the result isn't useful. The model can't parse the JSON, the result is too long, the tool description was misleading so the model sent unexpected arguments.
The key diagnostic question is always: at which phase did it fail? A tool that never appears in the client's tool list failed at phase 1–3. A tool that appears but fails when called failed at phase 4. A tool that succeeds but produces bad AI behavior failed at phase 5.
Discipline 1: Structured logging — continuous visibility
Structured logging is the only debugging tool that works in all five phases simultaneously and requires no active developer session. It's the baseline layer that everything else builds on.
The foundational rule for MCP servers is always log to stderr, never stdout. In stdio-mode servers, stdout is the JSON-RPC wire — every byte written to stdout is parsed as a protocol message by the client. A single startup banner on stdout silently corrupts the session:
// WRONG — corrupts the JSON-RPC stream in stdio mode
console.log('Server starting...');
// CORRECT — stderr is safe in both stdio and HTTP modes
console.error('Server starting...');
// BEST — structured logging with pino, always writing to stderr (fd 2)
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
}, pino.destination(2));
Once stderr logging is established, the key discipline is logging three events per tool call — start, result, and error — while carefully logging argument shape rather than argument values. Tool arguments frequently contain user-provided text (emails, queries, document content) that constitutes PII. Log that query is present and 47 characters long, not what the query says:
server.tool('search_documents', searchSchema, async (args, extra) => {
const requestId = extra?.meta?.progressToken ?? crypto.randomUUID();
const start = performance.now();
const callLogger = logger.child({ requestId, tool: 'search_documents' });
callLogger.info({
event: 'tool_call_start',
args: {
query: args.query?.slice(0, 100), // truncate, don't omit
limit: args.limit,
filter: args.filter ? '[present]' : '[absent]',
},
}, 'tool call start');
try {
const results = await searchDocuments(args.query, args);
const durationMs = Math.round(performance.now() - start);
callLogger.info({
event: 'tool_call_success',
durationMs,
resultCount: results.length,
}, 'tool call success');
return { content: [{ type: 'text', text: JSON.stringify(results) }] };
} catch (err) {
callLogger.error({
event: 'tool_call_error',
durationMs: Math.round(performance.now() - start),
errorMessage: err.message,
stack: err.stack,
}, 'tool call error');
return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
}
});
For multi-tool agent workflows — where Claude calls several tools in sequence — add correlation IDs via AsyncLocalStorage using the meta.progressToken from the extra parameter. This lets you filter your log aggregator to a single correlation ID and see the complete trace of what an agent workflow did, in order, with durations.
For high-volume servers (thousands of tool calls per minute), sample successful calls at 1% and always log errors at 100%. This keeps log ingestion costs manageable while preserving full error visibility and enough success samples for latency percentile monitoring.
When to use structured logging: Always. It's not a debugging tool to reach for when things break — it's infrastructure that makes every other debugging tool more effective by providing context when you look at the other layers.
Discipline 2: Stdio transport debugging — diagnosing connection failures
Stdio transport failures are the most common first failure developers encounter, and the most confusing — because the error appears on the client side, not in the server output, and the failure often looks identical to "server crashed" even when the server is running fine with a stdout pollution problem.
The four symptoms of a stdio connection failure, from most to least obvious:
- Silent connection failure — the client shows the server as disconnected with no error message
- JSON parse error in the client log — a non-JSON line hit the parser
- Server connected but tool list empty — the server initialized but tools/list returned nothing
- Server connected but all tool calls fail — the server is partially broken
The first diagnostic step is always: run the server command directly in a terminal. The exact command from your Claude Desktop or Cursor mcp.json config, with the same environment variables. This shows you the crash message or startup output that the client is suppressing. If you see any non-JSON text on stdout during startup, that's the cause.
The DEBUG environment variable activates safe stderr logging in the MCP SDK and the wider Node.js ecosystem (debug package). Set it in your config's env block for a temporary debugging session:
# In claude_desktop_config.json or .cursor/mcp.json
"env": {
"DEBUG": "mcp:*,myserver:*",
"LOG_LEVEL": "debug"
}
For the initialize handshake specifically, each failure step has a distinct cause. If the server never responds to initialize, the server crashed on startup or stdout is polluted. If the tools/list response returns an empty array, tool registration is happening asynchronously after the connection is established — move all server.tool() calls before server.connect(transport).
The most common config mismatch is using a relative path in args. Neither Claude Desktop nor Cursor inherit your shell's working directory — absolute paths are required. Missing environment variables in the env block are the second most common cause: the server crashes at startup because DATABASE_URL is undefined, and the crash log lives in ~/Library/Logs/Claude/mcp-server-<name>.log (macOS) or %APPDATA%\Claude\logs\ (Windows), not in any visible UI.
When to use stdio transport debugging: When a tool doesn't appear in the client's tool list, when the server shows as disconnected immediately after connecting, or when all tool calls fail in a way that suggests the session never initialized correctly.
Discipline 3: VS Code breakpoints — interactive handler debugging
Once the server is connecting and initializing correctly, VS Code's debugger is the most powerful tool for understanding what's happening inside a handler. Unlike log lines that show you what you thought to log, breakpoints show you the full state of every variable at the exact line where execution is paused.
MCP servers require a specific launch.json pattern because the server runs as a child process of the MCP client, not as a process VS Code starts directly. Two patterns cover the main use cases:
Launch pattern (recommended for handler debugging): VS Code starts the server with --inspect-brk and the MCP Inspector acts as the client.
// .vscode/launch.json — debug with MCP Inspector as client
{
"name": "Debug MCP Server (via Inspector)",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/dist/index.js",
"runtimeArgs": ["--inspect-brk"],
"env": {
"DATABASE_URL": "postgres://localhost/mydb",
"DEBUG": "mcp:*"
},
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"sourceMaps": true,
"console": "integratedTerminal",
"preLaunchTask": "build"
}
Attach pattern (for debugging against a live Claude Desktop session): add --inspect=9229 to the server's node args in claude_desktop_config.json, then attach VS Code.
// .vscode/launch.json — attach to running server
{
"name": "Attach to MCP Server",
"type": "node",
"request": "attach",
"port": 9229,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"sourceMaps": true,
"restart": true
}
For TypeScript projects, ts-node eliminates the most common VS Code debugging mistake: hitting a breakpoint in compiled code that's one commit behind your current source. With ts-node, there's no build step, source maps are inline, and breakpoints always reflect the current .ts source:
// .vscode/launch.json — ts-node, no build step needed
{
"name": "Debug MCP Server (ts-node)",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/ts-node",
"runtimeArgs": ["--esm"],
"program": "${workspaceFolder}/src/index.ts",
"sourceMaps": true,
"console": "integratedTerminal"
}
Two breakpoint patterns worth knowing explicitly: set breakpoints at the first line of the handler body (before any await) to inspect the incoming args object and verify what the model actually sent. Enable "Uncaught Exceptions" in the VS Code breakpoints panel to catch handler throws without manually placing breakpoints on every error path.
When to use VS Code breakpoints: When you know the connection works and you need to understand exactly what's happening inside a specific handler — wrong output for certain inputs, unexpected error on a specific argument combination, or a complex async flow that's hard to trace through logs alone.
Discipline 4: TypeScript-specific debugging — the language layer
TypeScript MCP servers have failure modes that don't exist in plain JavaScript. These are worth knowing in advance because they look like runtime errors but require language-layer fixes.
Zod validation errors before the handler runs
The MCP SDK validates tool arguments against your Zod schema before calling the handler. When validation fails, your handler never runs — the SDK returns a JSON-RPC error directly. In the Cursor MCP Output panel, this appears as an error on the tools/call response with an "error" key (not an isError: true result). The MCP Inspector shows it as a red protocol error, not a yellow result error.
When debugging a Zod validation failure, use ZodError.flatten() to get a structured breakdown of which fields failed and why:
try {
searchSchema.parse(args);
} catch (err) {
if (err instanceof z.ZodError) {
const flat = err.flatten();
logger.error({ event: 'validation_failed', flat }, 'zod validation error');
// flat.fieldErrors shows which fields failed with which messages
// flat.formErrors shows top-level errors
}
}
Async stack traces that don't show the handler
The MCP SDK dispatches tool calls through several layers of async callbacks. When a handler throws, the stack trace starts inside the SDK, and the tool handler name often isn't visible. Increase Error.stackTraceLimit at startup and use error chaining to add context:
Error.stackTraceLimit = 50; // at server startup, before anything async
// In handlers — chain errors with cause to preserve context
throw new Error(`search_documents failed for query="${args.query.slice(0,50)}"`, {
cause: originalError,
});
Source map mismatches in VS Code
The most common VS Code TypeScript debugging problem is "grayed out breakpoints" — VS Code found the source file but can't match it to the compiled output. The fix is almost always one of: run npm run build after the last TypeScript change, verify "sourceMap": true in tsconfig.json, or switch to ts-node to eliminate the build-step sync problem entirely.
The as const pattern for MCP result types
TypeScript infers type: string for string literals, but the MCP SDK's TextContent type requires the literal type: 'text'. This is one of the most common TypeScript errors in new MCP handler code:
// TypeScript error: type 'string' is not assignable to type '"text"'
return { content: [{ type: 'text', text: output }] };
// Fix: 'as const' narrows 'string' to literal 'text'
return { content: [{ type: 'text' as const, text: output }] };
Debugging with Vitest
For handler bugs that are reproducible with specific input, the fastest path is a Vitest test using InMemoryTransport. This keeps the full MCP protocol stack in-process and debuggable with VS Code's attach configuration, without involving the stdio transport or Claude Desktop at all:
// Vitest test — reproduce a handler bug without the MCP client
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const server = createServer({ db: mockDb });
await server.connect(serverTransport);
const client = new Client({ name: 'test', version: '1' }, { capabilities: {} });
await client.connect(clientTransport);
// Run with: vitest --inspect-brk run search.test.ts
// Then attach VS Code — breakpoints in handler code work normally
const result = await client.callTool('search_documents', { query: '', filter: 'deleted' });
expect(result.isError).toBe(true);
When to use TypeScript-specific patterns: When the error message mentions ZodError, when the VS Code debugger shows the wrong line numbers, when TypeScript compilation passes but runtime throws a TypeError, or when the MCP result type doesn't satisfy the SDK's type checker.
Discipline 5: Cursor's MCP Output panel — the AI client's perspective
The previous four disciplines are all server-side — they help you understand what the server is doing. Cursor's MCP Output panel flips the perspective: it shows you exactly what the AI client sent and received, which is sometimes the only way to understand why a tool is misbehaving.
Open the Output panel (View → Output on macOS), click the dropdown, and select the MCP channel. The panel shows every JSON-RPC message Cursor sent and received, in chronological order.
The most important diagnostic distinction in the Output panel is between two failure modes that look identical in the Cursor UI but require completely different fixes:
| Symptom in Cursor | Failure type | What to look for in the Output panel |
|---|---|---|
| Tool doesn't appear in Cursor's tool list | Initialization failure — happened before tools/list |
Errors in the initialize response, or no tools/list message at all (server crashed during init) |
| Tool appears but calls fail | Handler failure — tool registered but handler throws or returns isError: true |
The tools/call response: "isError":true (handler error) vs "error" key (Zod validation or uncaught throw) |
| Tool appears but model never uses it | Description quality — model doesn't recognize when to use it | The tools/list response — read the tool's description field as a model would |
When you find a failing tools/call in the Output panel, the log shows the exact JSON arguments Cursor sent. Copy those arguments and paste them into the MCP Inspector to reproduce the failure outside of Cursor — this isolates the problem from Cursor's AI behavior entirely. You're now debugging the server directly with the exact inputs that caused the failure.
Cursor's config lives in .cursor/mcp.json (project-level) or ~/.cursor/mcp.json (global). The same rules apply as Claude Desktop: absolute paths in args, all required environment variables in env, and reload the window after config changes (Cmd+Shift+P → "Developer: Reload Window"). Adding --inspect=9229 to the node args in the Cursor config enables the VS Code debugger to attach to the Cursor-spawned server process simultaneously.
When to use Cursor's MCP Output panel: When you can't tell whether the failure is in initialization, tool registration, or handler execution — the Output panel immediately shows which phase failed. Also use it to get the exact arguments the AI model sent when you need to reproduce a failure in the Inspector.
The AliveMCP connection: production observability
All five debugging disciplines share a critical limitation: they require a developer to be actively watching. Structured logs need someone to tail them. VS Code breakpoints require an active debugging session. Cursor's Output panel only shows events while Cursor is open. The MCP Inspector only runs when you start it.
Production MCP servers have a different failure surface. A deployment at midnight breaks the initialization handshake. A dependency update causes a specific tool to start throwing on certain inputs. An SSL certificate expires and the HTTP transport silently fails. A handler that worked fine under load testing starts timing out under real traffic.
None of these failures are visible to a developer who isn't actively running one of the five debugging tools. And many of them aren't visible even in structured logs, because the log is only generated when a tool call reaches the handler — connection failures, initialization failures, and TLS errors happen before the first log line.
AliveMCP closes this gap with a continuous protocol-level probe that runs on a schedule regardless of whether a developer is watching. Every few minutes, AliveMCP executes the full MCP lifecycle against your deployed server:
- Opens a connection to the server's HTTP endpoint
- Sends an
initializerequest and validates the response - Sends
tools/listand validates the tool manifest is non-empty - Calls the
alivemcp_sentineltool (registered automatically by the SDK) and validates the result
This four-step probe validates the entire MCP stack — transport, initialization, tool registration, and handler execution — in a way that an HTTP /health check cannot. A server can return HTTP 200 on /health while the MCP protocol is completely broken. AliveMCP's probe fails in exactly the four ways a real MCP client would fail: connection_refused, tls_error, protocol_error, or timeout.
The AliveMCP probe also shows up in your structured logs. If you've implemented the logging from Discipline 1, you'll see the probe's signature every few minutes:
{"level":30,"tool":"tools/list","event":"tool_call_start","args":{},"msg":"tool call start"}
{"level":30,"tool":"tools/list","event":"tool_call_success","durationMs":2,"resultCount":8,"msg":"tool call success"}
{"level":30,"tool":"alivemcp_sentinel","event":"tool_call_start","args":{"nonce":"[present]"},"msg":"tool call start"}
{"level":30,"tool":"alivemcp_sentinel","event":"tool_call_success","durationMs":1,"msg":"tool call success"}
Filter these out of your tool call analytics (filter where tool == "alivemcp_sentinel") but don't discard them — the probe's duration metrics reveal handler initialization latency that wouldn't otherwise be visible between real tool calls.
The five debugging disciplines and AliveMCP form a complete stack: structured logging gives you context when things are working (and when they fail), stdio debugging identifies connection-level failures, VS Code and TypeScript debugging identify handler-level failures in development, Cursor's Output panel identifies failures from the AI client's perspective during development, and AliveMCP identifies production failures automatically at any hour without requiring a developer session.
Choosing the right tool for the problem
The decision tree for MCP server debugging maps symptoms to tools:
| Symptom | Start here | Then try |
|---|---|---|
| Tool doesn't appear in any client's tool list | Run the server command directly in a terminal; check for stdout output | Stdio transport debugging: check the initialize handshake and tools/list response |
| Server shows as disconnected in Claude Desktop | Tail ~/Library/Logs/Claude/mcp-server-<name>.log |
Verify all env vars are in the env block; use absolute paths |
| Tool appears but fails every call | Cursor's MCP Output panel: check whether tools/call response has isError or an error key |
VS Code breakpoint at handler entry; inspect the args object |
| Tool fails on specific inputs | Copy failing args from Cursor Output panel; replay in MCP Inspector | Write a Vitest test with those exact args; debug with vitest --inspect-brk |
| TypeScript error or Zod validation failure | ZodError.flatten() in a catch block; enable Error.stackTraceLimit = 50 |
Switch to ts-node for debugging to avoid source map staleness |
| Production outage, no developer session active | AliveMCP alert tells you which phase failed and when | Structured log context from the time window around the alert |
Related pages
FAQ
What's the first thing to check when an MCP tool stops working?
Check whether the tool still appears in the client's tool list. If it doesn't appear, the failure is in the connection or initialization phase — run the server command directly in a terminal to see any crash output, and check the client's MCP log file for startup errors. If the tool appears but calls fail, open Cursor's MCP Output panel (or the equivalent in your client) and look at the tools/call response — "isError":true means the handler returned an error; an "error" key at the top level means the server threw an uncaught exception or Zod validation failed.
Why does my server work in the MCP Inspector but fail in Claude Desktop or Cursor?
The most common cause is environment differences. The MCP Inspector inherits your shell's environment (including PATH, HOME, and all shell-set environment variables). Claude Desktop and Cursor spawn the server in a minimal environment — they don't inherit your shell profile. Check that all required environment variables are in the env block of your claude_desktop_config.json or .cursor/mcp.json, and that all paths in args are absolute (not shell-resolved relative paths). Run the exact command from the config in a fresh terminal without your shell profile to simulate the client environment.
How do I debug a handler that fails only with specific arguments from the AI model?
Copy the exact failing arguments from Cursor's MCP Output panel (the "arguments" object in the tools/call request) and paste them into the MCP Inspector's raw JSON input mode. This reproduces the failure outside of Cursor without the AI model layer. Once you can reproduce the failure deterministically, write a Vitest test with those exact arguments and run it with vitest --inspect-brk. Attach VS Code to the Vitest process and set a breakpoint at the start of the handler — you can then step through the handler with the exact failing arguments.
Should I use ts-node or tsx for TypeScript MCP server development?
tsx for development speed (esbuild transpilation is 10–20x faster than the TypeScript compiler, and it handles ESM correctly without flags); ts-node when you need full type checking during execution (tsx skips type checking for speed). For VS Code debugging, both work — configure runtimeExecutable in launch.json to point to ./node_modules/.bin/tsx or ts-node. The source map behavior is identical; the main practical difference is that ts-node catches type errors at runtime while tsx will let them through (they surface only as runtime TypeErrors or wrong behavior).
How does AliveMCP know which phase of the MCP lifecycle failed?
AliveMCP's probe executes the MCP lifecycle sequentially and records the result at each step. A connection_refused result means the HTTP connection failed — the server isn't running or isn't reachable. A protocol_error on the initialize step means the server connected but the handshake failed. A protocol_error on tools/list means initialization succeeded but tool registration is broken. A protocol_error or timeout on the sentinel tool call means the tool manifest looks correct but handler execution fails. Each failure class points to a different layer of the stack, which narrows debugging to the right tool immediately.