BXUP Track progress & test yourself — free
QA & Testing · Playbook · Advanced · ~4 min read
QA & TESTING · PLAYBOOK · ADVANCED

Contract-Test an MCP Server Like Any Other API

An MCP server is an API consumed by a model — and it deserves the discipline you give any API under contract: schema validity, discovery, happy and error paths, timeouts, auth scoping, and drift detection. Test the tool layer deterministically so agent-level failures can be blamed on the model, not the plumbing.

Tier: Advanced · Workflow area: Testing AI systems — judges, agents & robustness

This playbook builds a pytest contract suite for an MCP server using an in-memory FastMCP client, with MCP Inspector for exploration and a schema-snapshot regression that catches version drift before the agents that depend on the server do.

When to use

USE THIS WHEN

The tool layer must be trustworthy

Your team ships or consumes an MCP server that agents depend on. Agent tests are failing and you cannot tell model bugs from server bugs. A server update broke agents downstream and nobody noticed until production. Auth-scoped tools (read vs write) need proof that scoping holds.

SKIP IT WHEN

There is no contract to test

Tools are in-process functions with no protocol boundary — plain unit tests suffice. You need to test the model's use of tools — that is the tool-call and trajectory playbooks; this one tests the server those playbooks assume is correct.

Prerequisites

  • The MCP server code or endpoint; for Python servers, FastMCP's in-memory test client removes all transport flakiness.
  • MCP Inspector (npx @modelcontextprotocol/inspector) for interactive exploration.
  • pytest, and a fixtures story for whatever backend the server wraps (test DB, sandbox API).

The workflow

  1. Explore with Inspector and write the contract down. Connect Inspector to the server, list tools, and record for each: name, description, input schema, expected result shape, error behavior, and which auth scope it requires. This table is the contract — most MCP servers have never had one written.

    npx @modelcontextprotocol/inspector uv run my_server.py
    
  2. Test discovery first. Agents choose tools by what discovery returns, so discovery is behavior, not metadata:

    import pytest
    from fastmcp import Client
    from my_server import mcp
    
    @pytest.fixture
    async def client():
        async with Client(mcp) as c:
            yield c
    
    async def test_discovery(client):
        tools = {t.name: t for t in await client.list_tools()}
        assert set(tools) == {"order_lookup", "order_cancel", "refund_create"}
        for t in tools.values():
            assert t.description and len(t.description) > 20
            assert t.inputSchema.get("type") == "object"
            assert "required" in t.inputSchema
    

    The description-length assert looks fussy until you remember the model routes on descriptions: an empty description is a routing bug shipped as documentation.

  3. Happy path per tool, asserting result structure.

    async def test_order_lookup_happy(client, seeded_order):
        res = await client.call_tool("order_lookup",
                                     {"order_id": seeded_order.id})
        assert res.data["status"] == "confirmed"
        assert res.data["total"] == "129.00"
    
  4. Error paths: invalid input must produce protocol-clean errors, not crashes. How a server fails is what the agent sees — a raw stack trace in a result string sends the model off reasoning about Python internals:

    async def test_unknown_order_is_clean_error(client):
        res = await client.call_tool("order_lookup",
                                     {"order_id": "NOPE-404"},
                                     raise_on_error=False)
        assert res.is_error
        assert "not found" in res.content[0].text.lower()
        assert "Traceback" not in res.content[0].text
    
    async def test_missing_required_param_rejected(client):
        res = await client.call_tool("order_lookup", {},
                                     raise_on_error=False)
        assert res.is_error
    
  5. Timeout and slow-backend behavior. Wrap the backend fixture with an injected delay and assert the server times out cleanly within its declared budget instead of hanging the session:

    async def test_slow_backend_times_out(client, slow_backend):
        slow_backend.delay("orders_db", seconds=30)
        res = await client.call_tool("order_lookup",
                                     {"order_id": "X1"},
                                     raise_on_error=False)
        assert res.is_error and "timeout" in res.content[0].text.lower()
    
  6. Auth scoping: prove the read-only token cannot write. Run the suite twice, parameterized by credential, and assert write tools are absent from discovery (or refused on call) under the read scope:

    async def test_readonly_scope_hides_writes(readonly_client):
        tools = {t.name for t in await readonly_client.list_tools()}
        assert "refund_create" not in tools
        assert "order_cancel" not in tools
    
  7. Snapshot the schemas for drift regression. Serialize list_tools output to a committed JSON file; the test fails when any name, description, or input schema changes, forcing a deliberate snapshot update in the same PR as the change:

    pytest tests/mcp_contract -q
    
    FAILED test_schema_drift - tool 'order_lookup' inputSchema changed:
      required: ['order_id'] -> ['order_id', 'region']
    Update snapshot deliberately: pytest --snapshot-update
    

    A new required parameter is a breaking change for every agent prompt-tuned against the old schema — exactly the drift this catches.

  8. Top with a thin LLM-in-the-loop smoke. Two or three end-to-end tasks through a real model against the contract-tested server, run nightly rather than per-commit. It exists to catch integration seams; the deterministic layers below carry the regression load.

Note

Keep the contract suite model-free. Everything through step 7 runs without an LLM key — fast, deterministic, and cheap enough for every commit. The moment a model enters the loop you are testing the model, and that belongs in the agent suites.

Variations

  • Non-Python servers: hit the server over stdio/HTTP with the MCP client SDK of your platform; the assertions are identical, you just lose the in-memory transport.
  • Third-party servers you consume: you cannot fix them, but the snapshot-drift test against the vendor's server in a nightly job is your early warning that an update will break your agents.
  • CI placement: contract suite on every server PR; snapshot-drift nightly against deployed staging; LLM smoke nightly.

Quality check

  • Discovery, happy, error, timeout, auth, driftall six categories present per tool, not just happy paths.
  • Error-path asserts check the text the model will see (clean message, no traceback), not just an error flag.
  • Schema snapshot committed and its update requires a deliberate flag in the changing PR.
  • Suite runs without an API keyanyone can run it locally in seconds.
  • Scoped-credential matrix covers every write tool under every non-write scope.

Pitfalls

  • Testing only through an agent. Model nondeterminism makes server bugs intermittent-looking; you will chase "flaky agents" that are deterministic server faults.
  • Ignoring description quality. Descriptions are the server's UI for the model; treat description edits as breaking-change candidates and cover them in the snapshot.
  • Mocking the server in the server's own tests. Stub the backend, never the MCP layer — the protocol handling is the thing under test.
  • Letting the smoke test gate merges. An LLM-in-the-loop test as a required check imports sampling noise into every PR. Nightly, with a human reading failures.
Key Takeaway:

Every hour of deterministic contract testing on the tool layer buys you back days of agent debugging — because once the server is provably correct, an agent failure has exactly one remaining suspect.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in