Writing unit tests for MCP servers

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The quick version

The best way to unit-test an MCP tool is to connect a real client to your real server over an in-memory transport, in the same process, and call the tool the way a model would. No subprocess, no network port, no mocking the protocol. You get the actual serialization, schema validation, and result shape, and the test runs in microseconds.

In Python with FastMCP, you hand the server straight to a Client:

import pytest
from fastmcp import FastMCP, Client

mcp = FastMCP("Calc")

@mcp.tool
def add(a: int, b: int) -> int:
    """Add two integers."""
    return a + b

@pytest.mark.asyncio
async def test_add():
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": 5, "b": 3})
    assert result.data == 8

Client(mcp) uses FastMCP's in-memory transport when you pass it a server object, so there's nothing to start or tear down. (FastMCP testing) The rest of this guide is about the details that decide whether these tests catch real bugs: which client to reach for, how the result is actually shaped, and the one thing about errors that trips up almost every first attempt.

Prerequisites

To follow along, you'll need:

  • Python 3.10+ with an async test runner. Install the standalone FastMCP package plus pytest and its async plugin: pip install fastmcp pytest pytest-asyncio.
  • Or Node.js 18+ with a test runner. Install the SDK, Zod, and Vitest: npm install @modelcontextprotocol/sdk zod and npm install -D vitest.
  • A tool or two you want to test. If you don't have a server yet, build an MCP server in Python or in TypeScript first, then come back.

One naming trap up front on the Python side: there are two libraries that both export a class with a similar role. The standalone fastmcp package (jlowin's project, docs at gofastmcp.com) is imported as from fastmcp import FastMCP and ships the Client used above. The in-SDK server class lives inside the official mcp package — but its spelling depends on the version. mcp 2.0.0 shipped stable on 2026-07-28, alongside the 2026-07-28 spec revision, and renamed that class to MCPServer (from mcp.server.mcpserver import MCPServer), removing the old mcp.server.fastmcp import path outright rather than deprecating it. So an unpinned pip install mcp today gives you MCPServer, not FastMCP. (python-sdk migration guide) The example below targets the pinned pre-2.0 line, where the class is still FastMCP; both libraries are covered because the client you pick changes how you assert on errors.

Why in-memory beats a subprocess

In production a host runs your server as a separate process and talks to it over stdio, or connects to it over Streamable HTTP. For a unit test, both are the wrong tool: a subprocess brings startup races, a pipe or port to manage, and flaky teardown, and a network transport adds a socket on top. You end up with an integration test wearing a unit test's clothes.

The alternative is an in-memory transport: a test-only connection where the client and server run in the same process and talk over a linked pair of in-process streams, with no subprocess and no socket. It's a real MCP transport, just wired for tests instead of deployment. The three helpers this guide uses are exactly that: FastMCP's Client(server), the SDK's create_connected_server_and_client_session, and TypeScript's InMemoryTransport.createLinkedPair().

You still exercise the full protocol path (the client encodes a tools/call, the server decodes it, validates arguments against the schema, runs your handler, and encodes the result back), so you're testing the same code that runs in production, minus the parts that make tests slow and nondeterministic. Save the subprocess and network paths for a smaller number of integration tests.

The result shape is not your return value

Your handler returns 8, but the client doesn't hand you back 8. A tool result is an MCP CallToolResult, and it carries several fields. With the standalone FastMCP Client, the ones you'll assert on are:

  • result.data: your return value, deserialized back into a Python object. This is usually what you want.
  • result.content: the list of content blocks, where content[0].text is the string form.
  • result.structured_content: the machine-readable payload, present when the tool has an output schema.
  • result.is_error: whether the call failed.

For a tool typed -> int returning 8, that works out to:

async with Client(mcp) as client:
    result = await client.call_tool("add", {"a": 5, "b": 3})

assert result.data == 8                       # deserialized return value
assert result.content[0].text == "8"          # text block, always a string
assert result.structured_content == {"result": 8}

A bare int or list return gets wrapped as {"result": value} in the structured payload rather than sent raw, so a -> int tool returning 8 gives you structured_content == {"result": 8}, not 8. Return a dict, a dataclass, or a Pydantic model when you want named fields you can address by key. Indexing the result object directly as result[0].text == "8" doesn't match this shape; assert on result.data or result.content[0].text instead.

Errors don't throw, they come back as a result

This is the single most common way an MCP test goes wrong. When a tool handler raises, the framework catches the exception and returns a normal result with isError set, rather than letting the exception propagate to the caller. So a test that wraps the call in pytest.raises(...) and expects an exception will fail, because no exception ever reaches it.

The behavior does differ between the two clients, which is exactly why the naming trap matters:

The standalone FastMCP Client re-raises by default. A handler that raises ToolError surfaces as a raised ToolError on the client, so pytest.raises is correct there:

from fastmcp.exceptions import ToolError

@mcp.tool
def get_user(username: str) -> dict:
    """Look up a user by username."""
    if username != "alice":
        raise ToolError(f"No user named {username!r}")
    return {"username": "alice", "active": True}

@pytest.mark.asyncio
async def test_unknown_user_raises():
    async with Client(mcp) as client:
        with pytest.raises(ToolError, match="No user named"):
            await client.call_tool("get_user", {"username": "bob"})

But if you'd rather assert on the error result instead of catching an exception, pass raise_on_error=False and read the fields off the result:

@pytest.mark.asyncio
async def test_unknown_user_result():
    async with Client(mcp) as client:
        result = await client.call_tool(
            "get_user", {"username": "bob"}, raise_on_error=False
        )
    assert result.is_error is True
    assert "No user named" in result.content[0].text

Both styles work; pick one and be consistent. The match= and the substring assertion are what keep the test honest, because a test that only checks "something failed" passes even when the tool fails for the wrong reason.

The in-SDK client returns the result, it doesn't raise

If you're on the official mcp package rather than standalone FastMCP, the in-memory helper is create_connected_server_and_client_session, and the low-level ClientSession it hands you behaves differently on error: it never raises for a failed tool call. It always returns a CallToolResult, and you check isError yourself. (python-sdk memory helper)

This example pins mcp<2. mcp 2.0.0 (2026-07-28) renames FastMCP to MCPServer (mcp.server.fastmcpmcp.server.mcpserver) and goes further than a rename for this helper specifically: create_connected_server_and_client_session is removed outright, replaced by a unified mcp.client.Client that connects to a Server/MCPServer instance directly for in-memory testing. If you're already on 2.0.0, treat the pattern below as background and check the migration guide for the current equivalent rather than porting this import as written.

# pip install "mcp<2" — mcp 2.0.0 renames FastMCP to MCPServer and removes
# create_connected_server_and_client_session (see the migration guide above)
import pytest
from mcp.server.fastmcp import FastMCP
from mcp.shared.memory import create_connected_server_and_client_session as client_session

mcp = FastMCP("Directory")

@mcp.tool()
def get_user(username: str) -> dict:
    """Look up a user by username."""
    if username != "alice":
        raise ValueError(f"No user named {username!r}")
    return {"username": "alice", "active": True}

@pytest.mark.asyncio
async def test_error_is_a_result_not_an_exception():
    async with client_session(mcp._mcp_server) as client:
        result = await client.call_tool("get_user", {"username": "bob"})
    assert result.isError is True
    assert "No user named 'bob'" in result.content[0].text

Two things are worth calling out:

  1. The helper unwraps FastMCP for you. It accepts either a low-level Server or a FastMCP and does if isinstance(server, FastMCP): server = server._mcp_server internally, so passing mcp directly works just as well as the explicit mcp._mcp_server shown here. Reach for ._mcp_server only when you want to be explicit about what's being connected.
  2. The field is isError, not is_error. On the low-level CallToolResult it's camelCase (matching the wire format), versus is_error on the standalone FastMCP result object. The error text is the SDK's wrapper, "Error executing tool get_user: No user named 'bob'", so assert on a substring you control from inside the handler rather than the whole string.

Testing a successful call end to end

Put the pieces together and a passing-path test reads cleanly. This one lists the tools (proving registration and the advertised schema) and then exercises one:

@pytest.mark.asyncio
async def test_tool_is_registered_and_runs():
    async with Client(mcp) as client:
        tools = await client.list_tools()
        assert "add" in [t.name for t in tools]

        result = await client.call_tool("add", {"a": 40, "b": 2})
    assert result.data == 42

list_tools is worth asserting on because a tool that never registered, or registered with the wrong name, is a bug your callers hit before any handler logic does. It's cheap insurance in the same test.

Testing resources, briefly

Tools are where most of the logic lives, but the same in-memory pattern tests resources. Register one, then read it through the client and assert on the returned contents:

@mcp.resource("config://settings")
def settings() -> dict:
    return {"theme": "dark"}

@pytest.mark.asyncio
async def test_resource_read():
    async with Client(mcp) as client:
        contents = await client.read_resource("config://settings")
    assert '"theme": "dark"' in contents[0].text

read_resource returns a list of content blocks, each with a .text you can assert on, mirroring how tool content works.

Doing it in TypeScript

The TypeScript SDK ships an InMemoryTransport whose createLinkedPair() returns two linked transports, one for each end. You connect a real Client to one and your McpServer to the other, and from there the client calls tools exactly as in production. (InMemoryTransport source)

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { z } from "zod";

function buildServer() {
  const server = new McpServer({ name: "calc", version: "1.0.0" });
  server.registerTool(
    "add",
    { description: "Add two integers.", inputSchema: { a: z.number(), b: z.number() } },
    async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] })
  );
  return server;
}

Note registerTool, not the older server.tool(...), which carries an @deprecated Use registerTool instead tag on every overload and is removed in the SDK's 2.x line. (@deprecated on tool() in mcp.ts) A tiny helper connects the pair and hands back a client you can call:

async function connect(server: McpServer) {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  const client = new Client({ name: "test", version: "1.0.0" });
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
  return client;
}

Now a Vitest spec calls the tool and asserts on the result. As in Python, the text lives on a content block and is always a string, so parse it if your tool returns JSON. One typing wrinkle: result.content is a union of block shapes (text, image, audio, resource), and .text only exists on the text variant, so under strict you have to tell the compiler which block you're reading before you touch .text. A one-line TextBlock alias and a cast does it:

import { describe, it, expect } from "vitest";

type TextBlock = { type: "text"; text: string };

describe("add tool", () => {
  it("returns the sum as text", async () => {
    const client = await connect(buildServer());
    const result = await client.callTool({ name: "add", arguments: { a: 5, b: 3 } });
    expect(result.isError).toBeFalsy();
    expect((result.content as TextBlock[])[0].text).toBe("8");
  });
});

In TypeScript, errors also come back as isError

The same rule holds on the TypeScript side. When a handler throws, or when arguments fail schema validation, the client's callTool promise does not reject. It resolves to a result with isError: true and the message in content[0].text. So a test written as await expect(client.callTool(...)).rejects.toThrow(...) fails, because the promise never rejects.

Assert on the result instead. A handler that throws:

server.registerTool(
  "divide",
  { description: "Divide two numbers.", inputSchema: { a: z.number(), b: z.number() } },
  async ({ a, b }) => {
    if (b === 0) throw new Error("Division by zero");
    return { content: [{ type: "text", text: String(a / b) }] };
  }
);

it("surfaces a thrown error as an error result", async () => {
  const client = await connect(buildServer());
  const result = await client.callTool({ name: "divide", arguments: { a: 10, b: 0 } });
  expect(result.isError).toBe(true);
  expect((result.content as TextBlock[])[0].text).toContain("Division by zero");
});

Schema validation lands the same way. Call add with a string where it wants a number and the SDK rejects it before your handler runs, returning isError: true with a message like MCP error -32602: Input validation error: .... Your handler never executes, which is the point: the schema is doing its job. Testing that path (a bad argument produces an error result, not a crash and not a wrong answer) is one of the highest-value tests you can write, and it's covered in depth in validation tests for tool inputs.

it("rejects a wrong-typed argument before the handler runs", async () => {
  const client = await connect(buildServer());
  const result = await client.callTool({ name: "add", arguments: { a: "nope", b: 3 } });
  expect(result.isError).toBe(true);
});

A few habits that keep these tests solid

  • Assert on a specific value, not just success. result.data == 42 catches a wrong answer; result.is_error is False alone doesn't. For errors, match a substring you set inside the handler so the test can't pass on an unrelated failure.
  • Build the server fresh per test. A module-level server that accumulates state across tests turns an unrelated failure into a debugging afternoon. A factory function (like buildServer() above) or a pytest fixture keeps each test isolated.
  • Mock what leaves the process, not the protocol. If a tool calls a database or an HTTP API, patch that dependency so the test stays fast and deterministic. Don't mock the client or the transport; those are the parts you actually want exercised.
  • Test the argument boundary. A missing required field, a wrong type, a value outside a Field constraint: each should produce an error result, and each is a real thing callers do.

Once your tools have unit tests and hold up in-memory, the questions that remain are operational: which tools get called against real clients, with what arguments, and where they error in production. That visibility is what AgentCat adds to a live MCP server.