Testing MCP Servers: From Unit Tests to Integration Tests
A complete testing strategy: Pydantic schema edge cases, MCP protocol flow testing, load testing, and error path coverage.
Lee Li
Independent Developer · MCP Enthusiast
Testing MCP Servers: From Unit to Integration
Note: This article reflects my testing practices for MCP servers in development. I am not a testing framework author—I am an engineer who has written tests for approximately 15 MCP servers over the past year. The recommendations here reflect what has worked in my projects, not academic testing theory.
Testing MCP servers requires a multi-level strategy. Unit tests catch logic errors. Integration tests catch protocol mismatches. Load tests find race conditions. Here is the strategy I use.
Why MCP Testing Is Different from Regular API Testing
MCP tools use Pydantic for input validation, which means boundary value testing is especially important. Unlike standard function tests where you might test happy path, MCP tools must handle JSON serialization/deserialization, type coercion failures, and schema validation errors. A tool that works when called directly may fail when called over the MCP protocol due to type differences.
Unit Testing Individual Tools With Mock Context
FastMCP provides MockMCPContext for unit testing. This lets you test tool logic without the full MCP protocol stack:
import pytest
from fastmcp import FastMCP
from fastmcp.testing import MockMCPContext
mcp = FastMCP('test-server')
@mcp.tool()
def add_numbers(a: int, b: int) -> int:
return a + b
def test_add_numbers_basic():
ctx = MockMCPContext()
result = mcp.call_tool('add_numbers', {'a': 2, 'b': 3}, ctx)
assert result == 5
def test_add_numbers_invalid_type():
ctx = MockMCPContext()
with pytest.raises(Exception): # Pydantic validation error
mcp.call_tool('add_numbers', {'a': 'two', 'b': 3}, ctx)
MockMCPContext provides minimal context. For auth testing:
def test_protected_tool_with_auth():
ctx = MockMCPContext(auth={'user_id': 'user123', 'role': 'admin'})
result = mcp.call_tool('admin_tool', {}, ctx)
assert result['status'] == 'ok'
def test_protected_tool_without_auth():
ctx = MockMCPContext(auth={})
with pytest.raises(PermissionError):
mcp.call_tool('admin_tool', {}, ctx)
Integration Testing With the Real Protocol
Unit tests miss protocol-level issues: wrong capability flags, malformed JSON-RPC framing, incorrect content type in responses. Use the official SDK's test client for integration tests:
@pytest.mark.asyncio
async def test_full_protocol_flow(mcp_client):
result = await mcp_client.initialize()
assert result.protocolVersion == '2024-11-05'
tools = await mcp_client.list_tools()
assert 'add_numbers' in [t.name for t in tools]
result = await mcp_client.call_tool('add_numbers', {'a': 10, 'b': 20})
assert result.content[0].text == '30'
This catches: incorrect protocol version negotiation, capability mismatches, schema format errors, and JSON-RPC framing issues.
Schema Validation Edge Cases
Test the values that Pydantic rejects—these are where production failures happen:
def test_schema_edge_cases():
with pytest.raises(ValidationError):
validate_input({'query': ''}) # min_length=1
with pytest.raises(ValidationError):
validate_input({'query': 'x' * 501}) # max_length=500
with pytest.raises(ValidationError):
validate_input({'limit': 0}) # ge=1
MCP-Specific Chaos Testing
Beyond standard chaos testing (killing processes, network partitions), MCP servers should be tested against protocol-level chaos:
Simulating malformed JSON-RPC messages:
Simulating MCP client timeouts:
Simulating protocol version mismatches:
@pytest.mark.asyncio
async def test_protocol_version_mismatch(mcp_client):
# Override protocol version in initialize
with pytest.raises(ProtocolVersionError):
await mcp_client.initialize(protocol_version='2023-01-01') # Old version
Recommended Test Distribution for MCP
Based on MCP-specific considerations, I recommend:
This differs from the generic pyramid because MCP's Pydantic validation and protocol negotiation are high-value test targets that generic API testing frameworks do not cover.
Adjust for your complexity: If your tools are simple (mostly pass-through), lean toward protocol tests. If your tools have complex validation and business logic, lean toward unit tests.
What Not to Test
Do not test third-party library internals. Do not test MCP protocol framing that is already tested by the SDK. Do not test trivially correct code. The goal is confidence that tools work, not coverage for its own sake.
Load Testing for Race Conditions
@pytest.mark.asyncio
async def test_concurrent_calls(mcp_client):
async def call_tool(i):
return await mcp_client.call_tool('slow_operation', {'input': f'test_{i}'})
results = await asyncio.gather(*[call_tool(i) for i in range(10)])
assert len(results) == 10
Run against a real server (not mock) to find race conditions and resource leaks. Mock servers do not reveal concurrency bugs.
End-to-End Testing: Simulating Real User Interactions
Unit and integration tests are necessary but not sufficient. End-to-end tests simulate real user interactions with the MCP server running in its actual deployment configuration:
@pytest.fixture(scope='module')
def real_mcp_server():
server = subprocess.Popen(
['python', '/opt/mcp-server/server.py'],
env={'API_KEY': 'test-key', 'LOG_LEVEL': 'DEBUG'},
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
yield server
server.terminate()
server.wait()
def test_server_e2e(real_mcp_server):
client = MCPClient('localhost', 8080)
result = client.call_tool('production_tool', {'input': 'test'})
assert result.status == 'ok'
Related Tools
Lee Li
Independent Developer · MCP Enthusiast
Building and breaking things with AI tools since 2023. MCP Find started as a personal project to track the rapidly evolving MCP ecosystem. Based in Hong Kong.