MCP Server Debugging: 10 Common Errors and How to Fix Them
MCP Server Debugging: 10 Common Errors and How to Fix Them
Model Context Protocol is new enough that every team building on it hits the same wall of confusing errors. The spec is clear. The implementations are not. A server that works in one client fails silently in another. A tool that responds in development returns empty in production. A stdio server suddenly breaks because someone added a print statement.
This post catalogs the ten most common MCP server bugs, what causes each one, and the exact fix. If you are building or debugging an MCP server, read it front to back, pattern-match to whatever you are seeing, and ship.
1. "Unexpected token" parse errors on stdio
Symptom. Client connects, sends initialize, immediately logs "Unexpected token" and disconnects.
Cause. Your server is printing something to stdout that is not a JSON-RPC message. Usually a log message, a welcome banner, or a print statement left in from debugging. The client reads stdout line by line, tries to parse each line as JSON-RPC, and fails hard on the first non-JSON line.
Fix. Everything that is not a JSON-RPC response must go to stderr, not stdout. Audit every print, console.log, logging.info, and println in your server code. Redirect all of them to stderr. In Python, replace print("hello") with print("hello", file=sys.stderr) or configure your logger to use stderr. In Node, use console.error not console.log. Once stdout is exclusively JSON-RPC, the parse errors vanish.
2. Server starts but never returns tool list
Symptom. Client connects, initialize succeeds, you call tools/list, response never comes.
Cause. The most common variant. Your stdio server buffers stdout. The write happens, but it sits in the buffer until flush. The client times out waiting.
Fix. Flush stdout after every write. In Python, sys.stdout.flush() after each JSON write, or better, print(json.dumps(msg), flush=True). In Node, process.stdout.write(JSON.stringify(msg) + "\n") is unbuffered already, but if you use console.log or a write stream with buffering, force a flush. Alternatively, run Python with PYTHONUNBUFFERED=1 in the env.
3. HTTP server ignores Mcp-Session-Id
Symptom. First request from a client works. Subsequent requests behave as if they are from a new client. State between calls is lost.
Cause. The MCP HTTP transport tracks sessions via the Mcp-Session-Id header. Your server is not reading or returning it. Clients send a session ID, expect the server to honor it, and when the header is missing in responses they treat every call as a fresh session.
Fix. Read Mcp-Session-Id from incoming requests. If present, associate this request with that session. If absent, generate a new session ID, create a session, and return the new ID in the response Mcp-Session-Id header. Store session state keyed by ID. Clients will then use the same ID across requests, and your server will maintain state.
4. SSE stream disconnects after the first event
Symptom. Your streaming tool sends one SSE event, then the stream closes.
Cause. The connection is being closed by something in the pipeline. Usually your reverse proxy. Sometimes your framework. Default timeouts on nginx and similar are 60 seconds; default SSE connection close is on any buffered flush.
Fix. Four checks. First, your framework must not buffer SSE responses. In Express, use res.flushHeaders() and write raw. In FastAPI, use StreamingResponse with explicit yield. Second, if you are behind a reverse proxy, disable buffering for the MCP endpoint. Nginx needs proxy_buffering off; and proxy_cache off;. Third, keep the connection alive with periodic heartbeat events, every 15 seconds is conservative. Fourth, check that your hosting platform does not have a hard request timeout. Some serverless platforms terminate long-lived connections regardless.
5. Tool returns, but client shows the wrong content
Symptom. Your tool runs successfully. You return the content. Client displays something different or empty.
Cause. The tool response shape is strict. MCP expects { content: [{ type: "text", text: "..." }] } or similar typed array. Returning plain strings, plain objects, or the wrong structure means the client parses nothing.
Fix. Wrap every tool return in the content array shape. For text, { content: [{ type: "text", text: YOUR_RESULT }] }. For images, { content: [{ type: "image", data: BASE64, mimeType: "image/png" }] }. For multiple outputs, push multiple content items. Never return a bare string.
6. Initialize succeeds, tools/list returns empty
Symptom. The client connects, sees a valid server, and finds no tools.
Cause. You registered tools in a different phase than you exposed them. Or you registered them but your tools/list handler returns an empty array unconditionally. Or you registered them on a different server instance than the one that is being queried.
Fix. Instrument your server. Log every tools/list request on entry and log the length of the array you are about to return. If the log shows zero, trace back through registration. If the log shows non-zero but the client sees empty, you are returning the right count from the wrong handler. Common cause: a library version mismatch where a newer client expects a different response shape.
7. Tool call succeeds locally, fails in Claude Desktop with "method not found"
Symptom. You can call the tool with the MCP Inspector. Claude Desktop says the method does not exist.
Cause. Claude Desktop caches tool lists. When you add a new tool, Claude Desktop does not re-query until restarted. The server knows about the new tool, the client does not.
Fix. Restart Claude Desktop after adding or renaming a tool. If restart does not help, check your config at claude_desktop_config.json and confirm the server entry is correct. Look at the app logs for connection errors. On Mac, the logs live in ~/Library/Logs/Claude/. Tail them while the app starts and you will see exactly what the server is returning.
8. Private IP blocked on connect
Symptom. Your server lives at http://192.168.1.10:8080 or http://localhost:8080. Some clients, including managed platforms, refuse to connect.
Cause. SSRF protection. Any service that lets one user point an MCP client at an arbitrary URL must block private IPv4, link-local IPv4, internal hostnames like localhost, and non-http schemes like file://. Otherwise users could point the client at internal infrastructure and exfiltrate data. This is a correctness feature, not a bug.
Fix. For local development, most clients allow a local-dev bypass via config. For production, deploy your MCP server on a public URL with TLS. The URL must resolve to a public IP. Behind this, the server itself can run on private infrastructure, but the entry point must be public.
9. Stdio server hangs on Windows
Symptom. Your stdio server starts, prints nothing, and the client hangs on initialize.
Cause. Windows, Python, and stdout buffering do not mix. The server is probably buffering output in line-mode, waiting for a newline that never comes, or Python is writing in binary but the shell expects text encoding.
Fix. Run your Python server with PYTHONUNBUFFERED=1 and PYTHONIOENCODING=utf-8 in the environment. Write one JSON object per line, terminated with a newline. Flush after every write. If you are using an older Python launcher, consider wrapping stdout: sys.stdout = open(sys.stdout.fileno(), "w", buffering=1, encoding="utf-8").
10. Tool run logs look fine but the output never surfaces
Symptom. Your tool's logs show a successful run. The response never appears in the client.
Cause. You forgot to await the async tool handler, or you returned the right shape before the data was actually ready. The promise resolved with an empty result because your code did not wait for the underlying async operation.
Fix. Audit every async boundary. In JavaScript, make sure every await is present and every tool handler returns an async function or a Promise. In Python, make sure every async def handler actually awaits the inner calls before returning. A common bug: wrapping an async call in a sync function that returns the coroutine object instead of the resolved value.
Debugging tools that help
The single most useful tool for MCP work is the MCP Inspector. It speaks the protocol, shows every message in both directions, and lets you poke tools directly without spinning up a full client. Run it against your server first. If the Inspector sees what you expect, the server is fine and the bug is in the client integration. If the Inspector sees something wrong, the server has the bug.
For HTTP transport, curl -N gives you the raw SSE stream. For stdio, run the server binary directly in a terminal and type JSON-RPC messages by hand. You will learn what the protocol actually looks like in five minutes.
For full observability of MCP tool calls in production, EmberLM tracks every MCP tool call with timing, cost, input, and output, giving you a clean audit of agent tool use. Pro plan includes it, twenty dollars per month.
Debugging checklist
When an MCP server is not working, run through this list:
- Is stdout exclusively JSON-RPC with no log contamination?
- Is stdout flushed after every write?
- Are you wrapping returns in the content array shape?
- For HTTP, are you reading and returning Mcp-Session-Id?
- For streaming, is your framework and proxy pipeline passing through unbuffered?
- Is the MCP Inspector showing the expected messages?
- Does the public URL resolve to a public IP?
- Have you restarted the client after adding or renaming tools?
- Are all async boundaries awaited?
- Does the server log show the exact request the Inspector or client sent?
If all ten answers are yes and it still does not work, post in the MCP community forum. Someone has hit your bug before.
Start debugging with full MCP observability at emberlm.dev/signup.