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"

Quick Summary

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.

3testing layers: unit tests, integration tests, and end-to-end with MCP Inspector

The MCP Testing Pyramid

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.

LayerSpeedScopeWhen to Run
Unit TestsFast (< 1s each)Individual functions and handlersEvery commit
Integration TestsMedium (< 5s each)Full MCP protocol round-tripsEvery pull request
End-to-End (Inspector)Slow (manual or scripted)Complete server with real transportBefore releases

Unit Testing Individual Tools

The most impactful tests verify your tool handlers as standalone functions, independent of MCP protocol wiring.

Extract Business Logic from MCP Handlers

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 at the Boundary, Not in the Middle

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.

Test the Full Protocol Round-Trip

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

Run the Inspector Before Every Release

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:

  1. All tools appear with correct names and descriptions
  2. Tool schemas render properly in the form UI
  3. Valid inputs produce expected output
  4. Invalid inputs produce helpful error messages
  5. Resources return data with correct MIME types
  6. Prompts generate valid message arrays

CI/CD Testing Pipeline

Automate Every Layer in CI

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
Coverage Goals

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.

Frequently Asked Questions