Debugging an MCP Server When the Agent Just Goes Quiet

Your tool is registered, the agent knows it exists, and nothing happens. Here is how to see what is actually crossing the wire.

Share
Debugging an MCP Server When the Agent Just Goes Quiet. Abstract ai tooling illustration in orange and dark grey on debugly.dev

The short answer

The Model Context Protocol runs over JSON-RPC, usually on stdio. When something breaks, the failure is almost always one of four things:

  1. You wrote to stdout. A single console.log corrupts the protocol stream and the connection dies silently. Log to stderr.
  2. The tool schema is invalid, so the client drops the tool without telling you.
  3. The tool description is too vague for the model to know when to call it. Nothing is broken, the model just never chooses it.
  4. The server crashed on startup and the client shows no error.

Start here:

npx @modelcontextprotocol/inspector node ./build/index.js

The Inspector gives you a UI showing the handshake, the tool list, and every request and response. It is the single most useful tool for this and most people do not know it exists.

Tested against MCP SDK 1.x on Node 22.14.

Why the failure is silent

MCP over stdio means the client spawns your server as a subprocess and speaks JSON-RPC over stdin and stdout. That design choice is the source of most of the confusion.

stdout is the protocol. Anything your process writes there is parsed as JSON-RPC. One stray log line produces a parse error, and most clients respond by closing the connection rather than surfacing a message.

This is the single most common MCP bug and it catches everybody once:

// breaks everything
console.log("server starting");

// correct
console.error("server starting");

Every logging library defaults to stdout. If you are using pino, winston, or anything similar, redirect it explicitly:

const logger = pino({ level: "debug" }, pino.destination(2));  // fd 2 = stderr

I would go further and remove the footgun entirely at the top of the entry file:

console.log = console.error;

Ugly, and it has saved me more than once in a codebase with dependencies that log.

Use the Inspector first

npx @modelcontextprotocol/inspector node ./build/index.js

It opens a browser UI where you can see the initialise handshake and the negotiated capabilities, the full tool list as the client sees it, and a form to invoke each tool with arbitrary arguments and read the raw response.

That last one is the important one. It separates "my tool is broken" from "the model is not calling my tool", which are completely different problems that present identically.

If the Inspector can call your tool and get the right answer, your server is fine and you have a description problem. If the Inspector cannot, you have a server problem. Establish which before doing anything else.

Reading the wire

When you need more than the Inspector shows, log every message. Wrap the transport:

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { appendFileSync } from "node:fs";

const log = (dir, msg) =>
  appendFileSync("/tmp/mcp.log",
    `${new Date().toISOString()} ${dir} ${JSON.stringify(msg)}\n`);

const transport = new StdioServerTransport();

const origSend = transport.send.bind(transport);
transport.send = async (msg) => { log("OUT", msg); return origSend(msg); };

transport.onmessage = ((orig) => (msg) => { log("IN", msg); return orig(msg); })(
  transport.onmessage?.bind(transport) ?? (() => {})
);

Then tail -f /tmp/mcp.log while the client runs. You will see the initialise exchange, the tools/list call, and every tools/call.

For Claude Desktop specifically, the client keeps its own logs:

# macOS
tail -f ~/Library/Logs/Claude/mcp*.log

Those contain your server's stderr output, which is where your logging is going if you followed the advice above.

The four failure modes in detail

1. Server crashes on startup

The client spawns your process and it exits immediately. Most clients report nothing useful.

Test outside the client first, always:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' \
  | node ./build/index.js

You should get a JSON response. If you get a stack trace, you have your answer without involving the client at all.

Common causes: a relative path that resolves differently because the client sets a different working directory, a missing environment variable, and an import error in a dependency.

The working directory issue is worth calling out. Clients typically spawn your server with the cwd set to something unrelated to your project. Any relative path in your code will break. Resolve from the module location instead:

import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataPath = join(__dirname, "../data/index.json");

Also note that the client's environment is not your shell's environment. PATH is often minimal, so node or python may not resolve. Use absolute paths in your client config:

{
  "mcpServers": {
    "mytool": {
      "command": "/Users/rohit/.nvm/versions/node/v22.14.0/bin/node",
      "args": ["/Users/rohit/projects/mytool/build/index.js"]
    }
  }
}

2. Invalid tool schema

The tool list request succeeds, your tool is absent, and nothing explains why. Usually a JSON Schema that does not validate.

// broken: "str" is not a JSON Schema type
inputSchema: {
  type: "object",
  properties: { path: { type: "str" } }
}

// correct
inputSchema: {
  type: "object",
  properties: { path: { type: "string", description: "Absolute file path" } },
  required: ["path"]
}

Generating the schema from Zod removes this class of error entirely:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";

const Input = z.object({
  path: z.string().describe("Absolute path to the file to read"),
  maxBytes: z.number().int().positive().default(65536),
});

// inputSchema: zodToJsonSchema(Input)

You get validation at runtime and a correct schema for free, which is the same argument for failing fast on config applied to tool definitions.

3. The model never calls your tool

Nothing is broken. The description is not doing its job.

The description is a prompt. It is the only thing the model has to decide whether your tool is relevant.

// too vague
description: "Query the database"

// useful
description: "Run a read-only SQL SELECT against the production analytics " +
  "Postgres database. Use this when the user asks about order counts, " +
  "revenue, or customer metrics. Returns at most 100 rows as JSON. " +
  "Cannot write, update, or delete. Table schema is available via " +
  "the list_tables tool."

Say what it does, when to use it, what it returns, and what it cannot do. The negative constraints matter as much as the positive ones, because they stop the model trying your tool for things it cannot serve.

Parameter descriptions matter equally. path: "Absolute path, not relative. Must be inside the project root." prevents a whole category of failed calls.

Tool count matters too. Past roughly forty tools, selection accuracy degrades noticeably. If you are exposing a large surface, group operations behind fewer tools with an action parameter rather than one tool per endpoint.

4. The tool runs and returns something unusable

The call succeeds and the agent does nothing useful with the result.

Return text the model can read, not internal structures:

// unhelpful
return { content: [{ type: "text", text: JSON.stringify(rows) }] };

// better
return { content: [{ type: "text", text:
  `Found ${rows.length} orders (showing first ${shown}):\n\n` +
  rows.map(r => `- #${r.id} ${r.status} ${r.total_paise/100} INR ${r.created_at}`).join("\n") +
  (truncated ? `\n\n[${total - shown} more rows omitted. Narrow the date range.]` : "")
}]};

Truncation notices are important. A silently truncated result leads the model to confident wrong conclusions, which is worse than an error.

And errors should be returned as errors, not as text saying an error happened:

return { isError: true, content: [{ type: "text", text:
  `Query failed: relation "orderz" does not exist. ` +
  `Did you mean "orders"? Use list_tables to see available tables.`
}]};

That message tells the model what went wrong and what to do next, which is the same principle as designing error messages for humans. The consumer is different and the requirements are identical.

Testing without an agent

Treat the server as a normal service and test it directly:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const client = new Client({ name: "test", version: "1.0.0" }, { capabilities: {} });
await client.connect(new StdioClientTransport({
  command: "node", args: ["./build/index.js"],
}));

const { tools } = await client.listTools();
expect(tools.map(t => t.name)).toContain("query_orders");

const res = await client.callTool({
  name: "query_orders",
  arguments: { sql: "SELECT 1" },
});
expect(res.isError).toBeFalsy();

This runs in CI, catches schema regressions, and does not need a model. Whether the model chooses your tool is a separate question that needs an eval, but correctness of the tool itself is ordinary integration testing.

A checklist

  • Nothing writes to stdout, including dependencies
  • Absolute paths in the client config for both command and args
  • No relative paths in server code
  • Schemas generated from Zod or validated against the JSON Schema spec
  • Descriptions state when to use, what is returned, and what is not supported
  • Errors return isError with an actionable message
  • Large results truncated with an explicit notice
  • Integration test that lists and calls every tool
  • Server tested standalone with the Inspector before touching the client