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
initializehandshake
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
descriptionon tool parameters (LLMs guess parameters without descriptions) requiredarray 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
| Aspect | stdio | Streamable HTTP | SSE |
|---|---|---|---|
| Debug stdout | Fatal (corrupts stream) | Safe | Safe |
| Log location | stderr | Server-side logs | Server-side logs |
| Session state | Implicit (process lifetime) | Mcp-Session-Id header | URL-bound session |
| Concurrent clients | No (one process = one client) | Yes | Yes |
| Primary test tool | Inspector + raw echo | curl / Postman | curl |
| Claude Code support | Yes | Yes | Yes (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.
Related Reading
- Writing Your Own MCP Server for Claude Code: A Practical Guide
- Claude Code MCP Servers: GitHub Sentry PostgreSQL
- Claude Code Hooks: Automate Pre/Post Tool Actions and Session Events (2026)
- Claude Code Hooks: The Complete 2026 Production Reference
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"
}
}
}
}