teachyou.ai academy
← All posts
MCP

Testing MCP Servers: Unit Tests for Tool Definitions

Ira Menon · May 2, 2026 · 14 min read

Why MCP Servers Break Silently

You ship an MCP server, an agent picks it up, and everything works in your five-minute manual test. Three weeks later, someone reports that the search_orders tool is returning malformed results, or worse, that the model has started calling a tool with arguments that never should have passed validation. You go digging and find the bug was introduced two commits ago, in a change that "only touched the description string."

This is the recurring failure mode with MCP (Model Context Protocol) servers: the surface area that matters most — the tool's name, its description, its input schema, and its handler logic — is exactly the surface area that almost nobody unit tests. Teams write integration tests that spin up a full client-server handshake and call a tool end to end, which is valuable, but slow and brittle. They skip the cheap layer underneath: plain unit tests that check whether a tool definition is even well-formed before a model ever sees it.

An MCP tool definition is a contract. It has a name the model uses to select the tool, a description the model reads to decide when to call it, a JSON Schema that constrains what arguments are legal, and a handler function that executes side effects. Every one of those four pieces can break independently, and each failure mode looks different in production. A schema bug lets bad input through. A description regression makes the model stop calling a tool it used to call correctly. A handler bug silently swallows errors and returns a "success" response with an empty payload. None of this requires a live model in the loop to catch — it requires disciplined unit testing of the definitions themselves.

This article walks through how to structure unit tests for MCP tool definitions: validating schemas, testing handlers in isolation, mocking transport, and catching the specific categories of bugs that tend to slip through code review because they're subtle rather than syntactically wrong.

The Four Things You're Actually Testing

Before writing a single test, it helps to separate what "testing an MCP server" actually means, because the term gets used loosely.

  • Schema validation — does the JSON Schema attached to a tool actually accept the inputs it should and reject the inputs it shouldn't?
  • Handler correctness — given valid arguments, does the function that executes the tool produce the right output and side effects?
  • Protocol conformance — does the server respond to tools/list, tools/call, and error cases the way the MCP spec expects?
  • Registration integrity — are tools registered exactly once, with unique names, and do they show up correctly when a client lists them?

Most teams only ever exercise the third category, because that's what an end-to-end test against a running server naturally covers. The first, second, and fourth categories are pure unit-testing territory — no server process, no transport, no model. That's also why they're the cheapest tests you can write and the ones with the best bug-catching ratio per line of test code.

Setting Up a Testable Tool Definition

The starting point for testability is separating the *definition* of a tool from the *transport* that serves it. If your tool's schema and handler are only reachable by spinning up the full MCP server and connecting a client, you've made every test slow and every failure hard to localize.

Here's a typical tool definition using the TypeScript MCP SDK pattern, structured so the handler is a plain, exported function:

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

export const getOrderStatusSchema = z.object({
  orderId: z.string().min(1, "orderId cannot be empty"),
  includeHistory: z.boolean().optional().default(false),
});

export type GetOrderStatusInput = z.infer<typeof getOrderStatusSchema>;

export async function getOrderStatusHandler(
  input: GetOrderStatusInput,
  deps: { fetchOrder: (id: string) => Promise<Order | null> }
) {
  const order = await deps.fetchOrder(input.orderId);

  if (!order) {
    return {
      content: [{ type: "text", text: `No order found for ID ${input.orderId}` }],
      isError: true,
    };
  }

  const summary = input.includeHistory
    ? `${order.status} (history: ${order.history.join(", ")})`
    : order.status;

  return {
    content: [{ type: "text", text: summary }],
  };
}

export const getOrderStatusTool = {
  name: "get_order_status",
  description:
    "Retrieve the current status of an order by its ID. Use this when the user asks about order tracking, delivery status, or wants to know if an order has shipped.",
  inputSchema: getOrderStatusSchema,
};

Notice the shape: the schema is exported separately, the handler takes a deps object instead of importing a database client directly, and the tool object composes the two. This is the seam that makes unit testing possible without touching a network socket. fetchOrder becomes trivially mockable, the schema becomes testable with .parse() and .safeParse() directly, and the handler becomes a pure function you can call with plain objects.

If your current tools import a database client, an HTTP client, or a file system call directly inside the handler body, that's the first refactor to make. Dependency injection isn't an architectural luxury here — it's the difference between a test suite that runs in milliseconds and one that requires a running Postgres instance.

Unit Testing the Schema

Schema tests are the highest-leverage tests you can write for an MCP server, because a schema bug directly changes what the model is allowed to send — and by extension, what your handler has to defend against.

// tools/getOrderStatus.test.ts
import { describe, it, expect } from "vitest";
import { getOrderStatusSchema } from "./getOrderStatus";

describe("getOrderStatusSchema", () => {
  it("accepts a valid orderId with no optional fields", () => {
    const result = getOrderStatusSchema.safeParse({ orderId: "ORD-1001" });
    expect(result.success).toBe(true);
  });

  it("rejects an empty orderId", () => {
    const result = getOrderStatusSchema.safeParse({ orderId: "" });
    expect(result.success).toBe(false);
  });

  it("rejects missing orderId entirely", () => {
    const result = getOrderStatusSchema.safeParse({});
    expect(result.success).toBe(false);
  });

  it("defaults includeHistory to false when omitted", () => {
    const result = getOrderStatusSchema.parse({ orderId: "ORD-1001" });
    expect(result.includeHistory).toBe(false);
  });

  it("rejects a non-boolean includeHistory", () => {
    const result = getOrderStatusSchema.safeParse({
      orderId: "ORD-1001",
      includeHistory: "yes",
    });
    expect(result.success).toBe(false);
  });

  it("rejects unexpected extra fields if schema is strict", () => {
    const result = getOrderStatusSchema.safeParse({
      orderId: "ORD-1001",
      adminOverride: true,
    });
    // Depending on your schema's strictness mode, decide and assert the behavior explicitly
    expect(result.success).toBe(true); // zod is permissive by default — document this choice
  });
});

That last test matters more than it looks. Zod objects are permissive by default — unknown keys are silently stripped, not rejected, unless you call .strict(). If a model (or a malicious prompt injection somewhere upstream) starts sending extra fields hoping one of them gets interpreted downstream, you want your test suite to tell you explicitly whether that's allowed, not discover it in a security review six months later. Write the test either way, but make the assertion match an intentional decision, not an accident.

The general pattern for schema tests: enumerate every field, and for each field write at least a "valid value passes," "missing value" (if required), "wrong type fails," and "boundary value" case. For a page parameter that should be a positive integer, test 0, -1, 1.5, and a string like "3". These are the exact values a model will occasionally produce when it's uncertain, and schema validation is your only backstop before the handler runs.

Testing Handlers in Isolation

With dependencies injected, handler tests become straightforward function tests — no MCP transport, no client, just calling the exported handler with a mock.

import { describe, it, expect, vi } from "vitest";
import { getOrderStatusHandler } from "./getOrderStatus";

describe("getOrderStatusHandler", () => {
  it("returns the order status when the order exists", async () => {
    const fetchOrder = vi.fn().mockResolvedValue({
      id: "ORD-1001",
      status: "shipped",
      history: ["placed", "packed", "shipped"],
    });

    const result = await getOrderStatusHandler(
      { orderId: "ORD-1001", includeHistory: false },
      { fetchOrder }
    );

    expect(fetchOrder).toHaveBeenCalledWith("ORD-1001");
    expect(result.content[0].text).toBe("shipped");
    expect(result.isError).toBeUndefined();
  });

  it("includes history when requested", async () => {
    const fetchOrder = vi.fn().mockResolvedValue({
      id: "ORD-1001",
      status: "shipped",
      history: ["placed", "packed", "shipped"],
    });

    const result = await getOrderStatusHandler(
      { orderId: "ORD-1001", includeHistory: true },
      { fetchOrder }
    );

    expect(result.content[0].text).toContain("placed, packed, shipped");
  });

  it("returns an isError response when the order is not found", async () => {
    const fetchOrder = vi.fn().mockResolvedValue(null);

    const result = await getOrderStatusHandler(
      { orderId: "ORD-9999", includeHistory: false },
      { fetchOrder }
    );

    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain("ORD-9999");
  });

  it("propagates unexpected errors instead of swallowing them", async () => {
    const fetchOrder = vi.fn().mockRejectedValue(new Error("db timeout"));

    await expect(
      getOrderStatusHandler({ orderId: "ORD-1001", includeHistory: false }, { fetchOrder })
    ).rejects.toThrow("db timeout");
  });
});

That last test is the one people forget. A common anti-pattern in MCP handlers is a blanket try/catch that turns every failure into a generic isError: true response with the message "something went wrong." That's convenient for the model — it gets a clean, parseable error — but it's terrible for you, because now a database outage looks identical to a bad input in your logs, and your handler test suite can't tell the difference either. Decide deliberately which errors should become tool-level isError responses (expected, recoverable, worth explaining to the model) and which should throw and get handled by your server's top-level error boundary (unexpected, operational, worth paging someone about). Write a test for both categories.

Testing Tool Descriptions Like Code, Not Prose

This is the part teams skip most often, and it's the one most specific to MCP versus testing a normal REST endpoint. The tool's description field isn't documentation — it's an input to the model's tool-selection decision. A vague or misleading description is a functional bug, not a style nit, because it changes whether the model calls the right tool at the right time.

You can't unit test "does the model understand this description" without an actual model call, but you can test the properties that correlate strongly with description quality, and catch regressions cheaply:

import { describe, it, expect } from "vitest";
import { getOrderStatusTool } from "./getOrderStatus";
import { allTools } from "./index";

describe("tool metadata quality", () => {
  it("has a non-empty description of reasonable length", () => {
    expect(getOrderStatusTool.description.length).toBeGreaterThan(20);
    expect(getOrderStatusTool.description.length).toBeLessThan(500);
  });

  it("uses snake_case for the tool name", () => {
    expect(getOrderStatusTool.name).toMatch(/^[a-z][a-z0-9_]*$/);
  });

  it("does not duplicate the tool name across the server", () => {
    const names = allTools.map((t) => t.name);
    const unique = new Set(names);
    expect(unique.size).toBe(names.length);
  });

  it("mentions when to use the tool, not just what it does", () => {
    // A lightweight heuristic: descriptions should contain guidance language
    const guidancePhrases = ["use this", "use when", "call this", "when the user"];
    const hasGuidance = guidancePhrases.some((phrase) =>
      getOrderStatusTool.description.toLowerCase().includes(phrase)
    );
    expect(hasGuidance).toBe(true);
  });
});

The duplicate-name check deserves special attention. As an MCP server grows past a dozen tools, accidental name collisions happen more often than you'd expect, especially across files maintained by different contributors. Some MCP client implementations will fail loudly on a duplicate name; others will silently let the second registration shadow the first, and you'll spend an afternoon debugging why a tool "isn't working" when it's actually not being called at all. This test costs three lines and catches a class of bug that's otherwise invisible until a client complains.

Testing the Server's Protocol Surface

Below the individual tool layer, there's a thinner layer worth testing directly: does your server correctly implement tools/list and tools/call, and does it handle the edge cases the protocol defines — an unknown tool name, malformed arguments, and a tool that throws?

Rather than spinning up a real transport (stdio or HTTP/SSE), most SDKs let you invoke the server's request handlers directly against an in-memory Server instance:

import { describe, it, expect } from "vitest";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { buildServer } from "./server";

describe("MCP server protocol surface", () => {
  it("lists all registered tools", async () => {
    const server = buildServer();
    const listHandler = server["_requestHandlers"].get("tools/list");
    const response = await listHandler({ method: "tools/list", params: {} });
    expect(response.tools.map((t) => t.name)).toContain("get_order_status");
  });

  it("returns a protocol error for an unknown tool name", async () => {
    const server = buildServer();
    const callHandler = server["_requestHandlers"].get("tools/call");

    await expect(
      callHandler({
        method: "tools/call",
        params: { name: "nonexistent_tool", arguments: {} },
      })
    ).rejects.toThrow(/unknown tool/i);
  });

  it("returns isError rather than throwing for invalid arguments", async () => {
    const server = buildServer();
    const callHandler = server["_requestHandlers"].get("tools/call");

    const response = await callHandler({
      method: "tools/call",
      params: { name: "get_order_status", arguments: { orderId: "" } },
    });

    expect(response.isError).toBe(true);
  });
});

Reaching into _requestHandlers is admittedly relying on SDK internals, and the cleaner long-term approach — especially if your SDK version exposes it — is a public test harness or an in-memory transport pair that lets a real Client talk to your Server without going over stdio or a socket. The point isn't the exact mechanism; it's the principle: you should be able to exercise tools/list and tools/call without spawning a process, opening a port, or waiting on I/O. If your only way to test the protocol layer is a full subprocess integration test, that's a signal to invest in an in-memory harness before your test suite gets slow enough that people stop running it locally.

Catching Regressions Before They Reach an Agent

Once the unit layer is solid, the payoff shows up in exactly the scenarios that used to be painful: refactoring a shared validation helper, upgrading your MCP SDK version, or letting a new contributor touch tool descriptions.

A few practical habits make this payoff bigger:

  1. Snapshot the tool list. A simple snapshot test of allTools.map(t => ({ name: t.name, description: t.description })) catches accidental description edits, accidental deletions, and accidental renames in one assertion. When the snapshot fails, the diff shows you exactly what changed, and you decide whether it was intentional.
  2. Test schema changes against real historical arguments. If you have logs or fixtures of arguments a model has actually sent in production, replay them against your schema in CI. A schema tightening that seems reasonable in isolation can silently break a valid, common call pattern you didn't think to write a test for by hand.
  3. Run handler tests with adversarial-but-schema-valid input. Schema validation only guarantees shape, not semantics. orderId: "'; DROP TABLE orders; --" passes a z.string().min(1) schema just fine. Your handler tests should include at least one case per tool that feeds in schema-valid garbage and asserts the handler treats it as opaque data, not as something to interpolate into a query or shell command.
  4. Keep a fixture file per tool, not per test. Centralizing valid/invalid input fixtures next to each tool definition keeps schema tests, handler tests, and any later contract tests using the same ground truth, so nobody has three slightly different ideas of what a "valid order ID" looks like.

None of this replaces integration tests where an actual client calls an actual running server, or evals where you check whether a model picks the right tool given a prompt. Those layers matter too, and they catch different things — model behavior, transport quirks, latency under load. But they're expensive to run and slow to iterate on, which is exactly why they shouldn't be your first line of defense. Unit tests on schemas, handlers, and metadata are cheap, fast, and catch the majority of bugs that make MCP servers unreliable in practice: a loosened validation rule, a swallowed exception, a description that quietly stopped making sense after a rename.

Building the Habit Into Your Workflow

If you're retrofitting tests onto an existing MCP server rather than starting fresh, the highest-value first move is usually the dependency injection refactor described earlier — pulling handler logic out from behind direct imports of database clients, HTTP clients, or file system calls. Everything downstream, from schema tests to handler tests to protocol tests, gets dramatically easier once a handler is a pure-ish function you can call with mocks.

From there, treat new tools the way you'd treat a new API endpoint: no tool merges without a schema test covering its required fields, its optional fields, and at least one deliberately invalid payload per field. No handler ships without a test for the happy path, the not-found path, and the unexpected-error path. It's a small amount of discipline that pays for itself the first time a schema change almost breaks a tool nobody remembered existed.

MCP servers sit in an unusual spot: they're infrastructure, but the thing consuming them is a model rather than a human clicking through a UI or a client library with compile-time types. That makes the definitions themselves — names, descriptions, schemas — part of your test surface in a way that traditional backend testing doesn't quite prepare you for. Treat them accordingly, and a whole category of "why did the agent call the wrong tool" incidents stops happening in production and starts getting caught in CI instead.

If you want to go deeper on the parts of this that unit tests alone don't cover — designing tool boundaries, wiring up transports, handling auth, and structuring a server that scales past a handful of tools — that's exactly the ground covered in Building & Integrating MCP Servers, where we walk through the full lifecycle from a first tool definition to a production-ready server an agent can rely on.