← Back to Blog

Testing the MCP Like Prisma Does

·8 min read

pyRPC has 197 tests total, 21 for MCP specifically. That ratio is deliberate: the MCP is a protocol server wearing your product name, and testing it as Python unit tests alone would miss exactly the classes of bugs that break real users.

The test pyramid

            /  E2E  \           (config discovery, both layers)
           /________\
          / Integration \        (stdio subprocess, 4 tests)
         /______________\
        /    Unit Tests   \      (in-memory protocol, 17 tests)
       /__________________\

Layer one: in-memory protocol tests (17 tests)

These use the official @modelcontextprotocol/sdk Client class, connecting via InMemoryTransport to the server object directly. No subprocess, no file system, no transport framing. The tests verify tool schemas match expected shapes, call results carry structured content, and error handling returns is_error with remediation text instead of exceptions. Because this is the same Client the SDK uses on itself, protocol drift gets caught upstream before it ever reaches pyRPC tests.

// In-memory: client connects directly to server object
const client = new Client({ name: "test" });
await client.connect(new InMemoryTransport(server));
const tools = await client.listTools();
// verify schema shapes, annotations, structured content

Layer two: stdio subprocess tests (4 tests)

In-memory cannot catch framing bugs, buffering hazards, or lifecycle mistakes. The stdio suite spawns a real uv run pyrpc mcp process in a temporary project and speaks raw JSON-RPC over stdin and stdout. Every stdout line is parsed as protocol, proving stdout purity mechanically. Closing stdin must end the process with exit 0, because that is exactly how GUI clients terminate sessions. These tests also drive the official Client with StdioServerParameters, which is character-for-character how Cursor or Claude Desktop launches the server.

What each layer catches

In-memory tests catch protocol compliance: wrong tool names, missing annotations, malformed schemas, and incorrect error shapes. Stdio subprocess tests catch real process lifecycle: startup ordering, stdout contamination from print statements, clean shutdown behavior, and buffering edge cases. Both layers together catch config discovery: the subprocess tests verify the server finds the project registry the same way a real user session would.

The purity test

Every subprocess test asserts two things beyond correctness: exit code 0 and no file writes. The purity assertion is a sentinel file created before the test and checked for absence after. If the MCP server ever accidentally mutates project state during a read-only introspection, the test fails with a message that explains itself. One stray write and the entire test suite turns red.

Mocking strategy

vi.mock covers only the mutating API, upsertServer, leaving detection functions real. This is the Prisma-inspired approach: test the wire protocol against a real server, mock only the side effects that would touch your file system in ways you do not intend. The detection code that finds installed agents runs against the real file system because that is exactly what breaks when an agent updates its config format. Mocking it would defeat the purpose.

Why real integration tests matter

add-mcp config file formats change. Merge logic evolves. Idempotency is critical because agents re-run registration on every startup. The only way to verify idempotency is to actually call upsertServer twice and assert the file is identical. The only way to catch format drift is to parse real config files. Unit tests that mock the config format are testing your mocks, not the format. Integration tests catch the thing that actually breaks in production: a new agent version that reorganized its JSON nesting.

The nine-leg matrix (three operating systems, three agent configs) caught a separator bug on Windows where ClientInfo paths carried native backslash separators while every other path in tool output used forward slashes. That inconsistency would have confused cross-platform agents forever. Only a real subprocess test on a real OS would surface it.