teachyou.ai academy
← All posts
MCP

Debugging MCP Servers: Common Errors and How to Fix Them

Ira Menon · Jun 15, 2026 · 14 min read

Why MCP servers fail in ways that feel invisible

The first time an MCP server breaks on you, it rarely breaks loudly. There's no red stack trace pointing at line 42. Instead, your AI client just says something like "tool not available" or silently ignores half your tool definitions, and you're left guessing whether the problem is your server code, your transport layer, your schema, or the client itself. This is the defining trait of MCP debugging: the failure surface is split across three moving parts — the host application, the client-server handshake, and your server's own logic — and most error messages don't tell you which layer actually broke.

If you've spent any time building with the Model Context Protocol, you already know the pain points repeat themselves. A tool that works in your test script fails silently when the client loads it. A stdio server that runs fine from the terminal hangs the moment Claude Desktop launches it. A schema that validates locally gets rejected the instant a real model tries to call it with edge-case arguments. None of this is mysterious once you've seen it a few times — but the first few times are genuinely confusing, because the protocol wraps JSON-RPC in layers of process management, capability negotiation, and schema validation that don't show up in a typical "hello world" tutorial.

This article walks through the errors that come up over and over when people build and deploy MCP servers — connection failures, schema mismatches, transport confusion, silent tool call failures, and permission issues — and gives you concrete steps to diagnose and fix each one. If you're building agent tooling seriously, understanding this failure surface is not optional; it's the difference between shipping a server that works in a demo and one that survives contact with a real user's machine.

Error 1: "Server disconnected" or the client can't even start your process

This is the single most common first failure, and it almost always comes from one of three causes: a bad command path, a missing dependency, or your server crashing before it finishes the initialization handshake.

MCP servers over stdio are launched as child processes by the host application (Claude Desktop, Claude Code, or any other MCP client). If the host can't spawn your process correctly, you get a generic "disconnected" or "failed to start" error with almost no detail. The fix is to stop trusting the host's error message and run the server exactly the way the host would run it, but by hand.

# Simulate exactly what the host does — run your command directly
node /Users/you/projects/my-mcp-server/dist/index.js

# Or for Python servers
python3 -m my_mcp_server

If this throws an error in your terminal, you've found the bug immediately — usually a missing node_modules, a stale build, or an absolute path issue in your config file. A shockingly common variant: the config file references a relative path or assumes a PATH environment variable that exists in your shell but not in the environment the host process spawns children in. GUI apps on macOS, in particular, often launch with a stripped-down PATH that doesn't include your shell's nvm or pyenv shims.

There's a second, quieter version of this failure: the process starts, runs for a second or two, and then exits cleanly with status code 0 — no crash, no error, just an early exit. This usually means your entrypoint script finished its top-level code and returned without ever calling server.connect(), or it called connect() but didn't keep the event loop alive because nothing else was scheduled. In Node this often traces back to a missing await on the connect call; the script reaches the end of the file, Node sees no more pending work, and the process exits before a single message is exchanged. In Python it's usually a missing asyncio.run(main()) at the very bottom of the file, or a main() coroutine that was defined but never invoked.

import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server

async def main():
    server = Server("my-server")
    # ... register tools here ...
    async with stdio_server() as (read_stream, write_stream):
        await server.run(read_stream, write_stream, server.create_initialization_options())

if __name__ == "__main__":
    asyncio.run(main())  # easy to forget this line entirely

Build a habit of adding a one-line stderr log immediately after the transport connects successfully. If you never see that line in your logs, you know the crash or early exit happened before the handshake even had a chance to start, which immediately rules out an entire category of schema and tool-logic bugs you might otherwise chase for an hour.

{
  "mcpServers": {
    "my-server": {
      "command": "/usr/local/bin/node",
      "args": ["/Users/you/projects/my-mcp-server/dist/index.js"]
    }
  }
}

Notice the fully-qualified path to node instead of just "node". That single change fixes an enormous fraction of "server won't start" reports. If you're unsure what binary path your shell resolves, run which node or which python3 and hardcode the result into your config while debugging.

Error 2: The handshake completes but no tools show up

Sometimes the process starts fine — you can even see it in your process list — but the client shows zero tools, zero resources, and zero prompts. This means the server started, but it never completed (or never correctly responded to) the initialize request, or it's advertising capabilities it doesn't actually implement.

The MCP handshake is strict: the client sends initialize, the server must respond with its capabilities (what it supports: tools, resources, prompts, sampling), and only after that does the client send notifications/initialized. If your server logs to stdout by accident — which is the same stream used for the JSON-RPC protocol in stdio mode — you will corrupt the message stream and the handshake will silently fail.

This is the single most under-documented gotcha in MCP server development: never write debug output to stdout in a stdio server. Every print() or console.log() call that isn't part of a JSON-RPC message will get interleaved with protocol traffic and break parsing on the client side.

import sys
import logging

# WRONG — this corrupts the stdio transport
print("Server starting up...")

# RIGHT — send logs to stderr, or a file, never stdout
logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
logging.debug("Server starting up...")

The same rule applies in Node:

// WRONG
console.log("Registered 5 tools");

// RIGHT
console.error("Registered 5 tools");

If your tools genuinely aren't showing up and stdout pollution isn't the cause, check that you're actually registering the tools before the server starts listening. A common structural bug is registering tool handlers asynchronously (e.g., after an await that loads config from disk) but calling server.connect(transport) before that registration promise resolves. The server advertises an empty tool list because, at the moment it responds to initialize, the list genuinely is empty.

Error 3: Tool calls fail with schema validation errors

Once tools are visible, the next class of bug shows up when the model actually tries to call one. You'll see errors like "invalid arguments," "required property missing," or the request timing out entirely because the client rejected the call before it reached your server.

This is almost always a mismatch between your declared inputSchema and what you actually expect at runtime. A frequent mistake: declaring a parameter as type: "string" in the JSON Schema but writing server-side code that only works if it receives a number, or vice versa. Models are good at following schemas, but only if the schema is unambiguous — vague descriptions lead to vague arguments.

{
  "name": "get_weather",
  "description": "Get current weather for a city",
  "inputSchema": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "City name, e.g. 'Austin' or 'Austin, TX'"
      },
      "units": {
        "type": "string",
        "enum": ["celsius", "fahrenheit"],
        "description": "Temperature unit — defaults to celsius"
      }
    },
    "required": ["city"]
  }
}

Two habits will save you hours here. First, always mark fields required explicitly instead of assuming the model will infer it — an optional-looking field with no default handling in your code is a guaranteed runtime crash the first time a model omits it. Second, add a defensive check inside your handler that validates arguments again before using them, because clients differ in how strictly they enforce schema on the way out.

async function handleGetWeather(args) {
  const { city, units = "celsius" } = args;
  if (!city || typeof city !== "string") {
    return {
      content: [{ type: "text", text: "Error: 'city' must be a non-empty string." }],
      isError: true,
    };
  }
  // ... proceed with the actual weather lookup
}

Returning a structured error inside the tool result (with isError: true) rather than throwing an unhandled exception is important — it lets the model see the failure and potentially retry with corrected arguments, instead of the whole call silently vanishing.

Error 4: The server works standalone but breaks inside the host application

This one is maddening because your manual tests pass. You run the server from the terminal, send it a raw JSON-RPC initialize message by hand, and it responds correctly. But the moment Claude Desktop or Claude Code launches it, something is different.

The usual suspects are environment variables and working directory. When you run a script from your terminal, it inherits your full shell environment — API keys, PATH, locale settings, everything you've exported in .zshrc or .bashrc. When a host application spawns your server, it does not inherit that shell environment; it spawns with a much more minimal environment, often just the OS defaults.

If your server reads a secret from process.env.API_KEY and that variable is only ever set in your shell profile, the server will crash or silently fail to authenticate when launched by the host. The fix is to pass required environment variables explicitly through the server's configuration:

{
  "mcpServers": {
    "my-server": {
      "command": "/usr/local/bin/node",
      "args": ["/Users/you/projects/my-mcp-server/dist/index.js"],
      "env": {
        "API_KEY": "sk-your-key-here",
        "LOG_LEVEL": "debug"
      }
    }
  }
}

The other common cause is a relative path used somewhere in your server code — reading a config.json next to the script using a relative path assumes the working directory is the script's own folder, but many hosts launch child processes with the working directory set to somewhere else entirely (often the user's home directory or the app's own install location). Always resolve paths relative to __dirname (Node) or the module's __file__ (Python), never relative to the process's current working directory.

import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const configPath = path.join(__dirname, "config.json"); // safe regardless of cwd

Error 5: Everything connects, but tool calls time out or hang forever

A tool call that just spins forever, with no error and no result, usually points to one of two things: your handler is genuinely stuck waiting on an external resource (a network call with no timeout, a database connection that never resolves), or your handler resolved but never actually sent a response back through the transport.

For the first case, always wrap outbound network calls in your tools with an explicit timeout. Don't rely on the underlying HTTP library's defaults, which are sometimes unbounded.

async function fetchWithTimeout(url, options = {}, timeoutMs = 8000) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(url, { ...options, signal: controller.signal });
    return response;
  } finally {
    clearTimeout(timeout);
  }
}

For the second case — a handler that completes but never replies — check whether you're using an SDK version where tool handlers must explicitly return a properly-shaped result object rather than just performing side effects. It's easy to write a handler that logs a result to stderr for debugging and forgets the return statement entirely, especially when refactoring an existing function into an MCP tool wrapper. The server process looks alive, CPU usage is near zero, and yet nothing ever comes back — because nothing was ever sent.

If you suspect this, add temporary instrumentation around the exact moment you call send or return inside your handler, and confirm in your stderr logs that the response actually gets constructed and dispatched, not just computed.

Error 6: Permission and sandboxing failures

MCP servers that touch the filesystem, spawn subprocesses, or make outbound network calls will sometimes work perfectly on your machine and then fail for a teammate or a user with a more locked-down environment. This shows up as permission denied errors, sandbox violations, or — in hosted/managed environments — silent rejections when the server tries to do something outside its declared scope.

A few practical habits reduce this class of bug significantly:

  • Always check file permissions explicitly and return a clear error rather than letting a raw EACCES bubble up as an opaque stack trace to the model.
  • If your server needs to write files, default to a scoped, well-documented directory rather than assuming write access to arbitrary paths the model might suggest.
  • Log every filesystem or network operation your server performs at debug level, so when something is denied, you have a clear audit trail of exactly what was attempted, not just that "an error occurred."
import os

def safe_write(path: str, content: str) -> dict:
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
        with open(path, "w") as f:
            f.write(content)
        return {"success": True}
    except PermissionError as e:
        return {"success": False, "error": f"Permission denied writing to {path}: {e}"}
    except OSError as e:
        return {"success": False, "error": f"Filesystem error: {e}"}

Treat every filesystem or subprocess call inside a tool handler as something that can fail, because in a real deployment — across different operating systems, containers, and user permission levels — it eventually will.

Error 7: Version mismatches between SDK, protocol, and client

MCP is still evolving quickly, and the protocol version your server was built against can drift from what the host client expects. This produces some of the strangest bugs: a server that used to work suddenly breaks after you update a dependency, or a tool that works in one client (say, a CLI) behaves differently in another (say, a desktop app), because the two clients were built against different protocol revisions.

When you hit behavior that seems client-specific, the first thing to check is the negotiated protocol version during the initialize handshake — both client and server declare a version, and mismatches should trigger a negotiation, but not every implementation handles this gracefully. Pin your SDK version deliberately rather than always installing latest, and re-test against your target client whenever you bump it.

# Check what version of the SDK you're actually running
npm list @modelcontextprotocol/sdk

# Pin it explicitly in package.json rather than using a caret range
# "dependencies": { "@modelcontextprotocol/sdk": "1.4.0" }

Keep a small smoke-test script alongside your server that exercises initialize, lists tools, and calls each tool once with valid arguments. Running this after every dependency bump catches version-drift regressions before they reach a user, and it takes seconds to run compared to the time you'll otherwise spend re-diagnosing the same class of bug a month from now.

// smoke-test.mjs — run manually after every SDK bump
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const transport = new StdioClientTransport({
  command: "node",
  args: ["./dist/index.js"],
});

const client = new Client({ name: "smoke-test", version: "1.0.0" }, { capabilities: {} });
await client.connect(transport);

const { tools } = await client.listTools();
console.log(`Discovered ${tools.length} tools:`, tools.map((t) => t.name));

for (const tool of tools) {
  console.log(`Checking schema shape for ${tool.name}...`);
}

await client.close();

This kind of script is deliberately boring. It doesn't test business logic, it tests that the plumbing works — the process starts, the handshake completes, and the tool list comes back non-empty. Boring tests like this catch the most expensive class of bug: the one that isn't in your code at all, but in the wiring around it.

Building a debugging habit, not just a checklist

The errors above cover the overwhelming majority of real-world MCP server issues, but the deeper lesson is about method, not memorization. When something breaks, isolate the layer first: is it the process starting at all, the handshake completing, the tool schema being accepted, or the tool's actual logic executing? Each layer has a distinct signature of failure, and jumping straight to "let me rewrite the handler" before confirming the process even starts wastes far more time than a methodical, layer-by-layer check.

Keep your server's logging disciplined from day one — stderr only, structured, and verbose enough that you can reconstruct exactly what happened without adding print statements under pressure at 11pm. Keep a raw JSON-RPC test harness handy so you can talk to your server directly, bypassing the host application entirely, whenever you need to rule out client-side weirdness. And treat schema definitions as a contract you test against real, slightly-messy model output, not just the clean examples you wrote them against.

If you want to go deeper than debugging in isolation — actually designing servers that are resilient to these failure modes from the start, wiring them into real agent workflows, and understanding how tool definitions, transports, and client capabilities fit together as a system — that's exactly the ground we cover in Building & Integrating MCP Servers, where these patterns move from "here's the fix" to "here's how you avoid needing the fix in the first place."