Testing servers, and the failure catalog

You would not test a REST API by asking a chatbot to please call it. Yet that is how most people test their first MCP server — through a host, with a model in the loop, paying tokens to discover a typo. This lesson is the professional loop: deterministic tests below, one standard tool in the middle, the host last.

The MCP Inspector

The ecosystem's standard test harness is the MCP Inspector — a browser UI that is an MCP client, minus the model:

bash
npx @modelcontextprotocol/inspector uv run --with fastmcp python paper_trail.py

It spawns your server, performs the handshake, and gives you tabs for each primitive: list tools and read their generated schemas (the fastest way to catch a signature that did not mean what you thought), call any tool from a form and see the exact result frame, read resources, render prompts with arguments filled. A history pane shows every JSON-RPC frame both ways — lesson 4, as an interactive view. It speaks stdio and streamable HTTP alike, and because there is no model, calls are free, instant, and exactly reproducible — which is what a test needs and a chat session can never be.

The inspection that pays first: open your tool list and read each description as a stranger. You are looking at precisely what the model will see — no more. Half of all "the model uses my tool wrong" bugs are visible on that tab without a single call.

Below the Inspector: in-process tests

FastMCP can serve a client in memory — no subprocess, no wire — which makes ordinary unit tests of MCP behaviour cheap enough to run on every save:

python
from fastmcp import Client
from paper_trail import mcp

async def test_unknown_order_is_a_teaching_error():
    async with Client(mcp) as c:
        r = await c.call_tool("order_status", {"order_id": "PT-9999"},
                              raise_on_error=False)
        assert r.is_error
        assert "no such order" in r.content[0].text

Test the contract, not the plumbing: every tool with its happy path, every validation branch by its message text (that text is model-facing interface — lesson 9 — so a test pinning it is a test of UX), and discovery itself (tool count and names), which catches an accidentally unregistered function — a silent failure nothing else surfaces.

The failure catalog

Six failures cover most of what goes wrong. Each has a signature; learn the signatures and debugging becomes routing.

The server never appears in the host. Not a protocol problem — a spawn problem: wrong command path, missing interpreter, an env var the server needs that the host's environment lacks. Hosts run servers from their environment, not your shell's — your PATH and exports are not there. Diagnosis: run the exact command from the host's config, verbatim, in a bare shell.

Connects, then instantly dies. The process started and crashed before or during the handshake — an import error, a missing key read at module level. The traceback is in stderr, which the host logged: read the host's MCP log, not your imagination.

Handshake completes; a capability is missing. The client never asks for what a server did not declare, and SDKs declare only what you registered. A typo'd decorator, a registration behind an if — the capability object in the initialize response (lesson 4) is ground truth; read it in the Inspector's history pane.

Everything lists; calls fail with invalid params. The schema and the model's (or your) arguments disagree — often a rename on one side. The Inspector's schema view against the actual call frame settles it in seconds.

Calls hang. The tool is doing something slow with no progress reporting, or is deadlocked on something that will never arrive — including, on stdio, the corrupted-frame case from lesson 9, where each side waits for a line the other will never send. Timeouts on calls; progress on slow tools; stderr for what the server was doing when the music stopped.

Works in the Inspector; fails through the host. The most instructive one: the protocol layer is fine, and the difference is the model. Almost always the tool description — ambiguous, or colliding with a similarly-described tool from another server so the model picks the wrong one. You are no longer debugging code; you are debugging prose. Edit the docstring, reconnect, retry.

Six failure signatures, and the layer each one lives in
Six failure signatures, and the layer each one lives in

Try this: point the Inspector at your Paper Trail copy, call order_status with PT-9999, and find the isError frame in the history pane. Then write the in-process test above and make it pass. You now have the whole testing ladder — permanent, free, and model-less until the last rung.

← Previous