Blog/

Intermediate

10 min read

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.

LL

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:

  • What happens when the JSON is valid but missing required fields?

  • What happens when the JSON-RPC version is wrong?
  • Simulating MCP client timeouts:

  • What happens when the client aborts mid-request?

  • Does your server handle partial state correctly?
  • Simulating protocol version mismatches:

  • What happens when a client sends an unsupported protocol version?

  • Does your error message help debug the issue?
  • @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:

  • 70% unit tests: Focus on Pydantic boundary values, error handling, and auth logic
  • 20% protocol integration tests: JSON-RPC framing, capability negotiation, schema validation
  • 10% end-to-end tests: Real MCP client, full server deployment, actual API calls
  • 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

  • [mcp-use](/tools/mcp-use) — Testing utilities for MCP server evaluation. Our preferred tool for running structured MCP server tests.
  • LL

    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.

    info@mcp-find.org📍 Sai Kung, Kowloon, Hong Kong

    Sponsored