08 · Testing MCP Servers (Inspector & Unit Tests)¶
You've been testing every server so far by poking at it manually through the
Inspector. That's the right tool for exploration — it's the wrong tool for
making sure create_issue still works after you refactor it next month.
This lesson covers both: deeper Inspector usage, and real automated tests.
The Inspector, beyond "click a tool and see what happens"¶
A few features worth knowing:
- History tab — every request and response sent during the session,
in order, with full JSON. This is the easiest way to see the exact wire
shape of a call without writing
curlby hand. - Notifications — a separate panel for one-way messages (progress updates, log messages) that don't fit the request/response view.
- Direct JSON editing — for a tool with a complex input schema, you can edit the raw JSON arguments instead of filling in a generated form, which is faster for testing edge cases like missing required fields.
Treat the Inspector as your REPL for this protocol — reach for it the moment something behaves unexpectedly, before reaching for print statements.
Unit-testing the handler, with no protocol involved¶
The fastest, most useful tests skip the protocol layer entirely and call your tool functions as plain Python:
# test_github_server.py
import pytest
from github_server import list_issues, create_issue
@pytest.mark.asyncio
async def test_list_issues_formats_each_as_a_line(monkeypatch):
async def fake_get(self, url, **kwargs):
class FakeResponse:
def raise_for_status(self): pass
def json(self): return [
{"number": 1, "title": "Bug A"},
{"number": 2, "title": "Bug B"},
]
return FakeResponse()
monkeypatch.setattr("httpx.AsyncClient.get", fake_get)
result = await list_issues(owner="acme", repo="widgets")
assert result == "#1: Bug A\n#2: Bug B"
@pytest.mark.asyncio
async def test_list_issues_empty_state_message(monkeypatch):
async def fake_get(self, url, **kwargs):
class FakeResponse:
def raise_for_status(self): pass
def json(self): return []
return FakeResponse()
monkeypatch.setattr("httpx.AsyncClient.get", fake_get)
result = await list_issues(owner="acme", repo="widgets")
assert "No open issues" in result
@mcp.tool()-decorated functions are still plain async def functions
underneath — importing and calling them directly, with the actual network
call mocked out, is ordinary pytest with no MCP machinery involved at all.
This is where almost all of your test coverage should live: it's fast,
doesn't need a running server, and tests the logic that actually matters.
A thin integration test, through the real protocol¶
For the handful of things unit tests can't catch — a tool's schema doesn't match what you think it does, or the server fails to start at all — use the SDK's in-process test client instead of mocking anything:
# test_integration.py
import pytest
from mcp.shared.memory import create_connected_server_and_client_session
from github_server import mcp
@pytest.mark.asyncio
async def test_list_issues_tool_is_registered():
async with create_connected_server_and_client_session(mcp._mcp_server) as client:
tools = await client.list_tools()
names = [t.name for t in tools.tools]
assert "list_issues" in names
assert "create_issue" in names
This spins up a real client/server pair connected over an in-memory
transport — no subprocess, no network — and exercises the actual
tools/list request. It's slower than the unit tests above and should be a
much smaller fraction of your suite: a handful of smoke tests confirming the
server wires up correctly, not where you test business logic.
A testing pyramid for MCP servers¶
- Most tests: plain unit tests on handler functions, with any outside API fully mocked — fast, numerous, test actual behavior.
- A few integration tests using the in-process client, confirming tools are registered with the schemas you expect and the server starts cleanly.
- Manual Inspector sessions for exploratory testing and debugging, not as a substitute for either of the above.
Exercise¶
Write a unit test for create_issue that mocks httpx.AsyncClient.post to
return a fake issue response, and asserts the returned string contains both
the issue number and its URL. Then write one more test confirming that if
the mocked response's raise_for_status() raises (simulating a 422 from
GitHub, e.g. an empty title), the exception propagates out of create_issue
rather than being silently swallowed.