How to Debug an MCP Server with MCP Inspector (2026 Guide)
This guide is for engineers whose MCP server works on their machine and nowhere else, or works in one client and silently fails in another. By the end you will have a repeatable debugging loop: reproduce the problem in MCP Inspector, script the same check with the Inspector CLI so it can run in CI, rule out the stdout trap, read Streamable HTTP and protocol-version errors for what they are, and know where each client keeps its own logs. Everything below was run on 31 October 2026 with @modelcontextprotocol/inspector 2.13.0 (published 28 October), Node 24.20.0, @modelcontextprotocol/server 2.4.0 and @modelcontextprotocol/node 2.1.2. You need Node 22.19 or later for the Inspector and a server you can start from a shell; if you have not built one yet, start with how to build an MCP server in Python and TypeScript.
Step 1: Get a small server you can break on purpose
Debugging is easier against a server whose failure modes you control. This one exposes a single tool, logs correctly to stderr, and prints to stdout only when BUGGY_STDOUT=1 is set, so you can reproduce the most common bug in MCP on demand.
mkdir mcp-debug-lab && cd mcp-debug-lab
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server@2.4.0 @modelcontextprotocol/node@2.1.2 zod@4.6.7
// server.mjs: stdio MCP server on @modelcontextprotocol/server 2.4.0
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const BUGGY = process.env.BUGGY_STDOUT === '1';
function buildServer() {
const server = new McpServer({ name: 'ops-debug', version: '0.1.0' });
server.registerTool(
'get_service_status',
{
description: 'Return the health of one service by name, e.g. "payments".',
inputSchema: z.object({ service: z.string().min(1) }),
outputSchema: z.object({ service: z.string(), healthy: z.boolean() }),
annotations: { readOnlyHint: true },
},
async ({ service }) => {
if (BUGGY) console.log(`checking ${service}`); // the bug: stdout is the JSON-RPC channel
console.error(`checking ${service}`); // correct: stderr
if (service === 'unknown') {
return { isError: true, content: [{ type: 'text', text: 'No service named "unknown". Call list_services first.' }] };
}
const out = { service, healthy: true };
return { content: [{ type: 'text', text: JSON.stringify(out) }], structuredContent: out };
},
);
return server;
}
if (BUGGY) console.log('ops-debug starting');
console.error('ops-debug starting on stdio');
serveStdio(buildServer);
Step 2: Open the Inspector web UI against the server
Version 2 of the Inspector is one package with three clients: a web UI (the default), a CLI and a TUI, all on a shared core, per the official Inspector docs. Pin the version: the package shipped 2.10.0, 2.11.0, 2.12.0 and 2.13.0 on consecutive weeks in October 2026, according to the npm registry.
npx -y @modelcontextprotocol/inspector@2.13.0 node server.mjs
Starting MCP inspector...
MCP Inspector Web is up and running at:
http://127.0.0.1:6274?MCP_INSPECTOR_API_TOKEN=<per-launch token>
Sandbox (MCP Apps): http://127.0.0.1:6275/sandbox
Open the printed URL, not a bare localhost:6274. The Node backend can spawn processes on your machine, so every /api/* route needs that per-launch token (web client docs). On my run the backend listened on 6274 (UI), 6275 (MCP Apps sandbox) and 6278 (app origin), all on 127.0.0.1. The separate v1 proxy is gone: its SERVER_PORT variable is now only a fallback for the sandbox port.
The tabs that matter for debugging:
- Tools, Resources, Prompts appear only if the server declared that capability. A missing Tools tab means your server never advertised
tools, which is a server bug, not an Inspector one. - Protocol is the JSON-RPC transcript, request paired with response.
- Console (stdio servers) shows the server's stderr. Network (HTTP servers) shows status codes, headers and bodies. They never appear together.
- Logs shows protocol log notifications, with a control that depends on the protocol era (Step 6).
Call get_service_status with payments, then with unknown. The second returns a result with isError: true and the text you wrote, which is exactly what a model will see. My MCP tool design post covers why that, rather than a thrown exception, is the right shape.
Step 3: Script the same checks with the Inspector CLI
The CLI connects, sends one request, prints the result and exits with a stable code, so it is what you put in CI and what you hand to a coding agent. Methods and flags below are from the CLI reference.
# a function, not a variable, so it also works in zsh (the macOS default shell)
insp() { npx -y @modelcontextprotocol/inspector@2.13.0 --cli "$@"; }
insp node server.mjs --method initialize
insp node server.mjs --method tools/call --tool-name get_service_status --tool-arg service=payments --format json
insp node server.mjs --method tools/call --tool-name get_service_status --tool-arg service=unknown --format json; echo "exit=$?"
insp node server.mjs --method tools/call --tool-name nope --format json; echo "exit=$?"
{"result":{"content":[{"type":"text","text":"{\"service\":\"payments\",\"healthy\":true}"}],"structuredContent":{"service":"payments","healthy":true}}}
{"result":{"content":[{"type":"text","text":"No service named \"unknown\". Call list_services first."}],"isError":true}}
{"error":{"code":"tool_is_error","message":"Tool 'get_service_status' returned isError:true."}}
exit=5
{"error":{"code":"tool_not_found","message":"Tool 'nope' not found on server."}}
exit=5
The result goes to stdout and a one-line JSON error envelope to stderr, so 2>&1 | tail -1 | jq .error is enough to branch on. The documented exit codes are 0 success, 1 usage or unexpected error, 3 authentication required, 4 unreachable, 5 tool error or tool not found, and 6 when --strict finds a schema portability problem. Two argument details save time: --tool-arg JSON-parses each value (so count=1 is a number), while --tool-args-json '{"zip":"10001"}' sends the object as written. An empty string against minLength: 1 came back as Input validation error: Invalid arguments for tool get_service_status: service: Too small: expected string to have >=1 characters, again as isError, which tells you the SDK validated input before your handler ran.
If your server takes its own flags, use the separator. In the CLI, everything before -- is the server command and everything after is Inspector options: insp node server.mjs --config prod.json -- --method tools/list.
A startup crash looks like this, with the server's stack trace above it on stderr:
Error: Cannot find module '/.../mcp-debug-lab/nothere.mjs'
{"error":{"code":"error","message":"Connection closed"}}
Connection closed almost always means the process exited. Read the stderr above it before anything else.
Step 4: Check stdout is clean (the trap Inspector will not catch)
Over stdio, stdout is the protocol channel. The debugging guide says plainly that local servers should not log to stdout because it interferes with the protocol. The surprise is that a clean Inspector run does not prove you are compliant. With BUGGY_STDOUT=1, both the CLI and a tool call succeeded:
insp node server.mjs -e BUGGY_STDOUT=1 --method tools/call --tool-name get_service_status --tool-arg service=payments --format json
# {"result":{"content":[...],"structuredContent":{"service":"payments","healthy":true}}} exit 0
The Inspector's client skipped the stray lines without complaint, and the Console tab shows only stderr, so the bug is invisible there. Other hosts, and other SDK versions, may be stricter or may parse a JSON-shaped log line as a message. Test stdout directly instead:
# Every stdout line from a stdio server must be a JSON-RPC message.
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}'
(printf '%s\n' "$INIT"; sleep 1) | BUGGY_STDOUT=1 node server.mjs 2>/dev/null \
| jq -Rr '(try fromjson catch null) as $m
| if ($m|type)=="object" and $m.jsonrpc=="2.0" then "ok " + .[0:60] else "BAD " + . end'
BAD ops-debug starting
ok {"result":{"protocolVersion":"2025-11-25","capabilities":{"t
Fixes by language: in Node use console.error or a logger pointed at process.stderr; in Python use the logging module (its default handler writes to stderr) or print(..., file=sys.stderr); and check that no dependency prints a banner on import. Over Streamable HTTP, stdout is just a log stream, so this whole step is stdio-only.
Step 5: Debug a Streamable HTTP server
Serve the same tool over stateless Streamable HTTP so you can see HTTP-layer failures:
// http.mjs: stateless Streamable HTTP on http://127.0.0.1:3000/mcp
import { createServer } from 'node:http';
import { McpServer, createMcpHandler } from '@modelcontextprotocol/server';
import { toNodeHandler, localhostHostValidation } from '@modelcontextprotocol/node';
import * as z from 'zod/v4';
function buildServer() {
const server = new McpServer({ name: 'ops-debug', version: '0.1.0' });
server.registerTool('get_service_status', {
description: 'Return the health of one service by name, e.g. "payments".',
inputSchema: z.object({ service: z.string().min(1) }),
annotations: { readOnlyHint: true },
}, async ({ service }) => ({ content: [{ type: 'text', text: `${service}: healthy` }] }));
return server;
}
const node = toNodeHandler(createMcpHandler(buildServer));
const hostOk = localhostHostValidation(); // DNS-rebinding guard
createServer((req, res) => {
console.log(`${req.method} ${req.url} ${req.headers['mcp-method'] ?? '-'}`); // stdout is fine over HTTP
if (!hostOk(req, res)) return;
void node(req, res);
}).listen(3000, '127.0.0.1');
node http.mjs &
insp http://127.0.0.1:3000/mcp --transport http --method tools/list --format json
insp http://127.0.0.1:3000/mcp --transport http --protocol-era modern \
--method tools/call --tool-name get_service_status --tool-arg service=search --format json
Both worked. Then I sent hand-written requests with curl to see what clients get wrong. Leaving out the Mcp-Method header that the 2026-07-28 transport requires:
curl -s -i -X POST http://127.0.0.1:3000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
HTTP/1.1 400 Bad Request
{"jsonrpc":"2.0","error":{"code":-32020,"message":"Bad Request: the request headers and body disagree: the body names method tools/list but the required Mcp-Method header is absent", ...},"id":1}
Adding -H 'Mcp-Method: tools/list' returned the tool list with "ttlMs":0,"cacheScope":"private". The Accept check depends on the era: a legacy initialize sent with only Accept: application/json returned 406 Not Acceptable with Client must accept both application/json and text/event-stream, while this SDK version answered the same modern-era tools/list with a 200 whatever Accept said, so send both types anyway because the spec requires them. A request with Host: evil.example.com got 403 from the host guard. With the server stopped, the CLI printed {"error":{"code":"unreachable","message":"fetch failed","cause":"connect ECONNREFUSED 127.0.0.1:3000"}}. The server's own access log is worth reading too: a modern-era Inspector connection sent server/discover, then subscriptions/listen, then tools/list, which tells you what a 2026 client will hit first. Choosing between stdio and HTTP is a bigger topic than debugging; my remote MCP server OAuth guide covers putting authentication in front of an HTTP server before it leaves localhost.
Step 6: Check protocol versions, capabilities and logging
The 2026-07-28 revision retired the initialize handshake and session IDs: every request now carries its protocol version and client capabilities in _meta, and an optional server/discover call returns what the server supports. The Inspector models this as a per-server protocol era: legacy (the default: plain initialize, no probing), auto (probe server/discover, fall back to initialize) and modern (pin 2026-07-28, no fallback). Test both eras explicitly, because the default will not show you a modern-only bug:
insp node server.mjs --method initialize # "protocolVersion": "2025-11-25"
insp node server.mjs --method initialize --protocol-era modern # "protocolVersion": "2026-07-28"
To see version negotiation fail, send a modern request with a date the server does not know:
BAD='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01","io.modelcontextprotocol/clientCapabilities":{}}}}'
(printf '%s\n' "$BAD"; sleep 1) | node server.mjs 2>/dev/null
{"jsonrpc":"2.0","id":2,"error":{"code":-32022,"message":"Unsupported protocol version: 2099-01-01","data":{"supported":["2026-07-28"],"requested":"2099-01-01"}}}
The three new error codes are worth memorising: -32020 header mismatch, -32021 missing required client capability (the server needs, say, elicitation and the request's clientCapabilities did not declare it), and -32022 unsupported protocol version with the supported list in data. A request missing the required _meta fields is rejected with the generic -32602, per the debugging guide.
Logging changed with the same release. Protocol logging (notifications/message) is deprecated as of 2026-07-28 and still works during the deprecation window. On a legacy connection the client sends logging/setLevel once; on a modern one it opts in per request with _meta["io.modelcontextprotocol/logLevel"], and a server must not send log notifications for a request that did not opt in. So an empty Logs tab on a modern connection with the level set to Off is correct behaviour. For anything you will rely on in production, log to stderr (stdio) or to your own aggregation and OpenTelemetry (HTTP), which is what the official guide now recommends.
Step 7: Read the client's own logs
When the server passes every Inspector check and still fails in a host, the difference is almost always the environment the host starts it in: a different working directory (the docs note it may be / on macOS), a reduced set of environment variables, or a PATH without your version manager's Node or uv. Use absolute paths in command and args, and pass secrets through env. Then read the host's logs:
# Claude Desktop (macOS); Windows: type "$env:AppData\Claude\logs\mcp*.log"
tail -n 20 -F ~/Library/Logs/Claude/mcp*.log
# Claude Desktop DevTools for client-side errors, then Cmd-Option-I
echo '{"allowDevTools": true}' > ~/Library/Application\ Support/Claude/developer_settings.json
# Claude Code: server health, debug output, longer startup timeout
claude mcp list
claude --debug
MCP_TIMEOUT=10000 claude
Claude Desktop paths are from the official debugging guide; fully quit and reopen the app after a config or code change, closing the window is not enough. Claude Code's /mcp panel shows status and reconnects servers, and its MCP docs list MCP_TIMEOUT, MCP_TOOL_TIMEOUT and the per-server timeout field. In Cursor, open the Output panel (Cmd+Shift+U) and choose MCP Logs, per Cursor's docs. In VS Code, run MCP: List Servers and choose show output; MCP: Reset Cached Tools helps when a changed tool list does not appear (VS Code reference).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Web UI loads but every action fails or is rejected | Opened localhost:6274 without the per-launch token |
Open the URL the launcher printed, or pin one with MCP_INSPECTOR_API_TOKEN |
Error: --catalog cannot be combined with an ad-hoc server URL/command. |
MCP_CATALOG_PATH is exported in your shell (the web client and TUI reject it alongside an ad-hoc server; the CLI ignores it) |
unset MCP_CATALOG_PATH, or use --catalog without a positional server |
CLI prints {"error":{"code":"error","message":"Connection closed"}}, exit 1 |
Server process crashed or exited on start | Read the stderr above it; fix paths, missing modules or missing env vars |
| Server works in Inspector, garbled or silent in a host | Writing to stdout on a stdio server | Log to stderr; run the jq stdout check from Step 4 |
406 Not Acceptable from an HTTP server |
Legacy-era request whose Accept header lacks application/json or text/event-stream |
Send Accept: application/json, text/event-stream |
400 with JSON-RPC -32020 |
Mcp-Method or Mcp-Name header missing or disagreeing with the body |
Send the 2026-07-28 headers, or use an SDK client instead of hand-built requests |
-32022 Unsupported protocol version |
Client and server share no protocol version | Read data.supported; test with --protocol-era legacy and modern; upgrade the older side |
-32021 missing required client capability |
Server needs a capability the request did not declare | Declare it in clientCapabilities, or redesign the tool to work without it |
403 from a local HTTP server |
Host or Origin validation (DNS-rebinding guard) rejected the request | Connect via 127.0.0.1 or localhost, or add the host to the allowed list deliberately |
| Logs tab stays empty | Modern era with log level Off, or the server never declared logging |
Set Log Level per Request in Server Settings; on legacy, use Set Active Level |
FAQ
What port does MCP Inspector use?
The web UI listens on 6274 on 127.0.0.1 by default (CLIENT_PORT), with the MCP Apps sandbox on 6275 (MCP_SANDBOX_PORT). Version 2 no longer runs the separate v1 proxy server. Binding to all interfaces requires DANGEROUSLY_BIND_ALL_INTERFACES=true.
Why does my MCP server work in Inspector but not in Claude Desktop?
Claude Desktop starts the server with its own working directory and a reduced environment, so relative paths and tools found through your shell's PATH break. Use absolute paths, pass variables through env, then fully quit and reopen the app and read ~/Library/Logs/Claude/mcp*.log.
Can I use console.log in an MCP server?
Not in a stdio server: stdout carries the JSON-RPC messages, so use console.error or a logger writing to stderr. In a Streamable HTTP server stdout is ordinary process output and logging there is fine.
Is MCP logging deprecated?
Protocol-level logging through notifications/message is deprecated as of the 2026-07-28 revision but still works during the deprecation window. Log to stderr or to your own aggregation and OpenTelemetry instead.
What I check first, every time, is whether the process even stays up: the CLI initialize probe plus its stderr answers that in seconds. Then I run the stdout check, because it is the one failure the Inspector will not show me, and I put both plus a tools/list --strict call in CI with the Inspector version pinned, so a protocol or SDK upgrade fails a build rather than a user's session. The official references are the Inspector docs, the Inspector repository, the MCP debugging guide and the 2026-07-28 specification.
Related: How to Build an MCP Server in Python and TypeScript (2026), MCP Security in 2026: The Protocol Got Hardened. The Ecosystem Didn't. and What Is MCP (Model Context Protocol)? A Plain-English Guide for 2026.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Connect MCP Servers to Claude Code, Cursor and VS Code (2026)
Step-by-step: add MCP servers to Claude Code with claude mcp add and .mcp.json scopes, to Cursor with mcp.json, and to VS Code, including remote OAuth servers, environment secrets, and fixes for failed starts, missing tools and timeouts.
13 minOct 20, 2026How to Build an MCP Server in Python and TypeScript (2026)
Build, test and ship an MCP server on the official SDKs: Python mcp 2.3.0 and TypeScript @modelcontextprotocol/server 2.3.1, with tools, resources, prompts, MCP Inspector, Claude Desktop, Claude Code and VS Code config, stateless HTTP, and packaging.
15 minOct 31, 2026Best MCP Servers for DevOps in 2026: 7 Official Servers, Read-Only First
The official MCP servers worth connecting for DevOps work in late 2026: GitHub, Kubernetes, AWS, Terraform, Grafana, Sentry and Datadog. What each exposes, current versions, how to run them read-only, and the risks to plan for.
12 min