MCP Claude Code testing debugging MCP Inspector unit testing 2026

How to Test and Debug MCP Servers for Claude Code (2026 Guide)

The Prompt Shelf ·

Testing an MCP server is different from testing a regular API. The transport layer — stdio, Streamable HTTP, or SSE — changes what breaks and where. The Claude Code integration adds another surface: does the server behave correctly when Claude invokes its tools inside a real coding session? And in production, how do you know a deployed MCP server is still returning sane responses?

This guide covers four levels: unit testing tool handlers in isolation, integration testing against real MCP clients, transport-specific strategies for each transport type, and using Claude Code hooks as lightweight MCP health monitors in production.

What Makes MCP Testing Different

A standard REST API test sends an HTTP request and checks a JSON response. MCP testing has three complications that shift the strategy:

1. Stdout is the protocol (for stdio servers)
Every console.log() in a stdio MCP server corrupts the JSON-RPC stream. Standard test output patterns — print debugging, test framework console output — break the transport silently. Log to stderr or via MCP’s log notification system instead.

2. Context is not a real object during unit tests
MCP tools receive a ctx (Python) or server (TypeScript) parameter that represents the client connection. In a unit test, no client is connected, so you must mock the context to test handler logic in isolation.

3. Client behavior varies
A server can pass every local test and still fail when Claude Code, Cursor, or Claude Desktop connects — because each client negotiates capabilities differently during the initialize handshake.

Layer 1: Unit Testing Tool Handlers in Isolation

The goal here is testing business logic inside a tool handler without standing up a real MCP server or connecting a client.

TypeScript (Vitest)

Given a tool handler like this:

// src/tools/calculator.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

export function registerCalculatorTools(server: McpServer) {
  server.tool(
    "add",
    "Add two numbers",
    {
      a: { type: "number" },
      b: { type: "number" },
    },
    async ({ a, b }) => ({
      content: [{ type: "text", text: String(a + b) }],
    })
  );
}

Write a unit test by extracting the handler and testing it directly:

// src/tools/calculator.test.ts
import { describe, it, expect } from "vitest";

// Extract the handler function for direct testing
async function addHandler({ a, b }: { a: number; b: number }) {
  return {
    content: [{ type: "text" as const, text: String(a + b) }],
  };
}

describe("add tool", () => {
  it("returns sum as text content", async () => {
    const result = await addHandler({ a: 3, b: 7 });
    expect(result.content[0].text).toBe("10");
  });

  it("handles negative numbers", async () => {
    const result = await addHandler({ a: -5, b: 3 });
    expect(result.content[0].text).toBe("-2");
  });

  it("returns text type", async () => {
    const result = await addHandler({ a: 1, b: 1 });
    expect(result.content[0].type).toBe("text");
  });
});

For tools that use the context parameter (sampling, logging, resource reads), mock it:

// Mock MCP context for tools that use ctx.log or ctx.sampling
const mockContext = {
  log: vi.fn(),
  sampling: {
    createMessage: vi.fn().mockResolvedValue({
      content: { type: "text", text: "mocked response" },
    }),
  },
};

it("logs on tool invocation", async () => {
  await myToolHandler({ query: "test" }, mockContext);
  expect(mockContext.log).toHaveBeenCalledWith("info", expect.any(String));
});

Python (pytest)

# tests/test_tools.py
import pytest
from unittest.mock import AsyncMock, MagicMock

# The tool handler extracted from your MCP server
async def search_handler(ctx, query: str, limit: int = 10):
    results = await ctx.call_tool("database", {"query": query, "limit": limit})
    return {"content": [{"type": "text", "text": str(results)}]}

@pytest.mark.asyncio
async def test_search_returns_text_content():
    mock_ctx = MagicMock()
    mock_ctx.call_tool = AsyncMock(return_value=[{"id": 1, "name": "result"}])
    
    result = await search_handler(mock_ctx, query="test", limit=5)
    
    assert result["content"][0]["type"] == "text"
    mock_ctx.call_tool.assert_called_once_with(
        "database", {"query": "test", "limit": 5}
    )

@pytest.mark.asyncio
async def test_search_default_limit():
    mock_ctx = MagicMock()
    mock_ctx.call_tool = AsyncMock(return_value=[])
    
    await search_handler(mock_ctx, query="test")
    
    mock_ctx.call_tool.assert_called_once_with(
        "database", {"query": "test", "limit": 10}
    )

Unit tests give you fast feedback on logic bugs before you connect any MCP client.

Layer 2: MCP Inspector for Interactive Testing

MCP Inspector is the primary tool for verifying that your server speaks the protocol correctly. Install and run it:

# For a stdio server
npx @modelcontextprotocol/inspector npx your-mcp-server

# For a Streamable HTTP server
npx @modelcontextprotocol/inspector --url http://localhost:3000/mcp

# For an SSE server  
npx @modelcontextprotocol/inspector --url http://localhost:3000/sse

Inspector opens a web UI at http://localhost:5173 that lets you:

  • Browse the full tool/resource/prompt catalog your server exposes
  • Invoke any tool with custom parameters and inspect the raw response
  • Watch the JSON-RPC message stream in real time
  • Test capability negotiation during the initialize handshake

Run Inspector before connecting a real client. A server that fails Inspector will fail every client.

Schema Validation in Inspector

Inspector validates tool schemas against the MCP spec automatically. Common schema issues it surfaces:

  • Missing description on tool parameters (LLMs guess parameters without descriptions)
  • required array not matching actual required parameters
  • Type mismatches between schema declaration and handler return type
  • Tool names with spaces or special characters (most clients reject these)

Fix schema issues before integration testing. They silently cause LLMs to hallucinate parameter values.

Layer 3: Transport-Specific Integration Testing

Each transport has a different failure surface. Test all three if your server supports them.

Stdio Transport

Stdio is the most common transport for local servers and the most fragile to debug.

Critical rule: log to stderr, not stdout.

// Wrong — corrupts the JSON-RPC stream
console.log("tool called");

// Correct — stderr is captured by the client, not the stream
console.error("tool called");

// Best — send as MCP log notification visible in Inspector
await server.sendLoggingMessage({ level: "info", data: "tool called" });

Test in isolation before connecting to Claude Code:

# Send a raw initialize request and inspect the response
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
  | npx your-mcp-server

The response should be a valid JSON-RPC object. If you see garbled output or mixed content, something is writing to stdout.

Integration test with a real client — add Claude Code config:

// ~/.claude/mcp.json (or project .mcp.json)
{
  "mcpServers": {
    "your-server": {
      "command": "npx",
      "args": ["your-mcp-server"],
      "env": {
        "NODE_ENV": "test"
      }
    }
  }
}

Start a Claude Code session and invoke a tool: /mcp lists connected servers and their tools.

Streamable HTTP Transport

# Test the initialize endpoint
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'

Check the Mcp-Session-Id header in the response — you need it for subsequent requests in the same session:

# Store session ID
SESSION_ID=$(curl -si -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' \
  | grep -i "Mcp-Session-Id" | awk '{print $2}' | tr -d '\r')

# Use it for tool invocation
curl -X POST http://localhost:3000/mcp \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: $SESSION_ID" \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"your-tool","arguments":{"param":"value"}}}'

SSE Transport (Legacy)

For SSE-based servers, the connection model is bidirectional — client listens on an SSE stream, sends requests to a separate POST endpoint:

# Open SSE stream in background
curl -N http://localhost:3000/sse &
SSE_PID=$!

# Send initialize request
curl -X POST http://localhost:3000/messages \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}'

# Clean up
kill $SSE_PID

SSE is the legacy transport from MCP 2024-11-05. New servers should use Streamable HTTP.

Transport Comparison for Testing

AspectstdioStreamable HTTPSSE
Debug stdoutFatal (corrupts stream)SafeSafe
Log locationstderrServer-side logsServer-side logs
Session stateImplicit (process lifetime)Mcp-Session-Id headerURL-bound session
Concurrent clientsNo (one process = one client)YesYes
Primary test toolInspector + raw echocurl / Postmancurl
Claude Code supportYesYesYes (legacy)

Layer 4: CI/CD Integration

Automated testing catches schema drift — when you change a tool signature but forget to update the schema declaration.

GitHub Actions Example

# .github/workflows/mcp-test.yml
name: MCP Server Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: npm ci
      - run: npm run test:unit

  schema-validation:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: npm ci && npm run build
      - name: Validate tool schemas
        run: |
          # Start server, run Inspector in headless mode
          timeout 30 npx your-mcp-server &
          SERVER_PID=$!
          sleep 2
          # Fetch tool list and validate schemas
          npx @modelcontextprotocol/inspector \
            --test-mode \
            npx your-mcp-server
          kill $SERVER_PID

  integration-test:
    runs-on: ubuntu-latest
    env:
      YOUR_API_KEY: ${{ secrets.YOUR_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "22"
      - run: npm ci && npm run build
      - name: Run integration tests
        run: npm run test:integration

Schema Snapshot Testing

Catch silent schema changes with snapshot tests:

// tests/schema.test.ts
import { describe, it, expect } from "vitest";
import { createServer } from "../src/server.js";

describe("tool schemas", () => {
  it("matches snapshot", async () => {
    const server = createServer();
    // Capture tool list
    const tools = await server.listTools();
    expect(tools).toMatchSnapshot();
  });
});

When you update a tool schema, update the snapshot intentionally with vitest --update-snapshots. Unexpected snapshot failures in CI mean an accidental schema change shipped.

Layer 5: Claude Code Hooks as Production Health Monitors

This is the pattern that no standard MCP testing guide covers. Claude Code’s hook system can validate MCP tool responses in production sessions, catching failures before they corrupt Claude’s context.

PostToolUse Hook for MCP Response Validation

Add this to .claude/settings.json in any project that uses your MCP server:

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "mcp__your-server__your-tool",
        "hooks": [
          {
            "type": "command",
            "command": "bash -c 'echo \"$CLAUDE_TOOL_RESULT\" | python3 ~/.claude/scripts/validate_mcp_response.py'"
          }
        ]
      }
    ]
  }
}

The validation script:

#!/usr/bin/env python3
# ~/.claude/scripts/validate_mcp_response.py
import json, sys

response = json.loads(sys.stdin.read())

# Check content array exists
if "content" not in response:
    print("ERROR: MCP response missing content array", file=sys.stderr)
    sys.exit(2)  # Exit 2 blocks the turn and shows the error to Claude

# Check content items have required fields
for item in response["content"]:
    if "type" not in item:
        print(f"ERROR: content item missing type: {item}", file=sys.stderr)
        sys.exit(2)
    if item["type"] == "text" and "text" not in item:
        print(f"ERROR: text content item missing text field: {item}", file=sys.stderr)
        sys.exit(2)

# Optional: warn on empty responses
if len(response["content"]) == 0:
    print("WARNING: MCP tool returned empty content", file=sys.stderr)
    # Don't exit 2 — empty might be valid

sys.exit(0)

Exit code 2 blocks the current turn and shows the error message to Claude. Claude then reports the issue in the session rather than silently using a malformed response.

PreToolUse Hook for Rate Limiting

Prevent Claude from hammering a rate-limited external API through your MCP server:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__your-server__expensive-tool",
        "hooks": [
          {
            "type": "command",
            "command": "bash ~/.claude/scripts/rate_limit_check.sh"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# ~/.claude/scripts/rate_limit_check.sh
COUNTER_FILE="/tmp/mcp_tool_counter_$(date +%Y%m%d%H)"
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)

if [ "$COUNT" -ge 50 ]; then
  echo "Rate limit reached: 50 calls per hour for this tool" >&2
  exit 2
fi

echo $((COUNT + 1)) > "$COUNTER_FILE"
exit 0

Common Pitfalls

Tool names with too many parameters
MCP tools with 4+ parameters cause LLMs to guess instead of asking for values. Break complex operations into multiple focused tools. Each tool should have 1–3 required parameters and no more than 2 optional ones.

Missing parameter descriptions
The description field on each parameter is not optional in practice. Without it, the LLM guesses parameter semantics from the name alone. Write one sentence per parameter explaining its format, range, and effect.

Environment variable inheritance
Stdio servers launched by Claude Code inherit a limited subset of environment variables. Don’t rely on ~/.bashrc exports. Declare all required env vars explicitly in .mcp.json:

{
  "mcpServers": {
    "your-server": {
      "command": "npx",
      "args": ["your-mcp-server"],
      "env": {
        "DATABASE_URL": "${DATABASE_URL}",
        "LOG_LEVEL": "info"
      }
    }
  }
}

Working directory assumptions
The working directory for a stdio server launched by Claude Code may be undefined (often / on macOS). Use absolute paths everywhere in your server code. Never use ./ relative paths in config.

Connection restart protocol
Configuration changes require restarting the MCP client to take effect. Code changes require a full restart (for Claude Desktop: quit and reopen, not just close the window). Use Inspector during development to avoid the restart cycle.

Capability negotiation errors
Error code -32602 (Invalid params) during initialization usually means your server sent a sampling or elicitation request to a client that didn’t declare that capability. Inspect the initialize exchange in Inspector logs to see what capabilities each side declared.

Debugging Logs

Log locations for Claude Desktop (one of many MCP clients):

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

# Windows
type "$env:AppData\Claude\logs\mcp*.log"

Enable Chrome DevTools inside Claude Desktop:

# macOS
echo '{"allowDevTools": true}' > ~/Library/Application\ Support/Claude/developer_settings.json
# Then: Cmd+Option+I to open DevTools

For Claude Code sessions, run /mcp to see connected server status, or /doctor to run a health check on the full configuration.

FAQ

Q: What is MCP Inspector and how do I install it?
MCP Inspector is the official interactive debugging tool for MCP servers. Run it without installation: npx @modelcontextprotocol/inspector [server command]. It opens a web UI at http://localhost:5173 for manual tool invocation and protocol inspection.

Q: How do I log from a stdio MCP server without breaking the protocol?
Write to stderr, not stdout. In Node.js: process.stderr.write("log message\n") or console.error("message"). In Python: print("message", file=sys.stderr). Alternatively, use the MCP log notification API to send logs to the client.

Q: How do I test an MCP server in CI without a running Claude Code instance?
Use MCP Inspector in headless/test mode for protocol validation, and write unit tests that test handler logic directly without the transport layer. Integration tests against the real server can use raw JSON-RPC over curl for HTTP transports, or raw stdin for stdio.

Q: What does exit code 2 do in a PostToolUse hook?
Exit 2 from a hook blocks the current Claude Code turn and surfaces the hook’s stderr output as an error. Claude sees the error and reports it in the session instead of continuing with a potentially invalid tool result.

Q: How do I debug a server that connects successfully in Inspector but fails in Claude Code?
Check capability negotiation: run Inspector and inspect the full initialize exchange. Compare what Claude Code sends in its initialize request (visible in Claude Desktop logs) against what your server requires. Mismatched capability declarations cause -32602 errors.

Q: Should I test stdio and Streamable HTTP transports separately?
Yes. They have different failure modes. Stdio breaks silently when stdout is polluted. HTTP has session management (Mcp-Session-Id) that requires correct header handling. A server that works on one transport may fail on the other.


Secure MCP Credentials in Development and Production

If your MCP server needs API keys, database credentials, or other secrets during testing or production use, don’t put them in .mcp.json directly — those files often get committed. 1Password CLI with op run injects secrets at runtime, keeping your MCP configs credential-free and audit-logged:

{
  "mcpServers": {
    "your-server": {
      "command": "op",
      "args": ["run", "--", "npx", "your-mcp-server"],
      "env": {
        "API_KEY": "op://vault/item/field"
      }
    }
  }
}

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules