Testing MCP Servers: A Complete Guide
February 2025 ยท 12 min read
You've built an MCP server. But how do you know it works? This guide covers everything from quick manual testing to comprehensive test suites that catch bugs before your users do.
Quick Validation with MCP Inspector
The fastest way to test your MCP server is the MCP Inspector โ a web-based tool that lets you interact with your server in real-time. It shows exactly what tools, resources, and prompts your server exposes.
Installing MCP Inspector
# Install globally
npm install -g @modelcontextprotocol/inspector
# Or run directly with npx
npx @modelcontextprotocol/inspectorRunning the Inspector
# For a Python server
npx @modelcontextprotocol/inspector python your_server.py
# For a TypeScript/Node server
npx @modelcontextprotocol/inspector node build/index.js
# For an installed package
npx @modelcontextprotocol/inspector npx -y your-mcp-serverThe Inspector opens a web interface (usually at localhost:5173) where you can:
- Browse all exposed tools, resources, and prompts
- Execute tools with custom arguments
- View raw JSON-RPC messages
- Test error handling
- Inspect resource contents
๐ก Pro Tip
Keep the Inspector running during development. It's the quickest way to verify changes without restarting your MCP client.
Manual Testing Techniques
Sometimes you need to test the raw protocol. Here's how to talk to your MCP server directly.
Using stdio Directly
MCP servers communicate over stdin/stdout. You can pipe JSON-RPC messages directly:
# Initialize the connection
echo '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}' | python your_server.py
# List available tools
echo '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}' | python your_server.py
# Call a specific tool
echo '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "your_tool", "arguments": {"arg1": "value"}}}' | python your_server.pyTesting with a Simple Script
#!/usr/bin/env python3
"""Quick test script for MCP servers."""
import subprocess
import json
def send_message(proc, message):
"""Send a JSON-RPC message and get response."""
proc.stdin.write(json.dumps(message) + "\n")
proc.stdin.flush()
response = proc.stdout.readline()
return json.loads(response)
# Start the server
proc = subprocess.Popen(
["python", "your_server.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True
)
# Initialize
response = send_message(proc, {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0"}
}
})
print("Initialize:", response)
# List tools
response = send_message(proc, {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
})
print("Tools:", response)
proc.terminate()Unit Testing in Python (FastMCP)
FastMCP makes testing straightforward. Here's how to write proper unit tests for your tools.
Basic Test Structure
# test_server.py
import pytest
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
# Your server code
from server import mcp # assuming your FastMCP instance is called 'mcp'
@pytest.fixture
async def client():
"""Create a test client connected to the server."""
server_params = StdioServerParameters(
command="python",
args=["server.py"]
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
@pytest.mark.asyncio
async def test_tools_list(client):
"""Test that tools are properly exposed."""
result = await client.list_tools()
tool_names = [t.name for t in result.tools]
assert "my_tool" in tool_names
assert "another_tool" in tool_names
@pytest.mark.asyncio
async def test_tool_execution(client):
"""Test tool execution with valid input."""
result = await client.call_tool("my_tool", {"input": "test"})
assert len(result.content) > 0
assert result.content[0].type == "text"
assert "expected output" in result.content[0].textTesting Tools Directly (Without Protocol)
For faster unit tests, you can test your tool functions directly without going through the MCP protocol:
# server.py
from fastmcp import FastMCP
mcp = FastMCP("test-server")
@mcp.tool()
def calculate_sum(a: int, b: int) -> int:
"""Add two numbers together."""
return a + b
@mcp.tool()
async def fetch_data(url: str) -> str:
"""Fetch data from a URL."""
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.text
# test_tools.py
import pytest
from server import calculate_sum, fetch_data
def test_calculate_sum():
"""Direct unit test for calculate_sum."""
assert calculate_sum(2, 3) == 5
assert calculate_sum(-1, 1) == 0
assert calculate_sum(0, 0) == 0
def test_calculate_sum_type_error():
"""Test that invalid types raise errors."""
with pytest.raises(TypeError):
calculate_sum("not", "numbers")
@pytest.mark.asyncio
async def test_fetch_data(httpx_mock):
"""Test fetch_data with mocked HTTP."""
httpx_mock.add_response(
url="https://api.example.com/data",
text="mocked response"
)
result = await fetch_data("https://api.example.com/data")
assert result == "mocked response"Testing Error Handling
@pytest.mark.asyncio
async def test_tool_invalid_input(client):
"""Test that invalid input returns proper error."""
with pytest.raises(Exception) as exc_info:
await client.call_tool("my_tool", {"invalid": "params"})
assert "required parameter" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_tool_handles_api_error(client, httpx_mock):
"""Test graceful handling of API failures."""
httpx_mock.add_response(status_code=500)
result = await client.call_tool("fetch_data", {"url": "https://api.example.com"})
# Should return error message, not crash
assert "error" in result.content[0].text.lower()Unit Testing in TypeScript
For TypeScript MCP servers, here's how to set up testing with Jest or Vitest.
Setting Up Vitest
# Install dependencies
npm install -D vitest @types/node
# package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}Writing Tests
// src/tools.ts - Your tool implementations
export function calculateSum(a: number, b: number): number {
return a + b;
}
export async function fetchData(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.text();
}
// src/tools.test.ts
import { describe, it, expect, vi } from 'vitest';
import { calculateSum, fetchData } from './tools';
describe('calculateSum', () => {
it('adds positive numbers', () => {
expect(calculateSum(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(calculateSum(-1, 1)).toBe(0);
});
it('handles zero', () => {
expect(calculateSum(0, 0)).toBe(0);
});
});
describe('fetchData', () => {
it('returns response text on success', async () => {
// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('mocked data'),
});
const result = await fetchData('https://api.example.com');
expect(result).toBe('mocked data');
});
it('throws on HTTP error', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await expect(fetchData('https://api.example.com'))
.rejects.toThrow('HTTP 500');
});
});Testing the Full Server
// src/server.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
describe('MCP Server Integration', () => {
let serverProcess: ChildProcess;
beforeAll(async () => {
serverProcess = spawn('node', ['build/index.js'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
// Wait for server to be ready
await new Promise(resolve => setTimeout(resolve, 1000));
});
afterAll(() => {
serverProcess.kill();
});
it('responds to initialize', async () => {
const message = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'test', version: '1.0' },
},
});
serverProcess.stdin!.write(message + '\n');
const response = await new Promise<string>((resolve) => {
serverProcess.stdout!.once('data', (data) => {
resolve(data.toString());
});
});
const parsed = JSON.parse(response);
expect(parsed.result).toBeDefined();
expect(parsed.result.serverInfo).toBeDefined();
});
});Integration Testing
Integration tests verify your server works correctly with real MCP clients. Here's a pattern for end-to-end testing.
# test_integration.py
import pytest
import asyncio
from pathlib import Path
@pytest.fixture(scope="module")
def event_loop():
"""Create event loop for the test module."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="module")
async def mcp_client():
"""Start server and create client for integration tests."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
server_params = StdioServerParameters(
command="python",
args=[str(Path(__file__).parent.parent / "server.py")],
env={
"API_KEY": "test_key", # Test credentials
"DEBUG": "true"
}
)
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
class TestIntegration:
"""Integration test suite."""
@pytest.mark.asyncio
async def test_full_workflow(self, mcp_client):
"""Test a complete user workflow."""
# Step 1: List available tools
tools = await mcp_client.list_tools()
assert len(tools.tools) > 0
# Step 2: Use first tool
first_tool = tools.tools[0]
result = await mcp_client.call_tool(
first_tool.name,
{"input": "test data"}
)
assert result.content
# Step 3: Read a resource
resources = await mcp_client.list_resources()
if resources.resources:
content = await mcp_client.read_resource(
resources.resources[0].uri
)
assert content.contents
@pytest.mark.asyncio
async def test_concurrent_requests(self, mcp_client):
"""Test that server handles concurrent requests."""
tasks = [
mcp_client.call_tool("my_tool", {"input": f"test_{i}"})
for i in range(10)
]
results = await asyncio.gather(*tasks)
assert len(results) == 10
assert all(r.content for r in results)CI/CD Pipeline Setup
Automate your tests with GitHub Actions. Here's a complete workflow:
# .github/workflows/test.yml
name: Test MCP Server
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test-python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -e .
pip install pytest pytest-asyncio pytest-httpx
- name: Run tests
run: pytest -v --tb=short
- name: Test with MCP Inspector
run: |
npm install -g @modelcontextprotocol/inspector
timeout 10 npx @modelcontextprotocol/inspector python server.py &
sleep 5
curl -s http://localhost:5173/api/tools | jq .
test-typescript:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run tests
run: npm test
- name: Lint
run: npm run lintMocking External Dependencies
Your MCP server probably calls external APIs. Here's how to mock them properly.
Python with pytest-httpx
# conftest.py
import pytest
@pytest.fixture
def mock_github_api(httpx_mock):
"""Mock GitHub API responses."""
httpx_mock.add_response(
url="https://api.github.com/repos/owner/repo",
json={
"name": "repo",
"full_name": "owner/repo",
"stargazers_count": 100
}
)
httpx_mock.add_response(
url="https://api.github.com/repos/owner/repo/issues",
json=[
{"number": 1, "title": "Bug fix", "state": "open"},
{"number": 2, "title": "Feature", "state": "closed"}
]
)
return httpx_mock
# test_github_tools.py
@pytest.mark.asyncio
async def test_get_repo_info(client, mock_github_api):
"""Test GitHub repo info tool with mocked API."""
result = await client.call_tool(
"get_repo_info",
{"owner": "owner", "repo": "repo"}
)
assert "100" in result.content[0].text # Star count
assert "owner/repo" in result.content[0].textTypeScript with vi.mock
// test/mocks.ts
import { vi } from 'vitest';
export const mockFetch = vi.fn();
// Reset before each test
beforeEach(() => {
mockFetch.mockReset();
global.fetch = mockFetch;
});
// Helper to mock specific endpoints
export function mockGitHubAPI() {
mockFetch.mockImplementation((url: string) => {
if (url.includes('/repos/')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
name: 'repo',
stargazers_count: 100,
}),
});
}
return Promise.resolve({
ok: false,
status: 404,
});
});
}Testing Best Practices
1. Test the Happy Path First
Start with tests that verify normal operation. Edge cases and error handling come second.
2. Test Tool Descriptions
LLMs use your tool descriptions to decide when to call them. Test that descriptions are accurate and helpful.
def test_tool_descriptions(client):
tools = await client.list_tools()
for tool in tools.tools:
assert len(tool.description) > 20
assert tool.inputSchema is not None3. Test Parameter Validation
Ensure invalid parameters are rejected with clear error messages, not cryptic crashes.
4. Test Timeouts and Cancellation
Long-running tools should handle timeouts gracefully. Test that they can be cancelled.
5. Use Fixtures for Common Setup
Don't repeat server startup code. Use fixtures to share setup across tests.
6. Test with Real LLM Clients (Occasionally)
Unit tests with mocks are fast. But occasionally test with Claude Desktop to catch integration issues.
Quick Reference: Test Commands
# Python
pytest # Run all tests
pytest -v # Verbose output
pytest -x # Stop on first failure
pytest --cov=src # With coverage
pytest -k "test_tool" # Run specific tests
# TypeScript
npm test # Run tests
npm run test:watch # Watch mode
npm run test:coverage # With coverage
# MCP Inspector
npx @modelcontextprotocol/inspector python server.py
npx @modelcontextprotocol/inspector node build/index.jsWrapping Up
Good testing is the difference between "it works on my machine" and "it works." For MCP servers specifically:
- Start with MCP Inspector for quick validation
- Unit test your tool functions directly for speed
- Integration test the full protocol for confidence
- Automate with CI/CD to catch regressions
A well-tested MCP server is one you can ship with confidence.