Testing MCP servers ensures your tools behave correctly and handle edge cases gracefully. This guide covers unit testing, integration testing, and using the MCP Inspector for manual verification—everything you need to ship reliable servers.
1. Why Test MCP Servers?
MCP servers are critical infrastructure for AI applications. Thorough testing helps you:
- Catch bugs early: Before they affect AI behavior or user experience
- Ensure schema validity: Invalid schemas cause silent failures
- Verify error handling: AI needs helpful error messages to recover
- Document behavior: Tests serve as executable documentation
- Enable refactoring: Change code confidently with test coverage
- Prevent regressions: Ensure fixes don't break existing functionality
2. The Testing Pyramid
Follow the testing pyramid for MCP servers:
- Unit tests (many): Test individual tool handlers in isolation
- Integration tests (some): Test the full server including protocol handling
- Manual tests (few): Use the Inspector for exploratory testing
Testing Goals
- Every tool has at least one happy-path test
- Error cases return helpful messages
- Schemas are valid JSON Schema
- Server handles graceful shutdown
3. Unit Testing Tools
Test individual tool handlers in isolation. This is the fastest and most reliable form of testing.
TypeScript with Jest
// src/tools/calculator.ts
export async function add(a: number, b: number): Promise<string> {
return `The sum is ${a + b}`;
}
export async function divide(a: number, b: number): Promise<string> {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return `The result is ${a / b}`;
}
// src/tools/calculator.test.ts
import { add, divide } from "./calculator";
describe("Calculator Tools", () => {
describe("add", () => {
it("adds two positive numbers", async () => {
const result = await add(2, 3);
expect(result).toBe("The sum is 5");
});
it("handles negative numbers", async () => {
const result = await add(-1, 1);
expect(result).toBe("The sum is 0");
});
it("handles decimals", async () => {
const result = await add(0.1, 0.2);
expect(result).toContain("0.3");
});
});
describe("divide", () => {
it("divides two numbers", async () => {
const result = await divide(10, 2);
expect(result).toBe("The result is 5");
});
it("throws on division by zero", async () => {
await expect(divide(10, 0)).rejects.toThrow("Cannot divide by zero");
});
});
});Python with pytest
# src/tools/calculator.py
async def add(a: float, b: float) -> str:
return f"The sum is {a + b}"
async def divide(a: float, b: float) -> str:
if b == 0:
raise ValueError("Cannot divide by zero")
return f"The result is {a / b}"
# tests/test_calculator.py
import pytest
from src.tools.calculator import add, divide
@pytest.mark.asyncio
async def test_add_positive_numbers():
result = await add(2, 3)
assert result == "The sum is 5"
@pytest.mark.asyncio
async def test_add_negative_numbers():
result = await add(-1, 1)
assert result == "The sum is 0"
@pytest.mark.asyncio
async def test_divide():
result = await divide(10, 2)
assert result == "The result is 5.0"
@pytest.mark.asyncio
async def test_divide_by_zero():
with pytest.raises(ValueError, match="Cannot divide by zero"):
await divide(10, 0)Test in Isolation
Use mocks for external dependencies (databases, APIs) to make tests fast and reliable. Unit tests should run in milliseconds, not seconds.
4. Integration Testing
Test the full server including protocol handling. This verifies that tools are properly registered and callable.
TypeScript Integration Test
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { spawn } from "child_process";
describe("MCP Server Integration", () => {
let client: Client;
let serverProcess: ChildProcess;
beforeAll(async () => {
// Start the server as a subprocess
serverProcess = spawn("node", ["./dist/index.js"]);
// Create client and connect
const transport = new StdioClientTransport({
command: "node",
args: ["./dist/index.js"]
});
client = new Client({ name: "test-client", version: "1.0.0" });
await client.connect(transport);
});
afterAll(async () => {
await client.close();
serverProcess.kill();
});
it("lists all tools", async () => {
const result = await client.listTools();
expect(result.tools.length).toBeGreaterThan(0);
expect(result.tools.map(t => t.name)).toContain("add");
expect(result.tools.map(t => t.name)).toContain("divide");
});
it("executes add tool correctly", async () => {
const result = await client.callTool({
name: "add",
arguments: { a: 5, b: 3 }
});
expect(result.content[0].text).toContain("8");
});
it("handles tool errors gracefully", async () => {
const result = await client.callTool({
name: "divide",
arguments: { a: 10, b: 0 }
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("zero");
});
});Python Integration Test
import pytest
import asyncio
from mcp.client import Client
from mcp.client.stdio import stdio_client
@pytest.fixture
async def client():
async with stdio_client(["python", "src/server.py"]) as (read, write):
client = Client("test-client")
await client.connect(read, write)
yield client
await client.close()
@pytest.mark.asyncio
async def test_list_tools(client):
result = await client.list_tools()
assert len(result.tools) > 0
tool_names = [t.name for t in result.tools]
assert "add" in tool_names
@pytest.mark.asyncio
async def test_call_tool(client):
result = await client.call_tool("add", {"a": 5, "b": 3})
assert "8" in result.content[0].text5. Schema Validation
Ensure your tool schemas are valid JSON Schema. Invalid schemas cause tools to fail silently.
import Ajv from "ajv";
describe("Tool Schemas", () => {
const ajv = new Ajv();
it("all tools have valid input schemas", async () => {
const tools = await server.listTools();
for (const tool of tools.tools) {
// Validate the schema itself is valid JSON Schema
const valid = ajv.validateSchema(tool.inputSchema);
expect(valid).toBe(true);
// Ensure required fields are present
expect(tool.name).toBeDefined();
expect(tool.description).toBeDefined();
expect(tool.description.length).toBeGreaterThan(10);
}
});
it("add tool schema accepts valid input", () => {
const addSchema = tools.find(t => t.name === "add").inputSchema;
const validate = ajv.compile(addSchema);
expect(validate({ a: 1, b: 2 })).toBe(true);
expect(validate({ a: "not a number", b: 2 })).toBe(false);
});
});6. Using the MCP Inspector
The MCP Inspector is essential for manual testing and debugging:
# Run your server with the Inspector
npx @modelcontextprotocol/inspector node dist/index.js
# For Python servers
npx @modelcontextprotocol/inspector python server.py
# With environment variables
API_KEY=xxx npx @modelcontextprotocol/inspector node dist/index.jsWhat to Test in the Inspector
- Tool listing: Verify all tools appear with correct descriptions
- Tool invocation: Test with various inputs
- Error handling: Try invalid inputs and edge cases
- Resource listing: Check resources are accessible
- Prompt templates: Verify prompts generate correctly
Inspector Tips
- Use the "Raw" view to see exact JSON-RPC messages
- Test edge cases that are hard to automate
- Verify error messages are helpful for AI
- Check response times for performance issues
7. Mocking External Dependencies
Mock external services to make tests fast and reliable:
TypeScript with Jest Mocks
// Mock the database module
jest.mock("./database", () => ({
query: jest.fn(),
connect: jest.fn(),
}));
import { query } from "./database";
import { searchUsers } from "./tools/users";
describe("searchUsers", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("returns formatted user results", async () => {
// Setup mock return value
(query as jest.Mock).mockResolvedValue([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
const result = await searchUsers("test");
expect(query).toHaveBeenCalledWith(
expect.stringContaining("SELECT"),
expect.arrayContaining(["test"])
);
expect(result).toContain("Alice");
expect(result).toContain("Bob");
});
it("handles empty results", async () => {
(query as jest.Mock).mockResolvedValue([]);
const result = await searchUsers("nonexistent");
expect(result).toContain("No users found");
});
it("handles database errors", async () => {
(query as jest.Mock).mockRejectedValue(new Error("Connection failed"));
await expect(searchUsers("test")).rejects.toThrow("Connection failed");
});
});Python with pytest-mock
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_search_users(mocker):
# Mock the database query
mock_query = mocker.patch(
"src.database.query",
new_callable=AsyncMock,
return_value=[
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
]
)
from src.tools.users import search_users
result = await search_users("test")
mock_query.assert_called_once()
assert "Alice" in result
assert "Bob" in result8. Pre-Deployment Checklist
Before Deploying Your Server
- ✓ All tools have valid JSON schemas
- ✓ Every tool has at least one unit test
- ✓ Error cases return helpful, actionable messages
- ✓ Server handles graceful shutdown (SIGTERM, SIGINT)
- ✓ Sensitive data is not logged or exposed in errors
- ✓ All tools work correctly in the MCP Inspector
- ✓ Server starts within a reasonable time (<5 seconds)
- ✓ Memory usage is stable over time (no leaks)
- ✓ Integration tests pass
- ✓ Documentation is up to date
Automated 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
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Run integration tests
run: npm run test:integration
- name: Validate schemas
run: npm run validate-schemas
- name: Test with Inspector
run: |
timeout 30 npx @modelcontextprotocol/inspector \
--test node dist/index.js || trueConclusion
A well-tested MCP server is reliable and predictable. Combine automated tests with manual verification using the Inspector to build confidence in your implementation. Start with unit tests for fast feedback, add integration tests for end-to-end verification, and use the Inspector for exploratory testing.
Outdated Content Warning
This guide was last updated on January 11, 2025 (12 months ago).
The information presented here may be significantly outdated. Technologies, APIs, and best practices may have changed since this content was written.
We strive to keep our content current, but with rapidly evolving technologies, some details may no longer be accurate.
Last updated
January 11, 2025
375 days ago
This content may be outdated
This content may contain outdated information. Please verify details before use.