teachyou.ai academy
← All posts
MCPmodel context protocoltestingdeveloper toolsAI agents

How to Test MCP Servers: Unit, Integration, and Inspector

Pramod Dutta · Jun 25, 2026 · 15 min read

Testing MCP servers is different from testing a REST API because the contract isn't just request/response shapes, it's a stateful JSON-RPC session with capability negotiation, streaming notifications, and a schema that an LLM has to interpret correctly. If you only click around in Claude Desktop and call it done, you will ship servers that break the moment a different client connects, a tool argument arrives malformed, or a resource read races with a list call. This guide walks through the three layers that actually catch these bugs: unit tests around your tool handlers, manual and scripted exploration with the MCP Inspector, and integration tests that drive your server through a real client session.

Why testing MCP servers is harder than testing a normal API

An MCP server exposes tools, resources, and prompts over JSON-RPC 2.0, typically transported over stdio or Streamable HTTP. Three things make this harder to test than a typical CRUD backend:

  • The client negotiates capabilities at initialize time. If your server advertises a capability it doesn't actually implement (or forgets to advertise one it does), the failure mode is a silent no-op in the client UI, not an HTTP 500.
  • Tool inputs are validated against a JSON Schema you generate from your code (via Zod, Pydantic, or similar), but the LLM is the one filling in those inputs at call time. A schema that's technically valid JSON Schema can still be ambiguous enough that the model consistently gets it wrong. That's a testing concern, not just a schema concern.
  • Long-running tools can emit progress notifications, and resources can change and push notifications/resources/updated events. A test that only checks the final response misses an entire class of bugs in the notification stream.

Because of this, a testing strategy for MCP servers needs to operate at three altitudes: the pure logic inside a tool handler (unit level), the wire protocol as an interactive human or script would exercise it (Inspector level), and the full session lifecycle including initialization, multiple round trips, and teardown (integration level).

Unit testing MCP tool handlers

The first rule of testing MCP servers: don't test the protocol layer when you mean to test business logic. If a tool handler queries a database and formats a table, the JSON-RPC framing around it is not what's likely to break. Extract the handler logic into a plain function and unit test that function directly, independent of the SDK's server object.

Here's the pattern using the TypeScript SDK (@modelcontextprotocol/sdk). Instead of writing your logic inline in server.registerTool(), keep it in a separate module:

// tools/searchDocs.ts
import { z } from "zod";

export const searchDocsSchema = z.object({
  query: z.string().min(1),
  limit: z.number().int().min(1).max(50).default(10),
});

export type SearchDocsInput = z.infer<typeof searchDocsSchema>;

export async function searchDocs(
  input: SearchDocsInput,
  deps: { db: DocStore }
) {
  const parsed = searchDocsSchema.parse(input);
  const results = await deps.db.search(parsed.query, parsed.limit);
  if (results.length === 0) {
    return {
      content: [{ type: "text" as const, text: "No documents matched." }],
    };
  }
  return {
    content: [
      {
        type: "text" as const,
        text: results.map((r) => `${r.title}: ${r.snippet}`).join("\n"),
      },
    ],
  };
}

Now register it in the server as a thin wrapper:

// server.ts
import { searchDocs, searchDocsSchema } from "./tools/searchDocs.js";

server.registerTool(
  "search_docs",
  {
    title: "Search documentation",
    description: "Search the indexed docs for a query string.",
    inputSchema: searchDocsSchema.shape,
  },
  (input) => searchDocs(input, { db: docStore })
);

The unit test never touches the MCP server object at all:

// tools/searchDocs.test.ts
import { describe, it, expect, vi } from "vitest";
import { searchDocs } from "./searchDocs.js";

describe("searchDocs", () => {
  it("returns formatted results when matches exist", async () => {
    const fakeDb = {
      search: vi.fn().mockResolvedValue([
        { title: "Auth Guide", snippet: "OAuth setup steps" },
      ]),
    };
    const result = await searchDocs({ query: "auth", limit: 10 }, { db: fakeDb });
    expect(result.content[0].text).toContain("Auth Guide");
  });

  it("returns a no-match message on empty results", async () => {
    const fakeDb = { search: vi.fn().mockResolvedValue([]) };
    const result = await searchDocs({ query: "zzz", limit: 10 }, { db: fakeDb });
    expect(result.content[0].text).toBe("No documents matched.");
  });

  it("rejects an empty query at the schema level", async () => {
    const fakeDb = { search: vi.fn() };
    await expect(
      searchDocs({ query: "", limit: 10 }, { db: fakeDb })
    ).rejects.toThrow();
  });
});

This is a fast, dependency-free test suite that runs in milliseconds and doesn't spin up a process or open a transport. The same pattern applies in Python with the official mcp SDK: keep your @server.call_tool() handlers as thin dispatchers to plain functions, and unit test the plain functions with pytest.

A few things worth asserting explicitly in unit tests for MCP tools:

  • Schema boundary cases. Test the min/max/enum edges of your Zod or Pydantic schema, not just the happy path. If limit has a max of 50, test 51 gets rejected and 50 is accepted.
  • Error shape, not just error occurrence. MCP distinguishes protocol errors (malformed JSON-RPC) from tool execution errors (the tool ran but failed, returned via isError: true in the result). Make sure your handler returns the second kind for expected failures like "file not found" rather than throwing an unhandled exception that becomes a generic protocol error.
  • Idempotency for tools that mutate state. If a tool creates a resource, call it twice in a test and check whether that's intentional (duplicate creation) or a bug.

Testing with the MCP Inspector

Once the unit layer is green, you need to verify the actual JSON-RPC surface: does tools/list return the schema you expect, does initialize negotiate correctly, does a real tool call round-trip through stdio without hanging. This is where the MCP Inspector earns its keep.

The Inspector is a standalone tool you run against your server without touching Claude Desktop or any other client:

npx @modelcontextprotocol/inspector node build/server.js

For a Python server run with uv:

npx @modelcontextprotocol/inspector uv run python server.py

This opens a local web UI (it prints the URL, typically on port 6274) split into two halves: a connection pane showing the raw JSON-RPC transcript, and an interaction pane where you can browse tools, resources, and prompts and invoke them with a form.

What to actually check with the Inspector, beyond "does it look right":

  • Open the tools list and read every description as if you were the model. If a tool's description or its parameter descriptions are ambiguous, that's a bug you'll never catch with a unit test, because a unit test calls the function with the right arguments by construction. The Inspector is where you catch "the LLM would never know it needs to pass an ISO date string here."
  • Call each tool with intentionally bad input (missing required field, wrong type, an out-of-range number) and confirm you get a clean tool error back, not a stack trace leaking into the response text.
  • Check the resources tab for pagination. If resources/list returns a nextCursor, verify the Inspector's "load more" actually pulls a distinct page and that cursors aren't reused stale.
  • Watch the raw transcript pane during a `initialize` handshake. Confirm your server's advertised capabilities object matches what you actually implemented. A server that claims resources: { subscribe: true } but never sends notifications/resources/updated will pass every functional test and still be broken for any client that relies on subscriptions.
  • Test cancellation. If a tool is slow, start the call and cancel it from the Inspector, then confirm your server handles the notifications/cancelled message instead of continuing to run the operation in the background and eventually erroring on a dead connection.

The Inspector also supports a CLI mode for scripting specific calls without the UI, which is useful for a quick sanity check in a terminal:

npx @modelcontextprotocol/inspector --cli node build/server.js \
  --method tools/call \
  --tool-name search_docs \
  --tool-arg query=auth \
  --tool-arg limit=5

Treat the Inspector as your exploratory testing tool, the equivalent of manually poking a REST API with curl before you write the automated suite. It is not itself a regression-test runner, it doesn't fail a CI build. Its job is catching the class of bug that only shows up when you look at the actual wire traffic and think about the model's perspective, not the server author's perspective.

Integration testing with a real client

Unit tests validate logic, the Inspector validates the protocol surface by eye. The third layer is scripted integration tests that drive your server through a real client SDK, covering the full session lifecycle: connect, initialize, list tools, call a tool, read a resource, disconnect. This is what should run in CI.

With the TypeScript SDK, spin up an in-memory client and server pair connected via the SDK's InMemoryTransport, or connect to a subprocess via StdioClientTransport for a closer-to-production test:

// integration/server.integration.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

describe("MCP server integration", () => {
  let client: Client;

  beforeAll(async () => {
    const transport = new StdioClientTransport({
      command: "node",
      args: ["build/server.js"],
    });
    client = new Client({ name: "test-client", version: "1.0.0" });
    await client.connect(transport);
  });

  afterAll(async () => {
    await client.close();
  });

  it("lists the expected tools", async () => {
    const { tools } = await client.listTools();
    const names = tools.map((t) => t.name);
    expect(names).toContain("search_docs");
  });

  it("calls search_docs end to end", async () => {
    const result = await client.callTool({
      name: "search_docs",
      arguments: { query: "auth", limit: 5 },
    });
    expect(result.isError).not.toBe(true);
    expect(result.content[0].text).toBeDefined();
  });

  it("returns a tool error for invalid arguments", async () => {
    const result = await client.callTool({
      name: "search_docs",
      arguments: { query: "" },
    });
    expect(result.isError).toBe(true);
  });

  it("reads a resource by URI", async () => {
    const { resources } = await client.listResources();
    expect(resources.length).toBeGreaterThan(0);
    const { contents } = await client.readResource({ uri: resources[0].uri });
    expect(contents[0].text).toBeDefined();
  });
});

This test suite is the closest thing to "does this actually work when a client uses it" without paying for a live LLM call. It runs your real server binary, over the real stdio transport, through the real client SDK's request/response matching and JSON-RPC ID handling. Bugs that only show up under this layer include: your server hanging because it wrote a log line to stdout instead of stderr (stdio transport requires stdout be reserved for protocol frames), a tool handler that never resolves its promise, or a resource URI scheme your server advertises in resources/list but doesn't actually handle in resources/read.

If your server uses the Streamable HTTP transport instead of stdio, the same pattern applies with StreamableHTTPClientTransport pointed at a locally running instance of your server, typically started in a beforeAll with a spawned child process and torn down in afterAll.

For Python servers, the equivalent uses mcp.client.stdio.stdio_client inside a pytest-asyncio test:

import pytest
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

@pytest.mark.asyncio
async def test_search_docs_tool():
    params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            assert any(t.name == "search_docs" for t in tools.tools)

            result = await session.call_tool(
                "search_docs", arguments={"query": "auth", "limit": 5}
            )
            assert result.isError is not True

Run this suite the same way you'd run any pytest module, and wire it into CI alongside your unit tests. Because it spawns a real subprocess, keep it in a separate CI job or test file from your fast unit suite so a hung stdio connection doesn't stall your whole pipeline; set an explicit timeout on the client connection step.

Testing resources and prompts, not just tools

Most MCP testing advice focuses on tools because that's where the interesting logic lives, but resources and prompts have their own failure modes worth covering:

  • Resource templates. If you expose a templated resource like docs://{category}/{slug}, write a test that calls resources/list and confirms the template URI is well-formed, then a separate test that reads a concrete instantiated URI and confirms it resolves to real content, not a 404 wrapped in a 200.
  • Resource subscriptions. If you support resources/subscribe, write an integration test that subscribes, triggers a change in the underlying data (write to the file, update the row), and asserts a notifications/resources/updated message arrives within a reasonable timeout. This is the one that gets skipped most often, and it's the one most likely to be silently broken.
  • Prompts with arguments. If you expose prompts/get with templated arguments, test that missing required arguments produce a proper JSON-RPC error rather than a template rendered with the literal string "undefined" spliced into it.

Testing error handling and edge cases

A short checklist worth turning into actual test cases, not just code review comments:

  • Call a tool that doesn't exist and confirm you get a proper -32602 (invalid params) or method-not-found style error, not a hang or a crash.
  • Send a tool call with extra, unexpected fields in the arguments object and confirm your schema either strips them (if using .passthrough() deliberately) or rejects them (the more common and safer default).
  • Simulate a downstream dependency failure (database timeout, upstream API 500) and confirm the tool returns isError: true with a human-readable message, rather than letting the exception propagate up and kill the transport.
  • If a tool accepts a file path or URI as an argument, test path traversal input like ../../etc/passwd and confirm your server rejects it rather than reading outside its intended sandbox. This is a real, common vulnerability class in early MCP servers that expose filesystem-adjacent tools.
  • Test what happens when two tool calls arrive concurrently on the same session, particularly if either one mutates shared state. The SDK does not serialize tool calls for you.

Wiring it into CI

A reasonable layout for a TypeScript MCP server repo:

tools/
  searchDocs.ts
  searchDocs.test.ts        (unit, fast, no subprocess)
integration/
  server.integration.test.ts (spawns the real server binary)

In your CI config, run the unit suite on every push since it's fast, and run the integration suite as a required check before merge, with a generous but bounded timeout (10 to 30 seconds is usually enough for stdio startup and a handful of round trips). Skip running the Inspector in CI: it's an interactive tool meant for a human, and its CLI mode is better suited to a pre-release manual smoke check than an automated gate.

If you publish your server as an npm package or PyPI package, add one more test that isn't code at all: install the published package fresh in a scratch directory and run it through the Inspector once before tagging a release. Bundling mistakes (a missing bin entry, a dependency accidentally left as devDependencies) are invisible to every test above because they all run against your local build, not the packaged artifact.

FAQ

Do I need all three layers, or can I skip straight to integration tests? You can technically skip unit tests and only run integration tests, but you'll pay for it in speed and in how precisely a failure points you at the bug. A unit test failure tells you exactly which function misbehaved; an integration test failure tells you "the server produced the wrong output somewhere," and you're back to manual debugging with the Inspector. Keep the unit layer for anything with real branching logic and use integration tests to cover the protocol wiring around it.

Can I test an MCP server without the official SDKs? Yes. Since the protocol is just JSON-RPC 2.0 over stdio or HTTP, you can write raw request/response tests with any HTTP client or by piping JSON lines to a subprocess's stdin and reading stdout. This is more brittle to maintain than using the client SDK, since you're hand-rolling ID matching and framing, but it's useful for a minimal smoke test in a language that doesn't have an official SDK yet.

How do I test what the LLM actually does with my tool, not just whether the tool works? That's a separate concern from server correctness, closer to prompt or agent evaluation than unit testing. You can approximate it by writing a small eval harness that gives a real model your tool definitions plus a task, and checks whether it calls the right tool with sensible arguments across a set of representative prompts. Keep this separate from your CI test suite since it costs real API calls and introduces model-level variance; run it as a periodic check rather than a per-commit gate.

Should I mock the LLM client when testing my MCP server? No, and this is a common confusion. Your MCP server doesn't call an LLM, it's called by one (or by a client acting on one's behalf). There's nothing to mock on that side. What you mock in unit tests is your server's own dependencies: databases, filesystems, external APIs. The client SDK in your integration tests plays the role the LLM's client would play, but it's a real, deterministic test client, not a mock of a model.

My server works in Claude Desktop but fails with the Inspector or a custom client. What's usually wrong? This almost always traces back to a capability or schema mismatch that Claude Desktop happens to tolerate but the spec doesn't strictly require it to. Check first whether your server writes anything to stdout besides JSON-RPC frames (logging to stdout instead of stderr on a stdio transport is the single most common cause), then check whether your initialize response accurately advertises only the capabilities you've actually implemented.

How often should I re-run the Inspector checks? Run it manually whenever you add a new tool, change a tool's input schema, or touch resource/prompt handling, since those are the changes most likely to introduce a subtle protocol-level or description-level bug that automated tests won't catch. For everything else, your unit and integration suites in CI should be enough to catch regressions.

How to Test MCP Servers: Unit, Integration, and Inspector · TeachYou Academy