If you read one line: an MCP server has only two things a client trusts — the tools it advertises via tools/list and the results it returns from tools/call — and both break silently in a refactor. Test them in three layers: the Inspector (npx @modelcontextprotocol/inspector node build/index.js) while you build, the Inspector CLI (--cli --method tools/list) as an exit-code check in CI, and a programmatic SDK client in your test runner to assert on output. The one test that earns its keep is a tools/list snapshot.
Here's the trap. Your MCP server is a thin wrapper around functions you already unit-test. So you feel covered. But your unit tests call getWeather(city) directly — they never go through the MCP protocol. The MCP layer is the part a model actually sees: the tool name, its description, its input schema, and the shape of the result. Rename a parameter, drop a tool in a cleanup pass, make a required field optional — every one of those passes your unit tests and breaks every client. Nothing you have exercises the contract. That's the gap these three layers close.
Layer 1 — Inspector, while you build#
The MCP Inspector is the official tester from the Model Context Protocol team. It runs from one binary, no install:
# Web UI (default) — opens a browser tab
npx @modelcontextprotocol/inspector node build/index.js
# Terminal UI, if you'd rather stay in the shell
npx @modelcontextprotocol/inspector --tui node build/index.js
It performs the real initialize handshake, then shows you every tool with its JSON schema and a generated form. You fill in arguments, click Run, and see the raw request and response. This is the fastest loop for the question "why is this call erroring" — you're looking at exactly what the model would send and get back, not at your function's return value.
It connects over stdio, SSE, or streamable HTTP, so a deployed server is the same drill — point it at the URL instead of the binary:
npx @modelcontextprotocol/inspector https://your-server.example.com/mcp
Use this layer to explore. It doesn't leave anything behind that guards the next commit — that's the next two layers.
Layer 2 — Inspector CLI, as a CI gate#
The same tool has a --cli mode that is headless and exit-code driven, which is the only property that matters for automation: a non-zero exit is a failed CI step.
# Assert the server starts and advertises its tools
npx @modelcontextprotocol/inspector --cli node build/index.js \
--method tools/list
# Assert a specific call succeeds with real arguments
npx @modelcontextprotocol/inspector --cli node build/index.js \
--method tools/call \
--tool-name get_weather \
--tool-arg city=Toronto
Drop those two lines into a CI job or a pre-push hook and you've caught the three most common regressions — the server that no longer boots, the tool that vanished, the call that started throwing — before they reach a user. No browser, no test framework, no assertions to maintain. It's the cheapest guard you can add, and you should add it first.
Layer 3 — Programmatic client, for real assertions#
The CLI tells you a call succeeded. It doesn't tell you the result was right. For that, drive the server from your own test runner with the MCP SDK's client — you get a real MCP session and normal asserts on the output:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { test } from "node:test";
import assert from "node:assert/strict";
async function withServer(fn) {
const transport = new StdioClientTransport({
command: "node",
args: ["build/index.js"],
});
const client = new Client({ name: "test", version: "0.0.0" });
await client.connect(transport);
try { await fn(client); } finally { await client.close(); }
}
test("tools/list is the contract we promised", async () => {
await withServer(async (client) => {
const { tools } = await client.listTools();
const names = tools.map((t) => t.name).sort();
// The snapshot: this line fails the build if a tool is added,
// removed, or renamed — the regressions unit tests miss.
assert.deepEqual(names, ["get_forecast", "get_weather"]);
const weather = tools.find((t) => t.name === "get_weather");
assert.equal(weather.inputSchema.required?.includes("city"), true);
});
});
test("get_weather returns structured content", async () => {
await withServer(async (client) => {
const res = await client.callTool({
name: "get_weather",
arguments: { city: "Toronto" },
});
assert.equal(res.isError, undefined);
assert.match(res.content[0].text, /Toronto/);
});
});
That first test is the one to write before any other. A tools/list snapshot asserting the exact set of names and their input schemas converts every silent contract change — the renamed arg, the deleted tool, the newly-optional required field — into a red build. It's five lines and it catches the failure mode your existing suite is structurally blind to.
The order to add these#
You don't need all three on day one. Add them in cost order:
- Inspector CLI
tools/listin CI — one line, zero maintenance, catches "it doesn't boot" and "the tool is gone." - A
tools/listsnapshot test — five lines, catches every schema and inventory change. callToolassertions on the tools that carry real logic — as many as the tool's blast radius justifies; skip the trivial ones.
The MCP lifecycle means every one of these does the full initialize handshake first, so if your server misnegotiates capabilities or advertises a protocol version a client won't accept, all three fail loudly — which is exactly what you want. And once the server holds under test, the next thing to get right is what happens when a tool call itself runs long or needs to be killed — give every tool call a deadline and cancel it cleanly, because a server that passes tools/list can still hang a client forever.



