You built an MCP server. It starts. It connects. But something is wrong — tools return empty results, the agent never calls the right function, or the connection drops after thirty seconds. Debugging MCP servers is not like debugging a REST API. There is no browser devtools panel, no Postman equivalent out of the box, and error messages from the client are often vague.

This guide covers the tools and techniques that actually work for testing MCP servers on your local machine.

Start with stdio, not a client

The fastest way to test an MCP server is to skip the AI client entirely. MCP servers using stdio transport read JSON-RPC messages from stdin and write responses to stdout. You can talk to them directly.

Start your server and send a raw tools/list request:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node srv.js

If your server is written in Python:

echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python srv.py

You should get back a JSON response listing every tool your server exposes. If you get nothing, your server is not reading from stdin correctly. If you get an error, the server crashed during initialization — check stderr output.

This catches the most common class of bugs: servers that start but never register tools, or crash before the handshake completes.

Use the MCP Inspector

The MCP Inspector is a browser-based tool that connects to your server and lets you explore tools, call them with test inputs, and see raw request/response pairs.

Install and run it:

npx @modelcontextprotocol/inspector

The inspector opens a local web UI. Point it at your server command (e.g., node srv.js) and it handles the stdio connection for you. From the UI you can:

  • Browse all registered tools with their input schemas
  • Call any tool with custom parameters
  • See the exact JSON-RPC messages exchanged
  • Check resource and prompt registrations

This is the closest thing MCP has to Postman. Use it before connecting to Claude Desktop or any other client.

Add structured logging

MCP servers that use stdio cannot print to stdout for debugging — that stream is reserved for JSON-RPC. Anything you console.log in a Node.js server will corrupt the protocol stream and crash the connection.

Instead, write logs to stderr or to a file:

// Node.js -- safe logging for stdio servers
const log = (msg: string) => process.stderr.write(`[DEBUG] ${msg}\n`);

log("Starting up");
log(`Registered ${tools.length} tools`);
# Python -- safe logging for stdio servers
import sys

def log(msg: str):
    print(f"[DEBUG] {msg}", file=sys.stderr)

log("Starting up")
log(f"Registered {len(tools)} tools")

When running your server through Claude Desktop or Claude Code, stderr output appears in the client’s logs. In Claude Desktop on macOS, check ~/Library/Logs/Claude/ for MCP server stderr output.

Trace tool calls end to end

When a tool returns unexpected results, you need to see exactly what went in and what came out. Add request/response logging to your tool handlers:

srv.setRequestHandler(CallToolRequestSchema, async (request) => {
  log(`Tool called: ${request.params.name}`);
  log(`Input: ${JSON.stringify(request.params.arguments)}`);

  const result = await handleTool(request.params.name, request.params.arguments);

  log(`Output: ${JSON.stringify(result).slice(0, 500)}`);
  return result;
});

This pattern catches three common problems:

  1. Wrong argument names. The agent sends query but your handler expects search_term. The input log makes this obvious.
  2. Type mismatches. The schema says the parameter is a number but the agent sends a string. JSON-RPC does not enforce types on its own.
  3. Silent failures. Your handler catches an error internally and returns an empty result instead of surfacing it. The output log reveals the gap.

Common failure modes and fixes

Server connects but tools never appear. Your server is not calling setRequestHandler for the tools/list method, or it is registering tools after the client has already queried the list. Tools must be registered before the server signals it is ready.

Connection drops after 30 seconds. Some clients enforce idle timeouts. If your server does not respond to ping messages, the client assumes it is dead. Make sure your server framework handles the ping/pong keepalive. The official SDKs handle this automatically — custom implementations need to respond to ping with a pong.

Tool call returns an error but the agent retries silently. MCP tool errors should use the standard JSON-RPC error format with a clear message. If your error response is malformed, the client may swallow it. Return errors like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "error": {
    "code": -32000,
    "message": "API key expired. Set GITHUB_TOKEN in your environment."
  }
}

Environment variables not available. When Claude Desktop spawns your server as a child process, it does not inherit your shell profile. Variables set in .zshrc or .bashrc are not available. Pass them explicitly in the client config:

{
  "mcpServers": {
    "my-mcp": {
      "command": "node",
      "args": ["index.js"],
      "env": {
        "API_KEY": "your-key-here"
      }
    }
  }
}

Testing with a real client

After verifying your server works standalone, connect it to a client for integration testing.

Claude Desktop: Add your server to ~/Library/Application Support/Claude/claude_desktop_config.json and restart the app. Open a conversation, and you should see your tools listed under the hammer icon.

Claude Code: Add the server to your project’s .mcp.json or run claude mcp add to register it. Claude Code connects to servers on startup and shows available tools in the sidebar.

Cursor: Add the server to your MCP configuration in settings. Cursor connects using the same stdio protocol.

In all cases, start with a simple prompt that should trigger your tool: “Use [tool name] to [do X].” If the agent does not call the tool, check that the tool name and description are clear enough for the model to match them to the request.

Testing SSE and HTTP transport

If your server uses SSE (Server-Sent Events) or the newer streamable HTTP transport instead of stdio, testing is slightly different. The server runs as a standalone HTTP service:

# Start your SSE server
node index.js --transport sse --port 3001

You can test the SSE endpoint directly with curl:

# Connect to the SSE stream
curl -N http://localhost:3001/sse

# Send a message to the message endpoint
curl -X POST http://localhost:3001/message \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

The MCP Inspector also supports SSE connections. Point it at your server’s URL instead of a command.

A minimal test checklist

Before deploying or sharing your MCP server, run through this list:

  • tools/list returns all expected tools with correct schemas
  • Each tool handles valid input and returns the expected output format
  • Each tool returns a clear error for invalid input (not a crash, not silence)
  • Environment variables are documented and the server fails clearly if they are missing
  • The server handles ping keepalive messages (or uses an SDK that does)
  • Stderr logging is in place for debugging without corrupting the protocol stream
  • At least one integration test with a real MCP client confirms the tool is callable

FAQ

Q: Can I use unit tests for MCP server tools? A: Yes, and you should. Test your tool handler functions directly, outside the MCP protocol layer. Pass them mock inputs and assert on outputs. The MCP transport is just plumbing — most bugs live in the tool logic itself.

Q: How do I debug connection issues between Claude Desktop and my server? A: Check ~/Library/Logs/Claude/ on macOS for MCP-related logs. Look for stderr output from your server. If the server does not appear in logs at all, the command path in your config is wrong or the server is crashing before it can start. Try running the command manually in your terminal first.

Q: My server works in the Inspector but not in Claude Desktop. Why? A: The most common cause is environment variables. The Inspector inherits your shell environment. Claude Desktop does not. Add every required variable to the env block in your config. The second most common cause is path issues — use absolute paths for the server command.