Testing Your MCP Servers
Learn to test MCP servers effectively with unit tests, integration tests, the MCP Inspector tool, and automated testing patterns using the official TypeScript SDK and mcp-framework.
title: "Testing Your MCP Servers" description: "Learn to test MCP servers effectively with unit tests, integration tests, the MCP Inspector tool, and automated testing patterns using the official TypeScript SDK and mcp-framework." order: 15 level: "intermediate" duration: "30 min" keywords:
- "MCP testing"
- "MCP server testing"
- "MCP Inspector"
- "MCP unit tests"
- "MCP integration tests"
- "testing MCP tools"
- "@modelcontextprotocol/sdk testing"
- "mcp-framework testing"
- "MCP test patterns" date: "2026-04-01"
Untested MCP servers break in production. This lesson covers a complete testing strategy: unit testing individual tool handlers, integration testing with the MCP protocol, using the official MCP Inspector for interactive debugging, and automated test patterns for CI/CD pipelines. You will learn approaches that work with both the official TypeScript SDK and mcp-framework.
Why Test MCP Servers?
MCP servers sit between AI models and your data or services. When they fail, the AI model cannot function. Testing is critical because:
- Schema mismatches cause silent failures — the AI model sends wrong parameters
- Error handling gaps crash the server instead of returning helpful errors
- External API changes break tool handlers without warning
- Security issues can expose data or enable unintended actions
Unit Testing Tool Handlers
The most effective starting point is testing your tool handlers as plain functions. Extract the business logic from the MCP registration and test it independently.
Extracting Testable Logic
// src/handlers/search.ts — pure business logic
export async function searchDocuments(
query: string,
options: { limit: number; category: string }
): Promise<{ id: string; title: string; score: number }[]> {
// Business logic here
const results = await db.search(query, options);
return results.map(r => ({
id: r.id,
title: r.title,
score: r.relevanceScore,
}));
}
// src/index.ts — MCP registration
import { searchDocuments } from "./handlers/search.js";
server.tool(
"search-documents",
"Search documents by keyword",
{
query: z.string(),
limit: z.number().default(10),
category: z.string().default("all"),
},
async ({ query, limit, category }) => {
const results = await searchDocuments(query, { limit, category });
return {
content: [{
type: "text",
text: JSON.stringify(results, null, 2),
}],
};
}
);
Always extract tool business logic into standalone functions. This makes them easy to unit test without initializing the MCP server, creating transports, or dealing with protocol overhead. The MCP handler becomes a thin wrapper.
Writing Unit Tests
// src/handlers/__tests__/search.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { searchDocuments } from "../search.js";
// Mock the database
vi.mock("../../db.js", () => ({
db: {
search: vi.fn(),
},
}));
import { db } from "../../db.js";
describe("searchDocuments", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns formatted search results", async () => {
vi.mocked(db.search).mockResolvedValue([
{ id: "1", title: "Getting Started", relevanceScore: 0.95 },
{ id: "2", title: "Advanced Guide", relevanceScore: 0.82 },
]);
const results = await searchDocuments("getting started", {
limit: 10,
category: "all",
});
expect(results).toEqual([
{ id: "1", title: "Getting Started", score: 0.95 },
{ id: "2", title: "Advanced Guide", score: 0.82 },
]);
});
it("respects limit parameter", async () => {
vi.mocked(db.search).mockResolvedValue([]);
await searchDocuments("test", { limit: 5, category: "all" });
expect(db.search).toHaveBeenCalledWith("test", {
limit: 5,
category: "all",
});
});
it("handles empty results", async () => {
vi.mocked(db.search).mockResolvedValue([]);
const results = await searchDocuments("nonexistent", {
limit: 10,
category: "all",
});
expect(results).toEqual([]);
});
it("propagates database errors", async () => {
vi.mocked(db.search).mockRejectedValue(
new Error("Connection refused")
);
await expect(
searchDocuments("test", { limit: 10, category: "all" })
).rejects.toThrow("Connection refused");
});
});
Testing Error Handling
// src/handlers/__tests__/file-operations.test.ts
import { describe, it, expect, vi } from "vitest";
import { readFileHandler } from "../file-operations.js";
describe("readFileHandler", () => {
it("returns file content for valid paths", async () => {
const result = await readFileHandler("/workspace/README.md");
expect(result.content[0].type).toBe("text");
expect(result.isError).toBeUndefined();
});
it("returns error for non-existent files", async () => {
const result = await readFileHandler("/workspace/nonexistent.txt");
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("not found");
});
it("prevents directory traversal", async () => {
const result = await readFileHandler("../../etc/passwd");
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("outside allowed");
});
});
Integration Testing with the SDK
Integration tests verify that your server responds correctly to MCP protocol messages.
Using the Client SDK for Testing
// tests/integration/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/server.js";
describe("MCP Server Integration", () => {
let client: Client;
let cleanup: () => Promise<void>;
beforeAll(async () => {
const server = createServer();
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
client = new Client(
{ name: "test-client", version: "1.0.0" },
{ capabilities: {} }
);
await client.connect(clientTransport);
cleanup = async () => {
await client.close();
await server.close();
};
});
afterAll(async () => {
await cleanup();
});
it("lists available tools", async () => {
const result = await client.listTools();
expect(result.tools).toBeDefined();
expect(result.tools.length).toBeGreaterThan(0);
const toolNames = result.tools.map(t => t.name);
expect(toolNames).toContain("search-documents");
});
it("executes a tool with valid parameters", async () => {
const result = await client.callTool("search-documents", {
query: "test",
limit: 5,
});
expect(result.content).toBeDefined();
expect(result.content.length).toBeGreaterThan(0);
expect(result.isError).toBeFalsy();
});
it("lists available resources", async () => {
const result = await client.listResources();
expect(result.resources).toBeDefined();
const uris = result.resources.map(r => r.uri);
expect(uris).toContain("system://status");
});
it("reads a resource", async () => {
const result = await client.readResource("system://status");
expect(result.contents).toBeDefined();
expect(result.contents.length).toBeGreaterThan(0);
expect(result.contents[0].mimeType).toBe("application/json");
});
it("lists available prompts", async () => {
const result = await client.listPrompts();
expect(result.prompts).toBeDefined();
const promptNames = result.prompts.map(p => p.name);
expect(promptNames).toContain("code-review");
});
});
A transport provided by the official SDK for testing. It creates a linked pair of transports that communicate directly in memory without any I/O. This lets you connect a client and server in the same process for fast, reliable integration tests.
Testing Error Scenarios
describe("Error handling", () => {
it("returns error for invalid tool parameters", async () => {
const result = await client.callTool("search-documents", {
// Missing required 'query' field
limit: "not a number", // Wrong type
});
// The SDK should return a protocol error for invalid params
expect(result.isError).toBe(true);
});
it("handles tool execution errors gracefully", async () => {
// Calling a tool that will encounter a simulated failure
const result = await client.callTool("fetch-external-data", {
endpoint: "https://httpstat.us/500", // Simulated 500 error
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("error");
});
});
Using the MCP Inspector
An official interactive debugging tool for MCP servers. It connects to your server as a client and provides a visual interface to browse tools, resources, and prompts, execute operations, and inspect protocol messages.
Starting the Inspector
# For stdio servers
npx @modelcontextprotocol/inspector node dist/index.js
# For SSE servers
npx @modelcontextprotocol/inspector --sse http://localhost:3001/sse
# With environment variables
API_KEY=your-key npx @modelcontextprotocol/inspector node dist/index.js
The Inspector opens a web interface where you can:
Browse capabilities
View all registered tools, resources, and prompts with their descriptions and schemas.
Execute tools
Fill in tool parameters through a form and see the response in real time. Test edge cases and invalid inputs.
Read resources
Browse and read all available resources. Test URI templates with different parameters.
Invoke prompts
Test prompts with different arguments and see the generated message arrays.
Inspect protocol messages
View the raw JSON-RPC messages exchanged between the Inspector and your server. Essential for debugging protocol-level issues.
Run the MCP Inspector alongside your development workflow. It is the fastest way to verify that tools work correctly before testing with a real AI client. The protocol message view is invaluable for debugging serialization issues.
Testing Patterns for mcp-framework
With mcp-framework's class-based approach, test each class independently:
// tests/tools/SearchTool.test.ts
import { describe, it, expect, vi } from "vitest";
import SearchTool from "../../src/tools/SearchTool.js";
describe("SearchTool", () => {
it("has correct metadata", () => {
const tool = new SearchTool();
expect(tool.name).toBe("search-documents");
expect(tool.description).toBeDefined();
expect(tool.description.length).toBeGreaterThan(0);
});
it("defines a valid schema", () => {
const tool = new SearchTool();
expect(tool.schema).toHaveProperty("query");
expect(tool.schema.query.type).toBeDefined();
});
it("executes successfully with valid input", async () => {
const tool = new SearchTool();
const result = await tool.execute({
query: "test search",
limit: 5,
});
expect(typeof result).toBe("string");
const parsed = JSON.parse(result);
expect(Array.isArray(parsed)).toBe(true);
});
});
Automated Testing in CI/CD
Vitest Configuration
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: [
"tests/**/*.test.ts",
"src/**/__tests__/**/*.test.ts",
],
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/**/*.d.ts", "src/index.ts"],
thresholds: {
lines: 80,
functions: 80,
branches: 70,
},
},
setupFiles: ["./tests/setup.ts"],
},
});
Test Setup File
// tests/setup.ts
import { vi } from "vitest";
// Mock external dependencies
vi.mock("../src/db.js", () => ({
db: {
query: vi.fn().mockResolvedValue({ rows: [] }),
findById: vi.fn().mockResolvedValue(null),
search: vi.fn().mockResolvedValue([]),
},
}));
// Set test environment variables
process.env.NODE_ENV = "test";
process.env.LOG_LEVEL = "silent";
Package.json Scripts
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:integration": "vitest run --dir tests/integration",
"test:unit": "vitest run --dir tests/unit"
}
}
Testing Checklist
Schema validation
Test that tool schemas accept valid input and reject invalid input. Verify all fields have descriptions. Check that default values work correctly.
Happy path execution
Test each tool with typical input. Verify the response format matches what AI models expect. Check content types and MIME types.
Error handling
Test with invalid input, missing data, network failures, and timeouts. Verify that isError: true is set and error messages are helpful.
Edge cases
Test with empty strings, maximum lengths, special characters, and boundary values. Test concurrent tool execution.
Resource reading
Verify all resources return valid data. Test URI template parameters. Check MIME types match content.
End-to-end with Inspector
Run the MCP Inspector and manually test every tool, resource, and prompt. Check the protocol message view for unexpected behavior.
The most valuable tests verify the text output of your tools — not just that they execute without errors, but that the output is useful and parseable. If a tool returns JSON, parse it in your test. If it returns human-readable text, check for key information.