MCP in Production · Chapter 8
An early MCP server I shipped to production had zero tests. Not "low coverage" -- zero. I had a tools/list that returned three tools, a tools/call that fanned out to an upstream API, and a Claude Code session where I had personally typed every prompt I could think of and watched the right thing happen. Ship it.
Three weeks later I changed a tool description from "search for products" to "search the catalog for products by name or SKU." Innocuous. Better, even. The next morning Claude stopped calling that tool entirely and started hallucinating product IDs from training data. The eval I didn't have would have caught it in ninety seconds. The eval I did have -- me, manually, in a chat window -- caught it three days and one customer support ticket later.
That experience is why this chapter exists. Testing an MCP server is not the same shape as testing a REST API or a CLI. The protocol has its own contracts, the consumer is a non-deterministic LLM, and the failure modes that matter most -- "the model stopped picking the right tool" -- don't show up in any traditional test suite. You need a layered strategy that covers the protocol, the transport, the handler logic, AND the model-facing surface. This chapter is the strategy I wish I'd had on day one.
Every MCP server I ship at Yaw Labs gets tested at four layers, in increasing order of cost and decreasing order of frequency:
These map roughly onto the classic test pyramid, but with one crucial inversion that I'll come back to: for MCP servers, the integration layer carries more weight than the unit layer. Handler logic is usually a thin shim over an upstream API. The interesting bugs live at the seams.
Callout: the hidden fifth layer. There's an implicit "manual smoke test in Claude Code" that every MCP author does at least once before shipping. Don't pretend it doesn't exist; bake it into your release checklist. I keep a
prompts.mdper repo with five to ten prompts that exercise the happy path. Before tagging a release, I paste those into Claude Code and watch. It is not a substitute for the four layers above. It is a sanity gate that catches the "wait, the binary doesn't even start" class of problem in thirty seconds.
A handler in an MCP server is the function that runs when tools/call arrives for a specific tool. In SDK 1.x with McpServer, that's the callback you pass to server.registerTool(...). (The lower-level Server class with its own setRequestHandler(CallToolRequestSchema, ...) is also available, but the rest of this book sticks with McpServer -- and so do all fourteen @yawlabs servers.)
Either way, the handler does roughly four things:
isError: true on failure and returns a useful error message.You unit-test each of those four things independently of the transport. The handler is just a function; you import it, you call it, you assert on the return value. No subprocess, no JSON-RPC, no SDK plumbing.
Here's the structure I use, from a real @yawlabs/lemonsqueezy-mcp test:
// tests/handlers/list-prices.test.ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { handleListPrices } from "../../src/handlers/list-prices.js";
import * as upstream from "../../src/upstream/lemonsqueezy.js";
vi.mock("../../src/upstream/lemonsqueezy.js");
describe("ls_list_prices", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("returns a text content block summarizing prices", async () => {
vi.mocked(upstream.listPrices).mockResolvedValue({
data: [
{ id: "1", attributes: { unit_price: 1500, status: "published" } },
{ id: "2", attributes: { unit_price: 2500, status: "published" } },
],
});
const result = await handleListPrices({ variantId: "v_123" });
expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("2 prices"),
});
});
it("flags isError when upstream 404s on an unknown variant", async () => {
vi.mocked(upstream.listPrices).mockRejectedValue(
new upstream.UpstreamError(404, "variant not found"),
);
const result = await handleListPrices({ variantId: "v_does_not_exist" });
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("variant not found"),
});
});
it("rejects an empty variantId before calling upstream", async () => {
const result = await handleListPrices({ variantId: "" });
expect(result.isError).toBe(true);
expect(upstream.listPrices).not.toHaveBeenCalled();
});
});
A few patterns worth calling out.
Mock at the upstream module boundary, not at fetch. I used to mock global.fetch and it was a nightmare -- the upstream client does retries, follows redirects, sometimes parses JSON:API envelopes, and reproducing all of that in a fetch mock is a full second test suite. Wrap the upstream API in a thin module (src/upstream/lemonsqueezy.ts exports listPrices, createPrice, etc.) and mock that module. The mock surface is small, your tests stay readable, and you can change HTTP libraries without rewriting tests.
Assert on content shape, not full content text. expect.stringContaining("2 prices") is a stable assertion. expect(text).toBe("Found 2 prices for variant v_123: $15.00, $25.00") is brittle and breaks every time you tweak the wording. Tweaking the wording is something you'll do constantly while tuning model behavior; don't make your tests punish you for it.
Always assert on isError. Either isError: true (failure) or isError: undefined (success). If you only assert on the content shape, a regression that swaps a success response for an error response with similar text will sail through.
Test the validation path before the upstream call. The third test above (rejects an empty variantId) verifies that argument validation runs before any network call. This matters because validation errors should be cheap and consistent, and because forgetting to validate is a security hole when arguments end up in URLs or queries.
The handlers folder mirrors the tools folder, one test file per tool. For a server with twenty tools, that's twenty test files, each four to ten tests. Total handler unit suite: 80 to 200 tests, runs in under two seconds with vitest's parallel runner. That's the floor.
Callout: zod schemas are testable too. If your tool inputs are described by a zod schema, write a couple of tests that feed obviously-bad input through the schema's
.safeParse()and assert that the error shape is what you want users (and the model) to see. Schema regressions are easy to introduce when you're refactoring tool definitions; cheap to catch at this layer.
Unit tests skip the SDK entirely. That's a feature -- they're fast and focused. But the SDK is also where a lot of real bugs live, and where your server's actual behavior is determined. Capability negotiation, content-block serialization, error code mapping, transport framing -- none of those run during a unit test.
Integration tests close the gap. The pattern is: spawn the compiled server binary as a child process over stdio, send it real JSON-RPC frames, parse the responses, assert on the wire-level result. No mocks below the transport.
Here's my baseline harness:
// tests/integration/harness.ts
import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
export type JsonRpcResponse<T = unknown> =
| { result: T }
| { error: { code: number; message: string } };
/** Asserts a JSON-RPC response carries a `result` and returns it typed.
* Throws (with the server's error message) if the response is an error.
* Use in tests that expect success; for negative tests, discriminate on
* `"error" in response` directly so you can assert on the error code. */
export function unwrap<T>(response: JsonRpcResponse<T>): T {
if ("error" in response) {
throw new Error(
`Expected a result but got JSON-RPC error ${response.error.code}: ${response.error.message}`,
);
}
if (!("result" in response)) {
throw new Error(
`Malformed JSON-RPC response: neither result nor error present. Got: ${JSON.stringify(response)}`,
);
}
return response.result;
}
export interface McpClient {
send<T = unknown>(method: string, params?: unknown): Promise<JsonRpcResponse<T>>;
notify(method: string, params?: unknown): void;
close(): Promise<void>;
/** The result of the initialize handshake; useful for capability assertions. */
initResult: { capabilities: Record<string, unknown>; serverInfo?: { name: string; version: string } };
}
const PROTOCOL_VERSION = "2025-06-18";
export async function startServer(env: Record<string, string> = {}): Promise<McpClient> {
const child = spawn("node", ["dist/index.js"], {
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let nextId = 1;
const pending = new Map<number, (value: JsonRpcResponse) => void>();
let buffer = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (!line.trim()) continue;
const msg = JSON.parse(line);
if (typeof msg.id === "number" && pending.has(msg.id)) {
pending.get(msg.id)!(msg as JsonRpcResponse);
pending.delete(msg.id);
}
}
});
child.stderr.on("data", (chunk: Buffer) => {
if (process.env.MCP_TEST_DEBUG) process.stderr.write(chunk);
});
// Fail fast if the server crashes during startup.
child.once("exit", (code, signal) => {
for (const resolve of pending.values()) {
resolve({ error: { code: -32099, message: `server exited (code=${code}, signal=${signal}) before responding` } });
}
pending.clear();
});
function send<T = unknown>(method: string, params?: unknown): Promise<JsonRpcResponse<T>> {
const id = nextId++;
return new Promise<JsonRpcResponse<T>>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Timeout waiting for response to ${method}`));
}, 10_000);
pending.set(id, (value) => {
clearTimeout(timer);
resolve(value as JsonRpcResponse<T>);
});
child.stdin.write(
JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
);
});
}
function notify(method: string, params?: unknown) {
child.stdin.write(
JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n",
);
}
// The protocol IS the ready signal. If the server cannot complete the
// handshake, the integration suite has nothing useful to test anyway.
const init = await send<McpClient["initResult"]>("initialize", {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: "yaw-test-harness", version: "0.0.0" },
});
if ("error" in init) {
child.kill("SIGTERM");
await once(child, "exit");
throw new Error(
`Server returned an error to initialize: ${init.error.message}`,
);
}
notify("notifications/initialized");
return {
send,
notify,
initResult: init.result,
async close() {
child.kill("SIGTERM");
await once(child, "exit");
},
};
}
The discriminated return type forces test code to handle both success and error cases explicitly. Without it, a regression that turns a result into an error would crash with Cannot read properties of undefined on the field access rather than fail cleanly on the assertion -- so use unwrap(...) when you expect success, and if ("error" in response) when you're asserting on a specific error code.
The ready signal is the initialize response, not a stderr substring. Earlier drafts of this harness watched stderr for the SDK's "running" log line, and that worked until the day a coworker tweaked the server's logger and the integration suite hung indefinitely in beforeAll. The protocol handshake is the contract; if it does not complete, there is nothing to test. Let it be the gate.
And a test using it:
// tests/integration/tools-list.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { startServer, unwrap, type McpClient } from "./harness.js";
describe("tools/list integration", () => {
let client: McpClient;
beforeAll(async () => {
client = await startServer({ LEMONSQUEEZY_API_KEY: "test_key" });
});
afterAll(async () => {
await client.close();
});
it("returns the expected tool surface after initialize", async () => {
// The harness already completed the initialize handshake.
expect(client.initResult.capabilities.tools).toBeDefined();
// unwrap() throws on `{ error }` with the server's message, so a
// regression that flips the response shape fails here -- not on a
// confusing TypeError three lines down.
const list = unwrap(
await client.send<{ tools: Array<{ name: string }> }>("tools/list"),
);
const names = list.tools.map((t) => t.name).sort();
expect(names).toEqual([
"ls_create_price",
"ls_get_price",
"ls_list_prices",
"ls_update_price",
]);
});
});
A handful of things this catches that unit tests cannot:
bin field.)tools block must be present and non-null.tools/list. If you forgot to wire one up in the dispatcher, the unit test for the handler still passes, but the integration test catches it.^[A-Za-z0-9_-]+$-shaped, no spaces, no slashes). The SDK rejects out-of-spec names today, but that has not always been the case across SDK versions, and even when registration succeeds, clients sometimes parse names through their own regex before dispatching. The integration test catches both the SDK regression and the client-side mismatch in one assertion: pull the names out of tools/list and validate them against your own copy of the regex.undefined, it'll quietly drop -- the unit test sees the in-memory object; the integration test sees what the client sees.Integration tests are slower than unit tests (a few seconds per file), so I run them less aggressively in dev. In CI, they run on every PR, in the same job as unit tests but after them.
Callout: don't share servers across tests unless you have to. Each
describeblock above starts its own server inbeforeAlland tears it down inafterAll. Test isolation matters; a stateful tool that mutates a fixture between tests is a debugging nightmare otherwise. The startup cost is real (~500ms) but acceptable for a suite of 20-30 integration tests.
Layers 1 and 2 verify your server does what you intended. Layer 3 verifies your server does what the spec demands -- which is not always the same thing, especially in places where the SDK gives you enough rope to ship something off-spec.
There are two tools I lean on here.
The official MCP Inspector (@modelcontextprotocol/inspector) is the reference UI and CLI for poking at a running server. The CLI mode is scriptable:
npx @modelcontextprotocol/inspector --cli node dist/index.js \
--method tools/list
I've wired this into the pretest script of a couple of repos as a sanity check -- if the inspector can't list tools, something is fundamentally broken. It's not a comprehensive conformance test; it's a "does this thing speak the protocol at all" smoke test.
For real conformance I use the Yaw MCP 88-test compliance suite. (Disclaimer: I run Yaw MCP, so I'm both biased and uniquely positioned to know what it covers.) The suite and its A-to-F grading scale are written up in Grading MCP Servers A to F: 88 Tests Against the Spec, and if you want to run it yourself, the one-command compliance CLI wraps the same suite as a single npx invocation. The suite drives a server through every method in the spec, with every documented capability combination, and asserts on:
tools/* methods, including pagination cursors and the _meta field.resources/* methods, including subscribe/unsubscribe lifecycle.tools you must respond to tools/*; if you don't, you must return -32601 Method not found).The suite runs against any MCP server that speaks stdio or Streamable HTTP. I run it against every Yaw Labs server before tagging a release; in CI it's a separate job that fires on tag push, not on every PR, because it's slow (a couple of minutes per server) and the failure mode is "you violated the spec," which is rare enough to not need per-PR coverage but bad enough that you really want to catch it before shipping.
If you don't want to depend on a third-party suite -- understandable -- the alternative is to read the spec carefully and write the conformance tests yourself. A few that have caught real bugs in my own code:
it("returns -32601 for an unknown method", async () => {
// Negative tests discriminate explicitly so an accidental `result`
// (the server happily handled the bogus method) fails loudly here.
const r = await client.send("tools/does_not_exist");
if (!("error" in r)) throw new Error("expected an error response");
expect(r.error.code).toBe(-32601);
});
it("returns -32602 for missing required parameters", async () => {
const r = await client.send("tools/call", { name: "ls_get_price" });
if (!("error" in r)) throw new Error("expected an error response");
// -32602 = Invalid params (no `arguments` field at all is malformed).
expect(r.error.code).toBe(-32602);
});
it("does not advertise tools capability when no tools are registered", async () => {
// Run a different binary configured with zero tools.
const empty = await startServer({ DISABLE_ALL_TOOLS: "1" });
expect(empty.initResult.capabilities.tools).toBeUndefined();
await empty.close();
});
The third test is one I've shipped a regression on twice. The SDK has a habit of advertising every capability you've imported, even if you've registered zero handlers for it. Some clients then call tools/list and get back an empty array, which is fine -- but other clients get confused and start retrying. Test the negative case explicitly.
Callout: protocol versions move. The MCP spec is dated; the examples in this chapter pin the
2025-06-18revision that the rest of the book references, but the value will move and your tests must move with it. The version in your initialize handshake must match what the client sent (or you must respond with the version you support). Pin the version in your conformance tests; do not pull it from a constant in the SDK, because that constant changes when you bump the SDK and you want the test to catch the change.
This is the layer where MCP testing diverges hardest from API testing. Your consumer is not a deterministic client calling your tools in a fixed order; it's an LLM deciding moment-to-moment whether your tool is the right one for the task it's been given. You can have 100% coverage at layers 1-3 and still ship a server the model refuses to use.
I've found two flavors of end-to-end test useful, and one that isn't.
prompts.md harnessThe cheap version. I keep a markdown file in each repo at tests/e2e/prompts.md with a list of prompts I expect the server to handle, organized by tool. Example fragment:
## ls_list_prices
- "Show me all the prices for variant v_123 on Lemon Squeezy."
- "What plans does my product on LS have?"
- "List the LS prices."
## ls_create_price
- "Create a $25/month price for variant v_123."
- "Add a one-time $99 price to v_456."
Before each release I paste these into Claude Code (with the server installed) and watch. For each prompt:
This is fundamentally a manual test, but it's a checklisted manual test, which is enough structure to catch regressions you'd otherwise miss. The full pass for a 20-tool server takes ~15 minutes. I do it before every release.
The "real" version. The Anthropic SDK supports tool use directly, so you can drive your MCP server through Claude with no human in the loop:
// tests/e2e/scripted.test.ts
import Anthropic from "@anthropic-ai/sdk";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { startServer, unwrap, type McpClient } from "../integration/harness.js";
const client = new Anthropic();
async function listToolsAsAnthropicSchema(mcp: McpClient) {
const list = unwrap(
await mcp.send<{
tools: Array<{
name: string;
description: string;
inputSchema: object;
}>;
}>("tools/list"),
);
return list.tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema,
}));
}
describe("e2e: tool selection", () => {
let mcp: McpClient;
let tools: Awaited<ReturnType<typeof listToolsAsAnthropicSchema>>;
beforeAll(async () => {
mcp = await startServer({ LEMONSQUEEZY_API_KEY: process.env.LS_API_KEY! });
tools = await listToolsAsAnthropicSchema(mcp);
});
afterAll(async () => {
await mcp.close();
});
it("picks ls_list_prices for a list query", async () => {
const r = await client.messages.create({
model: "claude-opus-4-7",
max_tokens: 1024,
tools,
messages: [{ role: "user", content: "List the prices for v_123 on LS." }],
});
const toolUse = r.content.find((c) => c.type === "tool_use");
expect(toolUse?.name).toBe("ls_list_prices");
expect((toolUse?.input as { variantId: string }).variantId).toBe("v_123");
});
});
This is slow (a few seconds per prompt at minimum, more if you let Claude execute the tool and see the response) and costs real money in API charges. I don't run it on every PR. I run it as a separate "evals" suite on tag push and on a nightly cron. More on the eval pattern in the next section.
I tried, briefly, to test the LLM's output text -- "did Claude's reply mention the customer's name?" -- and abandoned it. LLM output is non-deterministic in its phrasing, and asserting on phrasing produces a flaky test that you eventually start ignoring. Assert on tool selection and tool arguments; let the prose float.
A proper eval suite is the difference between "I think my tool descriptions are good" and "I know they are, and I have a number." For a server that's actively consumed by an LLM, the eval suite is the canonical correctness measure -- more so than unit tests, because the unit tests verify what your code does, while the eval verifies whether the model can use what your code does.
The pattern, distilled:
For @yawlabs/lemonsqueezy-mcp the eval suite is 200 prompts. The format is JSONL:
{"prompt": "Show me all prices for variant v_123", "expected_tool": "ls_list_prices", "expected_args": {"variantId": "v_123"}}
{"prompt": "What does v_123 cost?", "expected_tool": "ls_list_prices", "expected_args": {"variantId": "v_123"}}
{"prompt": "Add a $25 price to v_123", "expected_tool": "ls_create_price"}
The runner is ~80 lines, batches concurrent calls to keep the wall-clock under a minute, and emits a summary like:
Eval results (200 prompts):
Correct tool selection: 187/200 (93.5%)
Correct args (when tool right): 174/187 (93.0%)
Wrong tool: 13
- "What's the cheapest plan?" -> expected ls_list_prices, got <none> (8x)
- "Update price for v_456" -> expected ls_update_price, got ls_create_price (5x)
The actionable output is the failing prompts. "What's the cheapest plan?" failing eight times tells me my ls_list_prices description doesn't make it clear that it returns price comparison data. I update the description, re-run the eval, see the number move from 93.5% to 95.0%, and have empirical confidence the change was an improvement.
This is the loop that would have caught the regression I opened the chapter with. A description change that drops eval performance by 5 points is impossible to miss; a description change that's "vibes-better" is impossible to verify any other way.
A few practical notes on running evals:
Don't put them on the per-PR critical path. Evals cost real money and take real minutes. Run them on tag push, on a nightly cron, and on-demand when someone asks "did my description change help?" The hard gate stays at unit + integration + conformance.
Expect drift between models. Evals run against claude-opus-4-7 will produce different numbers than evals against claude-sonnet-4-6. That's information -- and not just across tiers. The same eval suite run against the same model six months apart can drift too, as the model series ships point releases. Track which model and which date the eval ran against alongside the score; "94% on opus-4-7, run 2026-04-29" is a comparable artifact, "94%" is not.
Seed the prompt set from real usage if you can. Production logs (anonymized) are gold here. If your server is hosted, the prompts that actually get sent are far more representative than the ones you'd dream up at your desk. If you're shipping unhosted, watch yourself use the server for a week and write down the prompts you typed.
Don't chase 100%. Past about 95% you're tuning for the noise floor of the model itself. Some prompts are genuinely ambiguous; some are tests you wrote on a Friday evening that you wouldn't write the same way today. 95% is a good ship gate; 90% is a "don't ship the latest description change" gate.
Callout: evals as a regression suite. The framing that helped me click was "evals are tests for the model's behavior, not your code's." You wouldn't ship a code change without running the unit tests; treat description changes the same way.
What this looks like in practice for a Yaw Labs MCP server:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push:
branches: [main]
tags: [v*]
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npm test # unit + integration
conformance:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
needs: [lint-test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build
- run: npx @modelcontextprotocol/inspector --cli node dist/index.js --method tools/list
- run: npm run test:conformance # the conformance suite of your choice
evals:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
needs: [lint-test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build
- run: npm run test:evals
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
The shape:
The matrix dimension I haven't used but would consider for a server with multiple transports: run the integration suite against each transport (stdio, Streamable HTTP) and assert that the same tool calls produce identical results. Most Yaw Labs servers are stdio-only, so this hasn't come up in practice.
A non-trivial chunk of the testing literature for backend services is "you must test X." For MCP servers specifically, there are a few categories where the answer is "no, please don't, you'll waste your time."
The SDK itself. @modelcontextprotocol/sdk has its own test suite. If setRequestHandler is broken, the SDK's CI catches it, not yours. Don't write tests that pin SDK behavior; write tests that pin your behavior.
JSON-RPC framing. The transport layer handles message framing, content-length headers (or newline delimiters in stdio), and parsing. Your tests should send messages and expect responses. They should not assert on the byte layout.
The protocol spec. If you find yourself writing expect(initResponse.protocolVersion).toBe("2025-06-18") in twenty test files, stop. Pin it once in a conformance test; the rest of your tests should not care.
LLM output prose. As discussed above. Test tool selection and tool arguments; let the prose float.
Upstream API behavior. If Lemon Squeezy returns a 500, your server returns an error. That's your contract. Whether Lemon Squeezy correctly returns a 500 in any given scenario is not your test to write.
The unifying principle: test the seams you own, test the contracts you make, do not test the contracts other systems make to you.
The classic test pyramid is wide at the unit base, narrow at the integration middle, narrower at the e2e top. For most backend services that's right; unit tests are cheap, integration tests are expensive, e2e tests are flaky.
For MCP servers, my pyramid is closer to a hexagon: unit and integration are roughly equal in count, conformance is a thin slice, and e2e/evals are a separate parallel column.
The reason is that handler logic is usually thin. A handler validates inputs (one zod schema), calls one upstream method, shapes the response, and returns. Maybe 20 lines. The interesting logic lives at the seams: argument parsing, content shaping, error mapping, transport handshake, capability negotiation. Those are integration territory.
A typical Yaw Labs server has about:
The unit:integration ratio is closer to 1.5:1 than the textbook 5:1 or 10:1. If your ratio looks more like the textbook, your integration tests are probably underweight, and your handler logic is probably doing too much (which is its own problem -- thin handlers are a virtue).
Two patterns serve me well: fixtures for upstream responses and golden files for content blocks.
When you mock the upstream API in unit tests, you need realistic payloads. I keep these as JSON files under tests/fixtures/upstream/:
tests/fixtures/upstream/
lemonsqueezy/
list-prices-success.json
list-prices-empty.json
get-price-404.json
create-price-422-validation.json
And load them in the test:
import fixture from "../fixtures/upstream/lemonsqueezy/list-prices-success.json";
vi.mocked(upstream.listPrices).mockResolvedValue(fixture);
The discipline: capture each fixture by hitting the real API with curl and saving the output verbatim, once. Don't hand-write fixtures; the JSON:API envelopes that real APIs return have subtleties you will get wrong. Re-capture when the upstream API changes.
When a tool returns formatted text (markdown, a table, a structured summary), the assertion expect.stringContaining(...) only goes so far. For tools where the entire content block matters, I use golden files:
import { readFileSync } from "node:fs";
it("formats list-prices output correctly", async () => {
vi.mocked(upstream.listPrices).mockResolvedValue(fixture);
const result = await handleListPrices({ variantId: "v_123" });
const expected = readFileSync(
"tests/fixtures/golden/list-prices.txt",
"utf8",
);
expect(result.content[0].text).toBe(expected);
});
The trick is updating golden files when behavior intentionally changes. I run the test suite with an env var that rewrites the golden files instead of asserting:
const expected = process.env.UPDATE_GOLDEN
? (writeFileSync(goldenPath, actual), actual)
: readFileSync(goldenPath, "utf8");
expect(actual).toBe(expected);
Then in the change PR: UPDATE_GOLDEN=1 npm test, review the diff, commit. The diff IS the test result; reviewing it is the test.
I don't golden-file every tool. Use this pattern for tools where the output formatting is non-trivial -- summary tables, multi-line markdown, anything where wording changes are meaningful. For tools that return Created price abc or Found 5 items, golden files are overkill.
Let me put it all together with the actual layout from one of my repos. This is @yawlabs/lemonsqueezy-mcp as of v0.4.0:
lemonsqueezy-mcp/
src/
index.ts # entry point, server bootstrap
server.ts # tool registration, dispatch
handlers/
list-prices.ts
get-price.ts
create-price.ts
update-price.ts
...
upstream/
lemonsqueezy.ts # thin wrapper over LS REST API
errors.ts
schemas/
tools.ts # zod schemas for every tool input
tests/
handlers/ # Layer 1: unit tests, one per handler
list-prices.test.ts
get-price.test.ts
...
integration/ # Layer 2: spawn the binary
harness.ts
tools-list.test.ts
tools-call.test.ts
error-codes.test.ts
conformance/ # Layer 3: spec compliance
protocol-version.test.ts
capabilities.test.ts
method-not-found.test.ts
e2e/ # Layer 4: real client
prompts.md # manual checklist
eval-prompts.jsonl # 200-prompt scripted eval
eval.test.ts # runs the eval against Claude
fixtures/
upstream/ # captured upstream responses
golden/ # golden-file content blocks
vitest.config.ts
package.json
vitest.config.ts (unit only -- the watch-mode default):
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/handlers/**/*.test.ts"],
exclude: ["tests/integration/**", "tests/conformance/**", "tests/e2e/**"],
testTimeout: 5_000,
hookTimeout: 5_000,
},
});
vitest.integration.config.ts (unit + integration -- the CI default):
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/handlers/**/*.test.ts", "tests/integration/**/*.test.ts"],
exclude: ["tests/conformance/**", "tests/e2e/**"],
testTimeout: 10_000,
hookTimeout: 10_000,
},
});
The default npm test -- the one CI runs and the one you run before pushing -- exercises layers 1 and 2 (about 220 tests, ~8 seconds). The watch-mode default exercises only layer 1, so you get sub-second feedback while editing handlers without paying the spawn-a-subprocess tax on every save. Conformance and evals are explicit:
// package.json
{
"scripts": {
"test": "vitest run --config vitest.integration.config.ts",
"test:watch": "vitest",
"test:unit": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:conformance": "vitest run --config vitest.conformance.config.ts",
"test:evals": "vitest run --config vitest.evals.config.ts"
}
}
The split configs prevent a developer from accidentally running the eval suite (which costs API credits) on every save, keep the integration tests out of the inner-loop watcher (so a saved file does not respawn three subprocesses per affected suite), and let CI run each layer as a separate job.
The CI matrix is what I laid out earlier: lint-test on every PR, conformance and evals on tag push. A typical PR has 220 tests pass in 8 seconds; a typical release tag has another 88 conformance tests pass in 90 seconds and a 200-prompt eval that finishes in about a minute and reports a score.
A regression like the one in the chapter opener -- description change, model stops calling the tool, three days to detect -- is preventable with this stack. The eval suite is the layer that catches it. The unit tests don't help (the handler still works); the integration tests don't help (the wire response is still correct); the conformance tests don't help (the spec is still respected). The eval is the only test that asks the question that matters: given my current tool surface, can the model still pick the right tool for a representative prompt?
The investment is real. A 200-prompt eval suite takes a couple of days to author the first time, and re-running it every release costs maybe $0.50 in API credits. But it's the difference between "I shipped a regression and a customer noticed" and "the eval flagged it before merge."
If you take one thing from this chapter, take this: for an MCP server, the eval suite is the hardest test to skip and the easiest test to defer. Author it early. Treat it like a unit test for your descriptions. The day you change a tool name without checking the eval is the day you ship the next three-day regression.
For everything else -- the unit tests, the integration tests, the conformance tests -- the recipes in this chapter are the floor, not the ceiling. Start there. The first server you ship with this layered setup will be the first server you ship that you can actually iterate on with confidence. That's the whole game.