n4nAI

Debugging MCP servers: common errors and fixes

Practical guide to debugging MCP servers: fix transport, JSON-RPC, capability, and tool schema errors with reproducible steps and code.

n4n Team3 min read710 words

Audio narration

Coming soon — every post will get a voice note here.

Debugging MCP servers is rarely about the protocol itself; it’s about the seams where your server meets the client, the model, and the host process. This guide gives an ordered path for debugging mcp servers that starts at the transport layer and ends at LLM integration, with concrete fixes you can apply today.

1. Verify the transport before anything else

Most MCP servers run over stdio or Server-Sent Events (SSE). A silent failure here looks like a hung client with no logs.

If you’re using stdio, the server must read JSON-RPC from stdin and write to stdout with strict newline delimiting. A common bug is buffering stdout in Python when running under a pipe:

import sys

# Wrong: buffered, client never sees the response
print(json.dumps(response))

# Right: flush explicitly
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()

For a quick health check, use the official inspector:

npx @modelcontextprotocol/inspector --stdio "python your_server.py"

If the inspector can’t connect, your process is crashing at import time or not honoring the stdio contract. Run the command directly and watch stderr.

Pitfall: mixing transports

A client configured for SSE will not speak to a stdio server. Confirm the transport field in the client config matches the server launch mode.

2. Capture and validate raw JSON-RPC messages

Once the transport is up, log the raw bytes. MCP is JSON-RPC 2.0; the two mandatory fields are jsonrpc: "2.0" and id. A frequent error in debugging mcp servers is an id mismatch between request and response, which makes the client drop the reply.

A correct tools/call request:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": {"city": "Berlin"}
  }
}

The response must carry the same id:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{"type": "text", "text": "12°C, cloudy"}]
  }
}

Pipe logs through jq to spot missing keys:

cat mcp.log | jq 'select(.id == null)'

3. Check capability negotiation on initialize

The client sends initialize with its supported capabilities. Your server must return a matching capabilities object and the protocol version. A server that omits tools in its capabilities will never receive tools/call, yet the client won’t error loudly.

Minimal Python SDK-style capability declaration:

from mcp.server import Server

server = Server("demo")

@server.list_tools()
async def list_tools():
    return [{
        "name": "get_weather",
        "description": "Get weather",
        "inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}}
    }]

If you roll your own, the initialize result needs:

{
  "protocolVersion": "2024-11-05",
  "capabilities": {"tools": {}},
  "serverInfo": {"name": "demo", "version": "0.1"}
}

4. Reproduce with a minimal client

Don’t debug against a full agent loop. Strip it down. The inspector gives you a manual tool-call UI, but a 10-line script is often faster:

import asyncio
from mcp.client.stdio import stdio_client

async def main():
    async with stdio_client("python", ["server.py"]) as (read, write):
        # send initialize, then tools/call
        ...

If the minimal client works but the agent doesn’t, the bug is in the agent’s prompt or routing, not your server.

5. Schema-check tool inputs and outputs

The most common runtime error in debugging mcp servers is a tool returning a raw string instead of a content array. The spec requires:

{"content": [{"type": "text", "text": "result"}]}

A broken implementation:

@server.call_tool()
async def call_tool(name, args):
    return "12°C, cloudy"  # rejected by clients

Fix:

@server.call_tool()
async def call_tool(name, args):
    return {"content": [{"type": "text", "text": "12°C, cloudy"}]}

Also validate inputSchema with jsonschema before trusting client args. Clients may send extra keys; reject them explicitly to avoid silent drift.

6. Instrument with structured logs

print statements pollute the stdio stream and break the protocol. Use a separate stderr logger with JSON lines:

import logging, json, sys

class JsonFilter(logging.Filter):
    def filter(self, record):
        record.msg = json.dumps({"event": record.msg, "args": record.args})
        return True

logging.basicConfig(level=logging.INFO, stream=sys.stderr)
logger = logging.getLogger()
logger.addFilter(JsonFilter())

Tradeoff

Structured logs are verbose. Sample at 10% in production to keep signal without drowning in data.

7. Handle async errors and timeouts

An unhandled exception in an async tool kills the event loop and the server dies silently. Wrap every tool call:

@server.call_tool()
async def call_tool(name, args):
    try:
        data = await external_api(args["city"])
    except TimeoutError:
        return {"content": [{"type": "text", "text": "upstream timeout"}], "isError": True}
    except Exception as e:
        logger.error("tool_failed", extra={"err": str(e)})
        return {"content": [{"type": "text", "text": "internal error"}], "isError": True}

Clients should check isError. Don’t fake a success response on failure; it corrupts agent reasoning.

8. Debug side effects and idempotency

Tools that write to a database or send email must be idempotent. During debugging mcp servers, run them against a disposable sandbox. A tool that creates a ticket on every call will spam your tracker when the client retries after a timeout.

Use a deterministic id derived from arguments:

import hashlib
dedupe_key = hashlib.sha256(json.dumps(args, sort_keys=True).encode()).hexdigest()

9. When your MCP server calls an LLM gateway

Many MCP servers proxy model calls. If your server forwards requests to an OpenAI-compatible endpoint such as n4n.ai, you get automatic fallback when a provider is rate-limited or degraded, but you still must map the gateway’s error shape into MCP’s error response. Honor the cache-control hints the gateway forwards to avoid redundant spend.

resp = await gateway.post("/v1/chat/completions", json=payload)
if resp.status == 429:
    return {"content": [{"type": "text", "text": "model overloaded"}], "isError": True}

Common pitfalls and tradeoffs

  • Over-logging stdio: writing logs to stdout breaks the protocol. Always use stderr.
  • Ignoring isError: clients may treat missing errors as success. Be explicit.
  • Tight timeouts: MCP doesn’t define a global timeout; set your own and return partial results where possible.
  • Schema drift: updating a tool’s inputSchema without versioning breaks old clients. Include a serverInfo.version bump.
  • Stateful sessions: if you keep per-session state, document it. Stateless servers are easier to debug but limit context.

Debugging mcp servers is a layers game: confirm bytes move, then messages validate, then capabilities match, then tools behave. Follow the order above and you’ll cut triage time from hours to minutes.

Tagsmcpdebuggingmcp-servertroubleshooting

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All model context protocol (mcp) deep dives posts →