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:

  1. Inspector CLI tools/list in CI — one line, zero maintenance, catches "it doesn't boot" and "the tool is gone."
  2. A tools/list snapshot test — five lines, catches every schema and inventory change.
  3. callTool assertions 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.