P425 min

Testing Strategies

Unit tests, integration tests, mocking, error path testing, and CI/CD for MCP servers.

On This Page

Key Concepts

  • Unit testing tool handlers in isolation
  • Integration testing with the MCP Client SDK
  • Mocking external dependencies
  • Testing error paths and edge cases
  • CI/CD pipelines for MCP servers
  • Test coverage strategy for production readiness

An MCP server that works in development is not the same as one that works in production. Testing is how you close that gap. This module covers testing strategies specific to MCP servers — from unit testing individual tool handlers to integration testing the full protocol flow.

Unit Testing MCP Tools

The key insight: your tool handler is just a function. Extract the business logic from the MCP registration and test it directly.

// src/tools/weather.ts — Extract handler logic
export async function getWeatherHandler(args: { city: string }) {
  const { city } = args;
  if (!city || city.length > 100) {
    return {
      content: [{ type: "text" as const, text: JSON.stringify({
        error: { code: "INVALID_INPUT", message: "City name required, max 100 chars" }
      }) }],
      isError: true,
    };
  }

  const data = await fetchWeatherAPI(city);
  return {
    content: [{ type: "text" as const, text: JSON.stringify({
      city: data.city,
      temp_f: data.temp,
      condition: data.condition,
    }, null, 2) }],
  };
}

// src/index.ts — Register with MCP
server.tool("get_weather", { city: z.string() }, getWeatherHandler);
// tests/tools/weather.test.ts
import { describe, it, expect, vi } from "vitest";
import { getWeatherHandler } from "../src/tools/weather";

// Mock the external API
vi.mock("../src/lib/weather-api", () => ({
  fetchWeatherAPI: vi.fn().mockResolvedValue({
    city: "Austin",
    temp: 85,
    condition: "Sunny",
  }),
}));

describe("getWeatherHandler", () => {
  it("returns weather data for valid city", async () => {
    const result = await getWeatherHandler({ city: "Austin" });
    expect(result.isError).toBeUndefined();
    const data = JSON.parse(result.content[0].text);
    expect(data.city).toBe("Austin");
    expect(data.temp_f).toBe(85);
  });

  it("returns error for empty city", async () => {
    const result = await getWeatherHandler({ city: "" });
    expect(result.isError).toBe(true);
    const data = JSON.parse(result.content[0].text);
    expect(data.error.code).toBe("INVALID_INPUT");
  });

  it("returns error for overly long city name", async () => {
    const result = await getWeatherHandler({ city: "a".repeat(101) });
    expect(result.isError).toBe(true);
  });
});

Integration Testing

Unit tests verify your logic. Integration tests verify the MCP protocol works end-to-end. Use the MCP Client SDK to connect to your server and call tools programmatically.

// tests/integration/server.test.ts
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { describe, it, expect, beforeAll, afterAll } from "vitest";

let client: Client;
let transport: StdioClientTransport;

beforeAll(async () => {
  transport = new StdioClientTransport({
    command: "node",
    args: ["./dist/index.js"],
  });

  client = new Client({
    name: "test-client",
    version: "1.0.0",
  });

  await client.connect(transport);
});

afterAll(async () => {
  await client.close();
});

describe("MCP Server Integration", () => {
  it("lists all expected tools", async () => {
    const tools = await client.listTools();
    const toolNames = tools.tools.map(t => t.name);
    expect(toolNames).toContain("get_weather");
    expect(toolNames).toContain("manage_forecasts");
  });

  it("calls get_weather with valid input", async () => {
    const result = await client.callTool({
      name: "get_weather",
      arguments: { city: "Austin" },
    });
    expect(result.isError).toBeFalsy();
    const data = JSON.parse(result.content[0].text);
    expect(data).toHaveProperty("temp_f");
  });

  it("lists resources", async () => {
    const resources = await client.listResources();
    expect(resources.resources.length).toBeGreaterThan(0);
  });

  it("reads a resource", async () => {
    const result = await client.readResource({
      uri: "weather://stations/list",
    });
    expect(result.contents.length).toBeGreaterThan(0);
  });
});

Integration tests catch problems that unit tests miss: serialization bugs, schema validation issues, transport errors, and protocol compliance.

Mocking the MCP Layer

Sometimes you need to test how your server handles specific MCP protocol scenarios without a full client-server connection.

// Mock the server context for handler testing
function createMockContext(overrides = {}) {
  return {
    server: {
      createMessage: vi.fn().mockResolvedValue({
        content: { type: "text", text: "mocked response" },
      }),
      sendLoggingMessage: vi.fn(),
    },
    auth: {
      userId: "test-user",
      scopes: ["read", "write"],
    },
    ...overrides,
  };
}

// Test a tool that uses sampling
it("uses sampling for classification", async () => {
  const ctx = createMockContext({
    server: {
      createMessage: vi.fn().mockResolvedValue({
        content: { type: "text", text: "billing" },
      }),
    },
  });

  const result = await processTicketHandler(
    { ticket_text: "I was charged twice" },
    ctx
  );

  // Verify sampling was called
  expect(ctx.server.createMessage).toHaveBeenCalledOnce();
  // Verify it was routed to billing
  const data = JSON.parse(result.content[0].text);
  expect(data.category).toBe("billing");
});

Error Path Testing

Most MCP server bugs surface in error paths. Test every failure mode:

describe("Error Paths", () => {
  it("handles API timeout gracefully", async () => {
    vi.mock("../src/lib/weather-api", () => ({
      fetchWeatherAPI: vi.fn().mockRejectedValue(
        new Error("Request timed out")
      ),
    }));

    const result = await getWeatherHandler({ city: "Austin" });
    expect(result.isError).toBe(true);
    const data = JSON.parse(result.content[0].text);
    expect(data.error.code).toBe("TIMEOUT");
    expect(data.error.retryable).toBe(true);
  });

  it("handles rate limiting", async () => {
    vi.mock("../src/lib/weather-api", () => ({
      fetchWeatherAPI: vi.fn().mockRejectedValue(
        Object.assign(new Error("Rate limited"), { status: 429 })
      ),
    }));

    const result = await getWeatherHandler({ city: "Austin" });
    expect(result.isError).toBe(true);
    const data = JSON.parse(result.content[0].text);
    expect(data.error.code).toBe("RATE_LIMITED");
    expect(data.error.retry_after_seconds).toBeGreaterThan(0);
  });

  it("handles malformed API response", async () => {
    vi.mock("../src/lib/weather-api", () => ({
      fetchWeatherAPI: vi.fn().mockResolvedValue(null),
    }));

    const result = await getWeatherHandler({ city: "Austin" });
    expect(result.isError).toBe(true);
  });

  it("handles invalid JSON in response", async () => {
    vi.mock("../src/lib/weather-api", () => ({
      fetchWeatherAPI: vi.fn().mockResolvedValue("not json"),
    }));

    const result = await getWeatherHandler({ city: "Austin" });
    expect(result.isError).toBe(true);
  });
});

CI/CD for MCP Servers

# .github/workflows/test.yml
name: Test MCP Server
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npm run build
      - run: npm run test          # Unit tests
      - run: npm run test:integration  # Integration tests

      # Type checking
      - run: npx tsc --noEmit

      # Lint
      - run: npm run lint

      # Verify the server starts and responds
      - name: Smoke test
        run: |
          node dist/index.js &
          SERVER_PID=$!
          sleep 2
          # If using HTTP transport, curl the health endpoint
          # If using stdio, run a quick client test
          kill $SERVER_PID

Test coverage targets:

  • Every tool handler: happy path + at least 2 error paths
  • Every resource: can be read, returns valid content
  • Every prompt template: renders with sample arguments
  • Protocol compliance: listTools, listResources, listPrompts all return valid schemas
  • Error responses: all structured, all include suggestion field

Exercise: Write a Test Suite

Take the MCP server you built in Phase 2 and create:

  1. Unit tests for every tool handler (mock external dependencies)
  2. Integration tests using the MCP Client SDK
  3. Error path tests for: network timeout, invalid input, rate limiting
  4. A GitHub Actions workflow that runs all tests on push
  5. A smoke test that verifies the server starts and responds

Check Your Understanding

  1. Why should you extract tool handler logic from MCP registration for testing?
  2. What problems do integration tests catch that unit tests miss?
  3. Name three error paths you should always test in an MCP tool.
  4. How would you test a tool that uses sampling without making real AI calls?
  5. What should a CI/CD smoke test verify about an MCP server?

Key Takeaway

Testing MCP servers requires both unit tests (handler logic in isolation) and integration tests (full protocol round trips). Extract your handlers for easy unit testing, mock external dependencies, and pay special attention to error paths — that is where production bugs live. A CI/CD pipeline with type checking, linting, unit tests, integration tests, and a smoke test gives you confidence to ship.