Testing MCP Servers
Comprehensive testing strategies for MCP servers including unit tests, integration tests, and end-to-end testing approaches.
title: "Testing MCP Servers" description: "Comprehensive testing strategies for MCP servers including unit tests, integration tests, and end-to-end testing approaches." order: 3 keywords: ["MCP testing", "test MCP server", "MCP unit tests", "MCP integration tests", "MCP test patterns"] date: "2026-04-01"
A well-tested MCP server is a reliable MCP server. This guide covers the testing pyramid for MCP — unit tests for individual tool logic, integration tests using the official SDK's InMemoryTransport, end-to-end testing with the MCP Inspector, and CI/CD automation patterns. All strategies apply to servers built with both the official TypeScript SDK (@modelcontextprotocol/sdk) and mcp-framework.
The MCP Testing Pyramid
The MCP testing pyramid has three layers. The base is unit tests for individual tool handlers and utility functions. The middle layer is integration tests that verify MCP protocol interactions using in-memory transports. The top layer is end-to-end tests using the MCP Inspector or a real AI client.
| Layer | Speed | Scope | When to Run |
|---|---|---|---|
| Unit Tests | Fast (< 1s each) | Individual functions and handlers | Every commit |
| Integration Tests | Medium (< 5s each) | Full MCP protocol round-trips | Every pull request |
| End-to-End (Inspector) | Slow (manual or scripted) | Complete server with real transport | Before releases |
Unit Testing Individual Tools
The most impactful tests verify your tool handlers as standalone functions, independent of MCP protocol wiring.
Never test business logic through the MCP protocol layer. Extract the core logic into pure functions, test those directly, and keep the MCP handler as a thin wrapper. This makes tests fast, focused, and free of protocol overhead.
// src/logic/github.ts — pure business logic
export async function fetchOpenIssues(
owner: string,
repo: string,
token: string
): Promise<{ title: string; number: number; labels: string[] }[]> {
const res = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues?state=open`,
{ headers: { Authorization: `token ${token}` } }
);
if (!res.ok) throw new Error(`GitHub API error: ${res.status}`);
const issues = await res.json();
return issues.map((i: any) => ({
title: i.title,
number: i.number,
labels: i.labels.map((l: any) => l.name),
}));
}
// tests/unit/github.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fetchOpenIssues } from "../../src/logic/github.js";
describe("fetchOpenIssues", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns formatted issues on success", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response(JSON.stringify([
{ title: "Bug report", number: 42, labels: [{ name: "bug" }] },
]), { status: 200 })
);
const issues = await fetchOpenIssues("owner", "repo", "token");
expect(issues).toEqual([
{ title: "Bug report", number: 42, labels: ["bug"] },
]);
});
it("throws on API failure", async () => {
vi.spyOn(global, "fetch").mockResolvedValue(
new Response("Not Found", { status: 404 })
);
await expect(
fetchOpenIssues("owner", "repo", "token")
).rejects.toThrow("GitHub API error: 404");
});
});
Mocking External Services
External APIs, databases, and file systems must be mocked in tests to ensure speed and reliability.
Mock the outermost boundary — the fetch call, the database client, the file system module. Do not mock internal helper functions. Mocking at the boundary tests the most real code path while keeping external dependencies out.
// Mock a database module
vi.mock("../../src/db.js", () => ({
pool: {
query: vi.fn(),
getConnection: vi.fn().mockResolvedValue({
query: vi.fn(),
release: vi.fn(),
}),
},
}));
import { pool } from "../../src/db.js";
it("handles database connection failures", async () => {
vi.mocked(pool.query).mockRejectedValue(
new Error("Connection refused")
);
const result = await queryHandler("SELECT 1");
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Connection refused");
});
Integration Testing with InMemoryTransport
The official SDK provides InMemoryTransport for testing full MCP protocol interactions without real I/O.
Integration tests should verify that your server handles the complete MCP lifecycle: initialization, capability negotiation, tool discovery, tool execution, and error handling. Use the SDK's InMemoryTransport to create a client-server pair that communicates in memory.
// 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("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 all registered tools", async () => {
const { tools } = await client.listTools();
expect(tools.length).toBeGreaterThan(0);
tools.forEach(tool => {
expect(tool.name).toBeTruthy();
expect(tool.description).toBeTruthy();
});
});
it("executes a tool and returns valid content", async () => {
const result = await client.callTool("search-documents", {
query: "test",
});
expect(result.content).toBeDefined();
expect(result.isError).toBeFalsy();
});
it("returns isError for failed tool calls", async () => {
const result = await client.callTool("fetch-data", {
url: "https://nonexistent.invalid",
});
expect(result.isError).toBe(true);
});
});
Testing mcp-framework Servers
With mcp-framework, test tool classes directly by instantiating them:
import { describe, it, expect } from "vitest";
import SearchTool from "../../src/tools/SearchTool.js";
describe("SearchTool", () => {
const tool = new SearchTool();
it("has correct name and description", () => {
expect(tool.name).toBe("search-documents");
expect(tool.description).toBeTruthy();
});
it("defines required schema fields", () => {
expect(tool.schema).toHaveProperty("query");
});
it("returns results as JSON string", async () => {
const result = await tool.execute({ query: "test" });
expect(() => JSON.parse(result)).not.toThrow();
});
});
End-to-End Testing with MCP Inspector
The MCP Inspector is the closest thing to testing with a real AI client. Before releasing your server, manually walk through every tool, resource, and prompt in the Inspector. Check that descriptions are clear, schemas are correct, and error messages are helpful. This catches issues that automated tests miss — like confusing tool descriptions.
# Test a stdio server
npx @modelcontextprotocol/inspector node dist/index.js
# Test an HTTP server
npx @modelcontextprotocol/inspector --sse http://localhost:3001/sse
# Pass environment variables
DATABASE_URL=postgres://localhost/test npx @modelcontextprotocol/inspector node dist/index.js
Use the Inspector to verify:
- All tools appear with correct names and descriptions
- Tool schemas render properly in the form UI
- Valid inputs produce expected output
- Invalid inputs produce helpful error messages
- Resources return data with correct MIME types
- Prompts generate valid message arrays
CI/CD Testing Pipeline
Run unit tests on every commit for fast feedback. Run integration tests on every pull request to catch protocol-level regressions. Run build verification (compile + start + health check) before deployment. This layered approach catches bugs early without slowing down development.
# .github/workflows/test.yml
name: Test MCP Server
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm run test:unit
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm run test:integration
build-check:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npm run build
- run: timeout 10 node dist/index.js --health-check || true
Aim for 80%+ line coverage on tool handler logic and 100% coverage on input validation code. Do not chase 100% overall coverage — protocol boilerplate and transport setup are already tested by the SDK itself. Focus your testing effort on the code that is unique to your server.