How do you test MCP servers — unit testing tool handlers vs. integration testing the full protocol flow?

5 minadvancedmcptestingunit-testingintegration-testing

Quick Answer

Unit test the actual business logic behind each tool/resource handler directly, as a plain function, independent of MCP entirely — call get_weather("Austin") directly and assert on its return value, mocking whatever external dependency it calls, exactly like testing any other function. Integration test the full protocol flow using the MCP SDK's in-process test utilities or the MCP Inspector's CLI mode, actually performing the initialize handshake, calling tools/list to confirm the schema is well-formed, and calling tools/call to confirm the wire-level request/response shape (including isError handling and content formatting) is correct end to end — this catches bugs unit tests miss, like a broken JSON Schema, a handler that throws instead of returning a proper isError result, or a transport-level framing issue. A thorough test suite includes both layers: fast, focused unit tests for logic correctness, and a smaller set of integration tests confirming the server actually behaves correctly as an MCP server, not just as a collection of correct Python/TypeScript functions.

Detailed Answer

Testing an MCP server benefits from the same two-layer split as testing any service with a network-facing protocol on top of plain business logic.

Unit testing the underlying logic

def test_get_weather_returns_formatted_string(mock_weather_api):
    mock_weather_api.fetch.return_value = WeatherData(temp_f=91, condition="sunny")
    result = get_weather_logic("Austin", api=mock_weather_api)
    assert result == "Austin: 91F, sunny"

This test never touches JSON-RPC, transports, or the MCP SDK at all — it's testing whether the underlying logic is correct, the same way you'd test any function.

Integration testing the protocol layer

import pytest
from mcp.client.session import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters

@pytest.mark.asyncio
async def test_tools_list_and_call():
    params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            assert any(t.name == "get_weather" for t in tools.tools)

            result = await session.call_tool("get_weather", {"city": "Austin"})
            assert result.isError is False
            assert "Austin" in result.content[0].text

This catches things unit tests structurally can't: a malformed inputSchema that fails validation, a handler that raises an uncaught exception instead of returning a graceful isError result, or a mismatch between what the SDK derives as the schema and what you actually intended.

What each layer catches that the other doesn't

Write most of your coverage as fast unit tests against the underlying logic, mocking dependencies as usual, and add a focused set of integration tests to confirm the MCP-specific plumbing — schemas, error shapes, transport behavior — since that's exactly the layer unit tests alone can't verify:

Unit testsIntegration tests
Business logic correctnessYesIndirectly
JSON Schema validityNoYes
Error handling shape (isError, JSON-RPC errors)NoYes
Speed / iteration costFastSlower (spins up a real process/session)
Catches transport-level bugs (stdout corruption, framing)NoYes, if run against the real transport

Related Resources