Module 7: Testing, Debugging, and Integration
Testing MCP Servers
Testing MCP Servers
Capsule description
Testing an MCP server isn't testing a web app or a library. Your server speaks a specific protocol, receives inputs from a language model (not from a human), and returns structured data that another program interprets. The tests must verify that the protocol works, that the tools return correct results, that the resources expose valid data, and that errors are handled without crashes.
In this capsule you're going to write real tests — not testing theory, but complete test files you can copy, adapt, and run against your own MCP servers. You'll use Vitest for TypeScript and pytest for Python. Each test is designed to detect real bugs that appear when Claude Code uses your server.
Setup: Vitest for MCP servers in TypeScript
Install dependencies
From your TypeScript MCP project directory:
npm install -D vitest @types/node
Configure Vitest
Create vitest.config.ts in the project's root:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
testTimeout: 10000,
hookTimeout: 10000,
},
});
Add a script to package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Test file structure
my-mcp-server/
├── src/
│ └── index.ts # Your MCP server
├── tests/
│ ├── tools.test.ts # Tool tests
│ ├── resources.test.ts # Resource tests
│ └── server.test.ts # Integration tests
├── vitest.config.ts
├── tsconfig.json
└── package.json
Testing pattern: in-memory Client-Server
The key to testing MCP servers is to create an in-memory connection between a client and your server. You don't need to spin up an HTTP server or use stdio. The MCP SDK provides an InMemoryTransport that connects the client and server directly:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
async function createTestClient(server: McpServer): Promise<Client> {
const client = new Client({
name: "test-client",
version: "1.0.0",
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return client;
}
This pattern is the one you'll use in all your tests. InMemoryTransport.createLinkedPair() creates two connected transports: one for the client and one for the server. The messages travel directly in memory — fast, reliable, no network configuration.
Unit testing Tools (TypeScript)
The example server
For the tests, we'll use a server with two tools (read_file, list_files) and one resource (status://server). This is the complete server — it exports a createServer() function so the tests can instantiate it:
// src/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import fs from "fs/promises";
export function createServer(): McpServer {
const server = new McpServer({ name: "file-utils", version: "1.0.0" });
server.tool(
"read_file",
"Reads the content of a file and returns its text",
{ filePath: z.string().describe("Path to the file") },
async ({ filePath }) => {
try {
const content = await fs.readFile(filePath, "utf-8");
return { content: [{ type: "text" as const, text: content }] };
} catch (error) {
return {
content: [{ type: "text" as const, text: `Error: ${(error as Error).message}` }],
isError: true,
};
}
}
);
server.tool(
"list_files",
"Lists files in a directory with an optional filter by extension",
{
directory: z.string().describe("Path to the directory"),
extension: z.string().optional().describe("Filter by extension (e.g., '.ts')"),
},
async ({ directory, extension }) => {
try {
const entries = await fs.readdir(directory, { withFileTypes: true });
let files = entries.filter((e) => e.isFile()).map((e) => e.name);
if (extension) files = files.filter((f) => f.endsWith(extension));
return {
content: [{ type: "text" as const, text: JSON.stringify({ directory, files, count: files.length }, null, 2) }],
};
} catch (error) {
return {
content: [{ type: "text" as const, text: `Error: ${(error as Error).message}` }],
isError: true,
};
}
}
);
server.resource(
"server-status",
"status://server",
{ description: "The server's current status" },
async () => ({
contents: [{
uri: "status://server",
text: JSON.stringify({ status: "running", version: "1.0.0", uptime: process.uptime() }),
mimeType: "application/json",
}],
})
);
return server;
}
Test file: tools.test.ts
// tests/tools.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../src/index.js";
import fs from "fs/promises";
import path from "path";
import os from "os";
let client: Client;
let tmpDir: string;
beforeAll(async () => {
const server = createServer();
client = new Client({ name: "test-client", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-test-"));
await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello, MCP!");
await fs.writeFile(path.join(tmpDir, "data.json"), '{"key": "value"}');
await fs.writeFile(path.join(tmpDir, "script.ts"), 'console.log("test");');
});
afterAll(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
await client.close();
});
describe("read_file tool", () => {
it("reads an existing file and returns its content", async () => {
const result = await client.callTool({
name: "read_file",
arguments: { filePath: path.join(tmpDir, "hello.txt") },
});
expect(result.isError).toBeUndefined();
expect(result.content).toHaveLength(1);
expect(result.content[0]).toMatchObject({
type: "text",
text: "Hello, MCP!",
});
});
it("returns an error for a nonexistent file", async () => {
const result = await client.callTool({
name: "read_file",
arguments: { filePath: path.join(tmpDir, "does-not-exist.txt") },
});
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({ type: "text" });
expect((result.content[0] as { text: string }).text).toContain("Error");
});
it("reads a JSON file correctly", async () => {
const result = await client.callTool({
name: "read_file",
arguments: { filePath: path.join(tmpDir, "data.json") },
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed).toEqual({ key: "value" });
});
});
describe("list_files tool", () => {
it("lists all the files in a directory", async () => {
const result = await client.callTool({
name: "list_files",
arguments: { directory: tmpDir },
});
expect(result.isError).toBeUndefined();
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.count).toBe(3);
expect(parsed.files).toContain("hello.txt");
expect(parsed.files).toContain("data.json");
expect(parsed.files).toContain("script.ts");
});
it("filters by extension", async () => {
const result = await client.callTool({
name: "list_files",
arguments: { directory: tmpDir, extension: ".ts" },
});
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.count).toBe(1);
expect(parsed.files).toEqual(["script.ts"]);
});
it("returns an empty list with an extension that has no matches", async () => {
const result = await client.callTool({
name: "list_files",
arguments: { directory: tmpDir, extension: ".py" },
});
const parsed = JSON.parse((result.content[0] as { text: string }).text);
expect(parsed.count).toBe(0);
expect(parsed.files).toEqual([]);
});
it("returns an error for a nonexistent directory", async () => {
const result = await client.callTool({
name: "list_files",
arguments: { directory: "/path/that/doesnt/exist" },
});
expect(result.isError).toBe(true);
});
});
Each test verifies something that could fail in production: incorrect encoding, crashes instead of controlled errors, broken filters, or uncaught exceptions. The nonexistent-file test is especially important — Claude Code can send paths that don't exist, and your server must respond with a clean error, not a crash.
Unit testing Resources (TypeScript)
The resource tests follow the same setup pattern — reuse the same beforeAll/afterAll you used for tools:
// tests/resources.test.ts — uses the same setup with InMemoryTransport
describe("server-status resource", () => {
it("returns the server's status as valid JSON", async () => {
const result = await client.readResource({ uri: "status://server" });
expect(result.contents).toHaveLength(1);
expect(result.contents[0].mimeType).toBe("application/json");
const status = JSON.parse(result.contents[0].text as string);
expect(status).toHaveProperty("status", "running");
expect(status).toHaveProperty("version", "1.0.0");
expect(typeof status.uptime).toBe("number");
});
it("the resource appears in the list of resources", async () => {
const { resources } = await client.listResources();
const statusResource = resources.find((r) => r.uri === "status://server");
expect(statusResource).toBeDefined();
expect(statusResource?.name).toBe("server-status");
});
});
Integration testing the server (TypeScript)
The integration tests verify that the server behaves correctly as an MCP server — not the individual tools, but the protocol:
// tests/server.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createServer } from "../src/index.js";
let client: Client;
beforeAll(async () => {
const server = createServer();
client = new Client({ name: "test-client", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
});
afterAll(async () => {
await client.close();
});
describe("MCP Server integration", () => {
it("the server announces tools correctly", async () => {
const { tools } = await client.listTools();
expect(tools.length).toBeGreaterThanOrEqual(2);
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read_file");
expect(toolNames).toContain("list_files");
});
it("each tool has a description and a schema", async () => {
const { tools } = await client.listTools();
for (const tool of tools) {
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe("object");
}
});
it("the server announces resources correctly", async () => {
const { resources } = await client.listResources();
expect(resources.length).toBeGreaterThanOrEqual(1);
expect(resources[0]).toHaveProperty("uri");
expect(resources[0]).toHaveProperty("name");
});
it("a nonexistent tool returns an error", async () => {
await expect(
client.callTool({ name: "tool_that_does_not_exist", arguments: {} })
).rejects.toThrow();
});
it("a nonexistent resource returns an error", async () => {
await expect(
client.readResource({ uri: "nonexistent://nothing" })
).rejects.toThrow();
});
});
Setup: pytest for MCP servers in Python
Install dependencies
pip install pytest pytest-asyncio
Configure pytest
Create pytest.ini or add to pyproject.toml:
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
File structure
my-mcp-server-python/
├── server.py # Your MCP server
├── tests/
│ ├── __init__.py
│ ├── test_tools.py
│ ├── test_resources.py
│ └── test_server.py
└── pyproject.toml
Testing MCP servers in Python
The example server
# server.py
from mcp.server.fastmcp import FastMCP
import json, os
mcp = FastMCP("file-utils")
@mcp.tool()
async def read_file(file_path: str) -> str:
"""Reads the content of a file and returns its text."""
try:
with open(file_path, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
raise ValueError(f"File not found: {file_path}")
@mcp.tool()
async def list_files(directory: str, extension: str | None = None) -> str:
"""Lists files in a directory with an optional filter by extension."""
try:
entries = os.listdir(directory)
files = [e for e in entries if os.path.isfile(os.path.join(directory, e))]
if extension:
files = [f for f in files if f.endswith(extension)]
return json.dumps({"directory": directory, "files": files, "count": len(files)}, indent=2)
except FileNotFoundError:
raise ValueError(f"Directory not found: {directory}")
@mcp.resource("status://server")
async def server_status() -> str:
"""The server's current status."""
return json.dumps({"status": "running", "version": "1.0.0"})
Tests with pytest
# tests/test_tools.py
import pytest
import json
import os
import tempfile
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
# For direct testing of functions (unit tests)
from server import read_file, list_files
@pytest.fixture
def tmp_dir():
"""Creates a temporary directory with test files."""
with tempfile.TemporaryDirectory() as d:
with open(os.path.join(d, "hello.txt"), "w") as f:
f.write("Hello, MCP!")
with open(os.path.join(d, "data.json"), "w") as f:
json.dump({"key": "value"}, f)
with open(os.path.join(d, "script.py"), "w") as f:
f.write("print('test')")
yield d
class TestReadFile:
async def test_reads_existing_file(self, tmp_dir):
result = await read_file(os.path.join(tmp_dir, "hello.txt"))
assert result == "Hello, MCP!"
async def test_error_nonexistent_file(self, tmp_dir):
with pytest.raises(ValueError, match="not found"):
await read_file(os.path.join(tmp_dir, "does-not-exist.txt"))
async def test_reads_json_correctly(self, tmp_dir):
result = await read_file(os.path.join(tmp_dir, "data.json"))
parsed = json.loads(result)
assert parsed == {"key": "value"}
class TestListFiles:
async def test_lists_all_files(self, tmp_dir):
result = await list_files(tmp_dir)
parsed = json.loads(result)
assert parsed["count"] == 3
assert "hello.txt" in parsed["files"]
assert "data.json" in parsed["files"]
assert "script.py" in parsed["files"]
async def test_filters_by_extension(self, tmp_dir):
result = await list_files(tmp_dir, extension=".py")
parsed = json.loads(result)
assert parsed["count"] == 1
assert parsed["files"] == ["script.py"]
async def test_extension_no_matches(self, tmp_dir):
result = await list_files(tmp_dir, extension=".rs")
parsed = json.loads(result)
assert parsed["count"] == 0
assert parsed["files"] == []
async def test_nonexistent_directory(self):
with pytest.raises(ValueError, match="not found"):
await list_files("/path/that/doesnt/exist")
Integration tests in Python
For integration tests, you connect a real client to your server via stdio:
# tests/test_server.py
import pytest
import json
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
@pytest.fixture
async def client_session():
server_params = StdioServerParameters(command="python", args=["server.py"])
async with stdio_client(server_params) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
yield session
class TestServerIntegration:
async def test_server_lists_tools(self, client_session):
tools = await client_session.list_tools()
tool_names = [t.name for t in tools.tools]
assert "read_file" in tool_names
assert "list_files" in tool_names
async def test_each_tool_has_description(self, client_session):
tools = await client_session.list_tools()
for tool in tools.tools:
assert tool.description, f"Tool {tool.name} without a description"
async def test_resource_returns_valid_json(self, client_session):
result = await client_session.read_resource("status://server")
data = json.loads(result.contents[0].text)
assert data["status"] == "running"
Advanced testing patterns
Pattern 1: Input validation test
This test verifies that your server rejects invalid inputs correctly. It's important because Claude Code can send unexpected inputs:
describe("input validation", () => {
it("rejects an empty filePath", async () => {
const result = await client.callTool({
name: "read_file",
arguments: { filePath: "" },
});
expect(result.isError).toBe(true);
});
it("rejects a directory as a filePath", async () => {
const result = await client.callTool({
name: "read_file",
arguments: { filePath: tmpDir },
});
expect(result.isError).toBe(true);
});
});
Pattern 2: Idempotency test
Verifies that calling a tool multiple times produces the same result:
it("read_file is idempotent", async () => {
const filePath = path.join(tmpDir, "hello.txt");
const result1 = await client.callTool({ name: "read_file", arguments: { filePath } });
const result2 = await client.callTool({ name: "read_file", arguments: { filePath } });
expect(result1.content).toEqual(result2.content);
});
Pattern 3: Concurrency test
it("handles multiple simultaneous requests", async () => {
const filePath = path.join(tmpDir, "hello.txt");
const results = await Promise.all([
client.callTool({ name: "read_file", arguments: { filePath } }),
client.callTool({ name: "read_file", arguments: { filePath } }),
client.callTool({ name: "list_files", arguments: { directory: tmpDir } }),
]);
expect(results).toHaveLength(3);
results.forEach((r) => expect(r.isError).toBeUndefined());
});
Test troubleshooting
"InMemoryTransport doesn't exist in my version of the SDK"
Update to the latest version: npm install @modelcontextprotocol/sdk@latest
"The async tests don't finish"
Make sure to close the connection in afterAll with await client.close().
"TypeError: Cannot read properties of undefined"
Verify that beforeAll uses await on server.connect() and client.connect().
Exercises
Exercise 1: Error handling test (Easy)
Write a test that verifies your server returns isError: true with a descriptive message when a tool receives an invalid input (e.g., a path with special characters).
See solution
describe("error handling", () => {
it("returns isError with a descriptive message for an invalid path", async () => {
const result = await client.callTool({
name: "read_file",
arguments: { filePath: "\0invalid\0path" },
});
expect(result.isError).toBe(true);
const text = (result.content[0] as { text: string }).text;
expect(text).toContain("Error");
expect(text.length).toBeGreaterThan(10);
});
});
Exercise 2: Resource test with dynamic data (Easy)
Write a test that verifies the status://server resource returns an uptime greater than 0.
See solution
it("status resource has a positive uptime", async () => {
const result = await client.readResource({ uri: "status://server" });
const status = JSON.parse(result.contents[0].text as string);
expect(status.uptime).toBeGreaterThan(0);
});
Exercise 3: Test suite for a CRUD tool (Medium)
Write a test suite for a manage_notes tool that allows creating, listing, and deleting notes. The test should follow the complete flow: create → list → delete → verify.
See solution
describe("manage_notes CRUD", () => {
it("complete flow: create → list → delete → verify", async () => {
const createResult = await client.callTool({
name: "manage_notes",
arguments: { action: "create", title: "Test Note", content: "Content" },
});
const created = JSON.parse((createResult.content[0] as { text: string }).text);
expect(created.created).toHaveProperty("id");
const listResult = await client.callTool({
name: "manage_notes", arguments: { action: "list" },
});
const listed = JSON.parse((listResult.content[0] as { text: string }).text);
expect(listed.notes.some((n: { title: string }) => n.title === "Test Note")).toBe(true);
await client.callTool({
name: "manage_notes",
arguments: { action: "delete", noteId: created.created.id },
});
const listAfter = await client.callTool({
name: "manage_notes", arguments: { action: "list" },
});
const after = JSON.parse((listAfter.content[0] as { text: string }).text);
expect(after.notes.some((n: { title: string }) => n.title === "Test Note")).toBe(false);
});
});
Exercise 4: Python integration test (Medium)
Write a pytest test that connects to your Python server via stdio, lists the available tools, and verifies that each tool has an inputSchema with at least one defined parameter.
See solution
async def test_tools_have_parameters(self, client_session):
tools = await client_session.list_tools()
for tool in tools.tools:
schema = tool.inputSchema
assert schema.get("type") == "object", f"{tool.name}: schema is not an object"
properties = schema.get("properties", {})
assert len(properties) > 0, f"{tool.name}: no defined parameters"
Exercise 5: Edge case test with Unicode characters (Hard)
Write a test that creates a file with Unicode content (emojis, CJK characters, diacritics), reads it with your read_file tool, and verifies that the content is preserved exactly.
See solution
it("preserves Unicode content correctly", async () => {
const unicodeContent = "Hello 🌍\nCafé ñoño\n你好世界\nÄÖÜ àèì\n🎉🚀💡";
const filePath = path.join(tmpDir, "unicode.txt");
await fs.writeFile(filePath, unicodeContent, "utf-8");
const result = await client.callTool({
name: "read_file",
arguments: { filePath },
});
expect(result.isError).toBeUndefined();
expect((result.content[0] as { text: string }).text).toBe(unicodeContent);
});
Summary
In this capsule you learned:
- InMemoryTransport is the key to testing MCP servers without spinning up processes or servers — it connects the client and server directly in memory
- Tool unit tests verify that each tool returns correct results and handles errors without crashing
- Resource unit tests verify that the exposed data is valid and accessible
- Integration tests verify that the server announces capabilities correctly and responds to the MCP protocol
- Vitest (TypeScript) and pytest (Python) are configured with minimal effort for MCP projects
- Advanced patterns — input validation, idempotency, concurrency — detect bugs that manual tests don't find
- The testing setup (temporary files, fixtures, cleanup) is as important as the tests themselves
Each test you wrote here detects a real bug that appears when Claude Code uses your server with inputs you didn't anticipate.
Additional resources
- Vitest Documentation — Testing framework for TypeScript
- pytest Documentation — Testing framework for Python
- pytest-asyncio — Plugin for async tests in Python
- MCP TypeScript SDK — Testing — SDK with InMemoryTransport
- MCP Python SDK — Official SDK for Python
- Vitest — Mocking — Mocking external dependencies
Next capsule: Debugging: Tools — MCP Inspector for visual debugging, effective logging, and request tracing in MCP servers.