Testing MCP Servers: Contract Tests, Mocks & Evals

13 min read Module 9 of 10 Topic 26 of 30

What you'll learn

  • Write contract tests that fail when a tool's schema changes in a breaking way
  • Mock downstream systems so integration tests run fast and deterministically, including simulated failures
  • Design an eval suite that measures whether a real model selects and uses tools correctly, not just whether the server responds
  • Decide which test layer runs on every commit versus on a scheduled or triggered basis
Building this at your company? For enterprise and company teams taking this to production: book a 30-minute session with our AI engineers for architecture guidance, code review, and a rollout plan for your use case.
Book a Team Session

Three distinct test layers catch three distinct classes of MCP server failure: contract tests catch schema regressions, mocked integration tests catch business-logic bugs quickly and deterministically, and model-in-the-loop evals catch tool-design problems that only show up when a real model tries to use the tool. Getting the CI cadence right for each layer matters as much as writing the tests themselves.

Layer 1: Contract Tests

Assert the exact shape of each tool’s schema so any unversioned change fails the build immediately, this is the automated enforcement of the versioning discipline from Lesson 16.

import pytest
from mcp.client.session import ClientSession

@pytest.mark.asyncio
async def test_kb_query_v2_contract(mcp_test_session: ClientSession):
    tools = await mcp_test_session.list_tools()
    tool = next(t for t in tools if t.name == "kb_query/v2")

    schema = tool.inputSchema
    assert set(schema["properties"].keys()) == {"query", "top_k", "doc_type", "min_relevance"}
    assert schema["properties"]["query"]["type"] == "string"
    assert "query" in schema.get("required", [])
    assert "doc_type" not in schema.get("required", [])  # Must stay optional, per Lesson 16

Layer 2: Mocked Integration Tests, Including Failure Injection

Test each tool’s actual logic against a mocked downstream system, fast, deterministic, and runnable in CI without live credentials or network access to production systems. It is not enough to only mock the happy path, the error-handling branches built in Lesson 6 and Lesson 13 (timeouts, 404s, 429s, partial failures) need their own tests, or they silently rot the first time a real downstream outage exercises a code path nothing has run since it was written.

@pytest.mark.asyncio
async def test_search_accounts_filters_by_tier(mock_crm_client):
    mock_crm_client.search.return_value = [
        Account(id="A1", name="Acme", tier="enterprise"),
        Account(id="A2", name="Beta", tier="startup"),
    ]

    result = await search_accounts(SearchAccountsInput(query="a", tier="enterprise"))

    mock_crm_client.search.assert_called_once_with("a", tier="enterprise", min_arr=0)
    assert result["count"] == 2  # Mock returns both; asserts the tool passes the filter through correctly
@pytest.mark.asyncio
async def test_query_orders_rejects_sql_injection_attempt(mock_db_pool):
    """Regression test for Lesson 14's parameterized-query requirement."""
    malicious_input = OrdersQueryInput(customer_id="1; DROP TABLE orders;--", limit=5)
    await query_orders(malicious_input)

    # Assert the value was passed as a bound parameter, never concatenated into query text
    call_args = mock_db_pool.fetch.call_args
    assert "DROP TABLE" not in call_args[0][0]  # Query text itself must never contain the payload
    assert call_args[0][1] == "1; DROP TABLE orders;--"  # Passed safely as a parameter value
@pytest.mark.asyncio
async def test_get_account_handles_downstream_timeout(mock_crm_client):
    """Failure-injection test: simulate a CRM timeout and assert the tool
    returns the retryable structured error from Lesson 6, not an unhandled
    exception that would surface as a confusing 500 to the caller."""
    mock_crm_client.get_account.side_effect = CRMTimeoutError()

    result = await get_account(account_id="A1042")

    assert result["isError"] is True
    assert result["retryable"] is True

@pytest.mark.asyncio
async def test_bulk_update_tier_reports_partial_failure(mock_crm_client):
    """Failure-injection test for Lesson 6's partial-success pattern:
    one account succeeds, one raises AccountNotFoundError."""
    async def side_effect(account_id, tier):
        if account_id == "A404":
            raise AccountNotFoundError()
    mock_crm_client.update_tier.side_effect = side_effect

    result = await bulk_update_tier(BulkUpdateInput(account_ids=["A1", "A404"], new_tier="enterprise"))

    assert result["succeeded"] == ["A1"]
    assert result["failed"] == [{"account_id": "A404", "reason": "not_found"}]

Failure-injection tests like these two are what actually validate the error-taxonomy design from Lesson 6, without them, a refactor that accidentally turns a caught CRMTimeoutError into an unhandled exception would pass every happy-path test and only surface in production during the next real timeout.

Layer 3: Model-in-the-Loop Evals

Run a fixed set of representative tasks against a real (or representative open-weight) model connected to the actual server, and score tool selection and argument correctness, not just server correctness, following the same methodology built out in Lesson 23.

EVAL_CASES = [
    {
        "task": "Find all high-priority support tickets for account A1042 from the last week.",
        "expected_tool": "support.search_tickets",
        "expected_args_contains": {"account_id": "A1042", "priority": "high"},
    },
    {
        "task": "What is Acme Corp's current subscription tier?",
        "expected_tool": "crm.search_accounts",  # Not support.search_tickets, tests disambiguation
        "expected_args_contains": {"query": "Acme"},
    },
]

async def run_tool_selection_eval(harness, eval_cases: list[dict]) -> dict:
    results = []
    for case in eval_cases:
        tool_call = await harness.run_single_turn(case["task"])
        correct_tool = tool_call.name == case["expected_tool"]
        correct_args = all(
            tool_call.arguments.get(k) == v for k, v in case["expected_args_contains"].items()
        )
        results.append({"task": case["task"], "correct_tool": correct_tool, "correct_args": correct_args})
    return {
        "tool_selection_accuracy": sum(r["correct_tool"] for r in results) / len(results),
        "argument_accuracy": sum(r["correct_args"] for r in results) / len(results),
    }
flowchart TD
    Layer1["Layer 1: Contract Tests\n(schema shape)"]
    Layer2["Layer 2: Mocked Integration Tests\n(business logic + failure injection)"]
    Layer3["Layer 3: Model-in-the-Loop Evals\n(tool selection quality)"]

    Layer1 -->|"Cheap, deterministic,\nrun on EVERY pull request"| Cadence1["CI: every PR"]
    Layer2 -->|"Fast, no live credentials,\nrun on EVERY pull request"| Cadence2["CI: every PR"]
    Layer3 -->|"Real inference calls,\nslower and costlier"| Cadence3["CI: only when tool name,\ndescription, or schema changes"]

    style Layer1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Layer2 fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style Layer3 fill:#fff7ed,stroke:#f59e0b,color:#0F172A
    style Cadence1 fill:#EEF0F7,stroke:#6366F1,color:#0F172A
    style Cadence2 fill:#f0fdf9,stroke:#0D9488,color:#0F172A
    style Cadence3 fill:#fff7ed,stroke:#f59e0b,color:#0F172A

Run Layer 1 and 2 on every pull request unconditionally, they are fast and deterministic enough that there is no good reason not to. Layer 3 is meaningfully more expensive (real inference calls against a model, whether a hosted API or a self-hosted endpoint), so gate it specifically on changes to what actually affects tool-selection behavior, a tool’s name, its description, or its schema, per Lesson 22’s point that these are exactly the properties a model reasons over. A pull request that only refactors internal implementation without touching any of those three surfaces gains nothing from re-running the eval suite and should not pay its cost.

The next lesson handles a case none of these three layers directly test: tools whose execution genuinely takes longer than a reasonable synchronous request-response window.

Knowledge Check

3 questions to test your understanding

1 A contract test asserts that the `kb_query` tool's input schema requires exactly the fields {query, top_k} with those exact types. A developer adds a new required field without bumping the tool's version. What should happen?

2 Why is a model-in-the-loop eval (having a real or representative model attempt real tasks against the server) necessary in addition to contract tests and mocked integration tests?

3 A team runs contract tests, mocked integration tests, and model-in-the-loop evals all as required checks on every single pull request, including ones that only fix a typo in a code comment. What is the likely consequence, and what should the CI policy be instead?

Go further with expert guidance

Ready to build production AI?
Talk to our R&D team.

These courses give you the foundation. Our embedded AI teams take you from prototype to production in 30–90 days, with your team, your codebase, your goals. Book a free strategy call to see how we can accelerate your AI initiative.

30 minutes · No obligation · Expert AI engineers, not sales reps

AI Architecture Review

Audit your current stack and identify high-impact improvements

Project Review

Get expert feedback on your AI implementation and codebase

Team Mentoring

Upskill your engineers with hands-on AI coaching sessions

AI Strategy

Define your AI roadmap, prioritization, and implementation plan