A small green grasshopper in sharp focus on the edge of a leaf while the surrounding foliage blurs into darkness: the one bug that stays hidden until you look at exactly the right surface.
Build an MCP Server · Part 3 of 3 All parts ↓

Your tests pass. The Inspector works. Claude Desktop can’t connect.

Nothing crashed. Nothing logged. The server simply isn’t there.

That gap, between “the tests pass” and “an agent can actually use it,” is where MCP servers really break. It opens because testing an MCP server is not one question but four, each with its own tool. This post picks up the notes server from how to build an MCP server in Python and answers the first three, in the order you should ask them.

TL;DR: Testing an MCP server means answering four questions, each with a different tool. Unit tests → is my Python correct. The Inspector → is my protocol correct. Real clients → is my runtime correct. LLM evaluation → is my API understandable. Debug from the bottom up, and guard stdout so a stray print can’t silently corrupt the transport.


The four questions every MCP server must answer

Before you test anything, know what you are testing. Every debugging tool for an MCP server exists to remove one specific source of doubt, and they stack in a fixed order:

The four questions MCP testing answers, drawn as a bottom-up stack: unit tests (is my Python code correct) as the foundation, then the Inspector (is my protocol correct), real clients (is my runtime environment correct), and LLM evaluation (is my API understandable to a model) on top, each layer resting on the one below.

Most debugging sessions go wrong because people start at the top. They ask whether Claude understands the tool before proving the transport is even intact. Each question assumes the ones below it are already answered, so the fix is almost always to find the lowest broken one first. There is no point asking whether a model understands your search tool while a stray print is still corrupting the transport, or whether the protocol is right while the tool returns the wrong rows.

The first two questions are deterministic and cheap, so you automate them. The third is environmental, and it bites hardest in practice, because your tests run in your shell and the client runs somewhere else entirely. The fourth needs a model in the loop, which is the your LLM is free QA for your MCP server idea. This post makes the first three boringly reliable so that the fourth is the only interesting question left.


Interactive debugging with the Inspector

The MCP Inspector is the official interactive tool for exercising a server by hand. It is the fastest way to answer “is my server even doing what I think.” FastMCP 4 ships it behind the dev command:

fastmcp dev inspector server.py

If you are not using FastMCP’s CLI, the raw invocation works against any server:

npx -y @modelcontextprotocol/inspector python server.py

Either way, you get a browser UI with three regions that matter:

  • The left sidebar lists your capabilities by type: Tools, Resources, Prompts. If a tool is missing here, it never registered. Stop and check your decorators before anything else.
  • The main panel is where you fill in parameters and run a call.
  • The bottom panel shows the raw JSON-RPC messages. This is the part people ignore and the part that actually tells you the truth.

Call add_note with a title and content, then read the bottom panel. You will see the exact tools/call request and the raw result your server returned:

-> tools/call  add_note
   {"title": "Launch", "content": "Ship on Friday"}
<- result
   {"id": "a3f7b2c1", "title": "Launch",
    "content": "Ship on Friday"}

That raw view is your ground truth. When a tool “works” but the model behaves oddly, the answer is almost always sitting in this panel: a field named something the model misreads, a count that disagrees with an array length, a null where the schema promised a string.

The reason these slip past you is a habit mismatch. Engineers inspect a response one field at a time, checking each in isolation. Models never do. They consume the entire payload as one sequence of tokens and reason across all of it at once. A response can be correct field by field and still be incoherent read as a whole, and the bottom panel is the only place you see it the way the model does.

For a server running over HTTP instead of stdio (the deploy target from how to deploy a Python MCP server), point the Inspector at the URL instead of spawning a subprocess. Same three panels, same raw JSON-RPC, but now you are also testing the transport and any auth in front of it.


The stdio traps: why a passing server fails silently

Here is the failure that opens this post. Over the default stdio transport, your server speaks JSON-RPC on stdout. That channel is reserved. Anything else written to stdout, a single print, a library banner, a progress bar, a warning, lands in the middle of a JSON-RPC frame and corrupts it.

The result is not a crash. It is silence. The Inspector’s Notifications pane shows Unexpected token parse errors. Claude Desktop shows the server failing to connect with no useful reason. Your tests, which run in-process and never touch stdio, stay green.

The obvious source is your own code, and the fix is to send debug output to stderr:

import sys

print("debug", file=sys.stderr)  # safe
print("debug")                   # corrupts stdio

The rule is about the stream, not the function. A print to stderr is harmless; a print to stdout is fatal. Every trap below is just an unexpected path to stdout you did not put there:

  • A dependency prints on import. Some libraries emit a banner or a “first run” notice to stdout. It only fires in the real subprocess, never in your test.
  • Logging configured to stdout. logging.basicConfig() defaults to stderr, which is fine, but a StreamHandler(sys.stdout) copied from a tutorial is not.
  • Pretty-printers and consoles. rich’s Console() and rich.print write to stdout by default, and so does pprint. A helper you added to read a payload more easily is enough to break the transport.

Because none of this shows up in-process, you need a test that asserts your stdout stays clean when a tool runs. capsys captures anything written during the call:

async def test_stdout_stays_clean(client, capsys):
    """A tool call must not write to stdout."""
    await client.call_tool(
        "add_note",
        {"title": "x", "content": "y"},
    )
    captured = capsys.readouterr()
    assert captured.out == "", (
        f"stdout polluted: {captured.out!r}"
    )

This one assertion catches the entire class of stdio-corruption bugs before they reach a client, including the ones a dependency introduces on an upgrade. It is the single highest-value test in an MCP suite.

To reproduce a suspected pollution by hand, run the server the way the client does and watch which stream the noise lands on:

python server.py < /dev/null 1>/tmp/out 2>/tmp/err

If /tmp/out contains anything that is not JSON-RPC, you found your corruption. /tmp/err is where all your logging should be. Feeding /dev/null to stdin makes the server exit immediately, so this only catches pollution at import time. The capsys test above covers what happens during a tool call.


In-process tests that catch real bugs

FastMCP’s Client connects straight to your server object, no subprocess, no port. The build tutorial showed the basic shape: a temp database, a client fixture, and a few tool calls. Once your tool calls are covered, everything else in the server is just another interface to exercise. Resources, prompts, and error behavior each ship untested in most suites. Cover them quickly, then spend the real effort on the one test that catches production bugs.

Start from the fixtures:

import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from server import mcp, init_db

@pytest.fixture(autouse=True)
async def setup_db(tmp_path, monkeypatch):
    db_path = str(tmp_path / "test_notes.db")
    monkeypatch.setattr("server.DB_PATH", db_path)
    await init_db()

@pytest.fixture
async def client():
    async with Client(mcp) as c:
        yield c

These are plain async def tests with no @pytest.mark.asyncio, which works because the build tutorial set asyncio_mode = "auto" under [tool.pytest.ini_options] in pyproject.toml. Without it, pytest-asyncio collects every async test below and runs none of them.

Resources and prompts are interfaces too. The note://{note_id} resource and the summarize_notes prompt are things an agent depends on, and each fails in its own way: a resource that raises on a valid ID, a prompt that silently renders an empty template. Same client, different methods:

async def test_resource_reads_a_note(client):
    created = await client.call_tool(
        "add_note",
        {"title": "Roadmap", "content": "Q1 plan"},
    )
    note_id = created.data["id"]
    contents = await client.read_resource(
        f"note://{note_id}"
    )
    assert "Q1 plan" in str(contents)

async def test_prompt_lists_stored_notes(client):
    await client.call_tool(
        "add_note",
        {"title": "Standup", "content": "Blockers"},
    )
    result = await client.get_prompt("summarize_notes")
    text = str(result.messages)
    assert "Standup" in text
    assert "no notes" not in text.lower()

Masking needs its own test. If you set mask_error_details=True to keep internal errors away from clients, a later refactor can quietly start leaking stack traces unless a test pins the behavior that expected errors still reach the caller:

async def test_toolerror_message_survives(client):
    """Expected errors stay visible even with masking."""
    with pytest.raises(ToolError, match="not found"):
        await client.call_tool(
            "delete_note",
            {"note_id": "missing"},
        )

Then comes the test that earns its place in the suite.

Test the shape of what you return, not just its presence. Almost nobody asserts that a response is internally consistent, so almost every server eventually ships one that is not: a total field that disagrees with the length of the array beside it, a page count that drifts, a matches number computed on a different code path from the matches themselves. A contradiction is obvious the second you see one:

{"total": 3, "matches": [{ }, { }]}

The payload promises three and delivers two. Nothing there is invalid JSON or a schema violation, so nothing catches it.

The notes server returns a bare list, so there is nothing to contradict yet. The assertion is trivial:

async def test_search_count_matches_array(client):
    await client.call_tool(
        "add_note",
        {"title": "API", "content": "deprecate v1"},
    )
    result = await client.call_tool(
        "search_notes", {"query": "v1"}
    )
    rows = result.data
    assert len(rows) == 1

The moment you add a total alongside that array, though, pin them together in the same test so they can never diverge. Asserting the relationship between fields rather than each field alone catches a class of bug I have hit in production: a count and its array computed on different paths that drifted apart after a refactor. Every field was individually correct, so the tests stayed green, and only the relationship between them was wrong.

You cannot unit-test whether a model understands your API, but you can unit-test that your API never contradicts itself. For everything past that, you need a model in the loop, which is where your LLM is free QA for your MCP server and evaluating the server with an LLM come in: drive it like an agent and watch where it stumbles.


Reproducing “works in tests, breaks in the client”

When the suite is green and the Inspector is happy but Claude Desktop still fails, the problem is the environment, not the code. The client launches your server as a subprocess with its own working directory, its own environment, and its own Python. Your tests inherit yours.

The usual culprits:

  • Working directory. A relative DB_PATH like "notes.db" resolves against wherever the client started the process, not your project folder. Use an absolute path or resolve it against the file’s location.
  • Python interpreter. The command in the client config must point at the interpreter that has your dependencies. A bare python may be the system one, not your venv.
  • Missing environment variables. Anything you set in your shell but not in the client config is absent at runtime.

Reproduce the client’s exact launch and read stderr, where all your logging now lives:

cd / && /path/to/.venv/bin/python \
  /path/to/notes-mcp/server.py 2>/tmp/err

If it fails from / but works from the project directory, you have a path bug. For Claude Code, claude mcp list reports each server’s connection status, which tells you whether the launch itself is failing before you even look at tools.

The deeper point is that “Claude Desktop” is not special here. Every launcher creates a slightly different runtime: its own working directory, its own environment, sometimes its own interpreter. A server that runs under one and fails under another does not have a client bug. It has an assumption about its environment that only one launcher happens to satisfy. Make the server independent of where it starts, and the differences stop mattering.


Wiring it into CI

None of this holds unless it runs on every change. A GitHub Actions job runs the suite, plus the one guard tests cannot express from inside the process: a step that fails on a stray print in server code.

name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install "fastmcp>=4.0.3,<4.1" aiosqlite
      - run: pip install pytest pytest-asyncio
      - name: Reject stray stdout prints
        run: |
          ! grep -nE '^\s*print\(' server.py
      - run: pytest -v

The grep is deliberately blunt. It rejects every bare print( at the start of a line, including print("...", file=sys.stderr), and that is deliberate: in server code you want logging, so a handler decides the stream and nobody has to remember which print was safe. Same discipline I put on agent runs in I built CI for my AI agent, and the same reason testing agent-facing systems has to run automatically or not at all.


A debugging checklist

When a server misbehaves, work from the bottom up:

Tool missing in Inspector?  -> decorator or import error
Parse errors / silent fail? -> something wrote to stdout
Wrong result?               -> read raw JSON-RPC, check payload
Green tests, dead client?   -> cwd, interpreter, or env vars
Passes checks, confuses AI? -> evaluate with an LLM

Most MCP debugging is identifying the first unanswered question. Naming them turns a vague “it does not work” into a two-minute triage.

That is the shift. Good MCP testing is not about proving your code works. It is about removing uncertainty one layer at a time, so that by the time a model touches your server the Python bugs, the transport bugs, and the environment bugs are already impossible, ruled out by tests that ran long before the model showed up.

Whether your interface makes sense to a model is the question a trustworthy server earns the right to ask. Once yours passes it, deploy it over HTTP with auth and Docker, and run the security pass every MCP server needs before it ships.

mcp python ai-agents observability
Kevin Tan

Kevin Tan

Cloud Solutions Architect and Engineering Leader based in Singapore. I write about AWS, distributed systems, and building reliable software at scale.