How to Test and Debug an MCP Server
An MCP server that works in isolation may fail in unexpected ways when connected to a real MCP client. Protocol compatibility issues, environment differences, schema mismatches, and edge cases in your tool logic all create problems that appear only under realistic conditions.
This tutorial covers the complete testing and debugging workflow: from the MCP Inspector for exploratory debugging, through unit tests for tool logic, to integration tests that exercise the full protocol stack.
Prerequisites#
- An existing MCP server to test (see the build MCP server tutorial)
- Node.js 18+ for running the MCP Inspector
- Jest or Vitest for TypeScript unit tests (or pytest for Python)
Step 1: The MCP Inspector β Interactive Debugging#
The MCP Inspector is your first stop for any MCP server debugging. It is an official tool that connects to your server and lets you call tools, read resources, and inspect raw protocol messages through a browser UI.
Launch the Inspector:
# No installation needed β uses npx to run the latest version
npx @modelcontextprotocol/inspector
This opens a web interface at http://localhost:5173 (or similar port).
Connecting to a Stdio Server#
In the Inspector UI, select "stdio" as the connection type and enter your server's launch command:
Command: python
Arguments: /path/to/my_server.py
Or for a TypeScript server:
Command: node
Arguments: /path/to/dist/server.js
Click "Connect" and the Inspector will launch your server and perform the MCP handshake. You will see:
- Tools tab: All registered tools with their schemas β click any tool to call it interactively
- Resources tab: All registered resources β click to read their current content
- Prompts tab: All registered prompt templates
- Messages panel: The raw JSON-RPC messages exchanged (invaluable for protocol debugging)
Connecting to a Remote HTTP/SSE Server#
Select "sse" as the connection type:
URL: https://my-mcp-server.example.com/sse
Headers: Authorization: Bearer your-api-key
Reading the Protocol Messages#
The Messages panel shows every JSON-RPC message. Use this to debug:
Tool not appearing in the list: Inspect the tools/list response. If the tool is missing, the schema registration failed silently β check your server logs for Zod or Pydantic validation errors.
Tool call returning unexpected errors: The error messages are formatted as MCP protocol errors. The actual error detail is in the error.message field of the response.
Schema mismatch: If the Inspector shows a different schema than you expect, your Zod definition is being serialized differently than intended β inspect the tools/list response's inputSchema field.
Step 2: Unit Testing Tool Logic#
Unit tests verify your tool handler functions without going through the MCP protocol stack. The key is separating tool logic from MCP registration.
Extract Logic from Handlers#
Structure your server code so the business logic is in separate, testable functions:
// tools/search-records.ts β pure business logic, no MCP dependencies
export async function searchRecords(
query: string,
limit: number,
db: Database
): Promise<Record[]> {
if (!query.trim()) {
throw new Error("Query cannot be empty");
}
if (limit < 1 || limit > 100) {
throw new Error("Limit must be between 1 and 100");
}
return await db.query(
"SELECT * FROM records WHERE content ILIKE $1 LIMIT $2",
[`%${query}%`, limit]
);
}
// server.ts β MCP registration
import { searchRecords } from "./tools/search-records.js";
server.tool(
"search_records",
"Search records by keyword",
{
query: z.string().min(1),
limit: z.number().int().min(1).max(100).default(10),
},
async ({ query, limit }) => {
const records = await searchRecords(query, limit, db);
return {
content: [{ type: "text", text: JSON.stringify(records) }],
};
}
);
Now write tests for searchRecords directly:
// tools/search-records.test.ts
import { describe, it, expect, beforeEach } from "vitest";
import { searchRecords } from "./search-records.js";
describe("searchRecords", () => {
let db: Database;
beforeEach(async () => {
db = await createTestDatabase(); // In-memory SQLite or test Postgres
await db.query("INSERT INTO records (content) VALUES ($1), ($2)", [
"Active user session management",
"Database connection pooling",
]);
});
it("returns matching records", async () => {
const results = await searchRecords("session", 10, db);
expect(results).toHaveLength(1);
expect(results[0].content).toContain("session");
});
it("throws on empty query", async () => {
await expect(searchRecords("", 10, db)).rejects.toThrow("Query cannot be empty");
});
it("throws on limit exceeding 100", async () => {
await expect(searchRecords("test", 200, db)).rejects.toThrow(
"Limit must be between 1 and 100"
);
});
it("respects limit parameter", async () => {
const results = await searchRecords("database", 1, db);
expect(results).toHaveLength(1);
});
});
Python equivalent (pytest):
# test_tools.py
import pytest
from tools.search_records import search_records
@pytest.fixture
async def test_db():
db = await create_test_database()
await db.execute("INSERT INTO records (content) VALUES ($1)", ["Test record"])
yield db
await db.close()
@pytest.mark.asyncio
async def test_search_finds_records(test_db):
results = await search_records("Test", 10, test_db)
assert len(results) == 1
assert "Test" in results[0]["content"]
@pytest.mark.asyncio
async def test_search_empty_query_raises(test_db):
with pytest.raises(ValueError, match="cannot be empty"):
await search_records("", 10, test_db)
Step 3: Integration Testing the Full MCP Stack#
Integration tests exercise the complete MCP protocol β client connects, tool is called, response is returned β without going through an actual network.
Using InMemoryTransport (TypeScript)#
The MCP SDK provides an InMemoryTransport for testing that connects a client and server in the same process:
// server.integration.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMyServer } from "../src/server.js";
describe("MCP Server Integration", () => {
let client: Client;
let cleanup: () => Promise<void>;
beforeEach(async () => {
const server = createMyServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: {} }
);
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport),
]);
cleanup = async () => {
await client.close();
};
});
afterEach(async () => {
await cleanup();
});
it("lists all registered tools", async () => {
const { tools } = await client.listTools();
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("search_records");
expect(toolNames).toContain("get_status");
});
it("search_records returns results for valid query", async () => {
const result = await client.callTool({
name: "search_records",
arguments: { query: "test", limit: 5 },
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
expect(result.content[0].type).toBe("text");
});
it("search_records returns MCP error for invalid arguments", async () => {
const result = await client.callTool({
name: "search_records",
arguments: { query: "", limit: 5 }, // Empty query should fail validation
});
expect(result.isError).toBe(true);
});
it("returns MCP error for unknown tool", async () => {
await expect(
client.callTool({ name: "nonexistent_tool", arguments: {} })
).rejects.toThrow();
});
});
Testing Schema Validation#
Verify that malformed inputs are correctly rejected:
it("rejects tool arguments that fail schema validation", async () => {
// Negative limit should be rejected by Zod schema
const result = await client.callTool({
name: "search_records",
arguments: { query: "test", limit: -5 },
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("limit");
});
it("applies default values for optional parameters", async () => {
// Should work without explicit limit β uses default of 10
const result = await client.callTool({
name: "search_records",
arguments: { query: "test" },
});
expect(result.isError).toBeFalsy();
});
Step 4: Debugging Common Errors#
Error: "Server process exited with code 1"#
Cause: Your server process crashed before the MCP handshake completed.
Debug:
- Run the server command directly in your terminal:
python my_server.pyornode dist/server.js - Check for import errors, missing environment variables, or syntax errors
- Add a try/catch around the server startup code and log errors to stderr
// Add startup error handling
try {
const transport = new StdioServerTransport();
await server.connect(transport);
} catch (error) {
process.stderr.write(`Server startup failed: ${error}\n`);
process.exit(1);
}
Error: "Tool not found" when tool is registered#
Cause: The tool was registered after the server connected, without sending tools/listChanged; or the tool name has a typo; or the tool registration threw an error silently.
Debug: Use the Inspector to inspect the tools/list response. If the tool is absent, add console.error logging immediately after the tool registration call to verify it is executing.
Error: "Invalid arguments" even with correct input#
Cause: Schema mismatch between what you think the schema validates and what it actually validates.
Debug:
- In the Inspector, open the tool and inspect its
inputSchema - Compare the JSON Schema to your Zod/Pydantic definition
- Test the schema independently:
const schema = z.object({
query: z.string().min(1),
limit: z.number().int().min(1).max(100).default(10),
});
// Test parsing directly
const result = schema.safeParse({ query: "test" });
console.log(result); // Shows parsed value with defaults applied
Error: "Command not found" in Claude Desktop#
Cause: Claude Desktop's subprocess launcher does not inherit your shell's PATH.
Fix: Use absolute paths in your claude_desktop_config.json:
{
"mcpServers": {
"my-server": {
"command": "/usr/local/bin/python3",
"args": ["/Users/me/my-mcp-server/server.py"]
}
}
}
Find absolute paths with:
which python3 # β /usr/local/bin/python3
which node # β /usr/local/bin/node
which npx # β /usr/local/bin/npx
Error: Server connects but returns garbled output#
Cause: Your server is writing non-JSON-RPC content to stdout. In stdio transport, ALL stdout must be valid MCP messages. Never use console.log() in a stdio server β it corrupts the protocol stream.
Fix:
// WRONG β corrupts stdio transport
console.log("Server started");
// CORRECT β use stderr for logging in stdio servers
console.error("Server started");
process.stderr.write("Server started\n");
Or configure your logger to write to a file:
import { createWriteStream } from "fs";
const logStream = createWriteStream("/tmp/mcp-server.log", { flags: "a" });
function log(msg: string) {
logStream.write(`${new Date().toISOString()} ${msg}\n`);
}
Error: SSE connection closes immediately on remote server#
Cause: Authentication failure β the server returns 401/403 and closes the connection.
Debug:
- Test the SSE endpoint directly:
curl -H "Authorization: Bearer your-key" https://your-server.com/sse - Check server logs for the HTTP status returned before the connection closed
- Verify the Authorization header is being sent in the correct format
Step 5: CI/CD Integration#
Add MCP server tests to your CI pipeline:
# .github/workflows/test.yml
name: Test MCP Server
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run build
- run: npm test # Unit and integration tests
- name: Test server startup
run: |
# Verify server starts and handles initialization
timeout 5 node dist/server.js <<< '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci-test","version":"1.0.0"}}}' || true
Next Steps#
- Set up comprehensive security controls from the MCP server security tutorial
- Deploy your tested server using the deploy remote MCP server tutorial
- Implement advanced patterns covered in the advanced MCP patterns tutorial
- Connect your server to monitoring tools to track tool call performance in production
Frequently Asked Questions#
How do I debug a Python MCP server that fails silently?
Python MCP servers using asyncio can fail silently if exceptions in async handlers are not properly propagated. Wrap your entire main() function in a try/except and log to stderr, add asyncio.get_event_loop().set_exception_handler() to catch unhandled async exceptions, and run the server with PYTHONUNBUFFERED=1 to ensure stderr output is not buffered. Also use mcp dev your_server.py β the mcp package includes a development runner that shows protocol messages and errors.
Can I test MCP servers with Playwright?
Not directly β Playwright tests browser interactions, and MCP servers are not browser applications. However, if you build an MCP server that controls a browser using Playwright (a common pattern for web automation tools), you can use Playwright's test framework to write end-to-end tests that exercise both the MCP tool interface and the resulting browser behavior. For testing the MCP protocol itself, use InMemoryTransport as shown in Step 3.
How do I simulate slow or failing tool calls in tests?
Inject a mock database or API client that has controllable latency and failure modes. Do not test latency with setTimeout in production code β instead, test that your tool handlers handle timeouts from their dependencies correctly:
const slowDb = {
query: () => new Promise((_, reject) =>
setTimeout(() => reject(new Error("Query timeout")), 100)
),
};
it("handles database timeout gracefully", async () => {
const result = await client.callTool({
name: "search_records",
arguments: { query: "test" },
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("timeout");
});