Skip to main content
RapidDev - Software Development Agency
mcp-tutorial

How to write automated tests for an MCP server

Test MCP servers with automated unit and integration tests using Vitest and the MCP client SDK. Use in-memory transports to connect a test client directly to your server without spawning processes. Write tests that verify tool schemas, validate responses, check error handling, and run in CI/CD pipelines. This catches regressions before they reach production.

What you'll learn

  • How to test MCP servers with in-memory transports for fast isolated tests
  • How to write unit tests for individual tool handlers
  • How to write integration tests that verify the full MCP protocol flow
  • How to set up CI/CD pipelines for MCP server testing
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced9 min read25-35 minMCP TypeScript SDK v1.x, Node.js 18+, Vitest or JestMarch 2026RapidDev Engineering Team
TL;DR

Test MCP servers with automated unit and integration tests using Vitest and the MCP client SDK. Use in-memory transports to connect a test client directly to your server without spawning processes. Write tests that verify tool schemas, validate responses, check error handling, and run in CI/CD pipelines. This catches regressions before they reach production.

Testing MCP Servers with Automated Unit and Integration Tests

MCP servers are critical infrastructure — they give AI assistants access to your files, databases, and APIs. Bugs in tool handlers can corrupt data, leak information, or crash production systems. This tutorial shows how to write comprehensive automated tests using in-memory transports that connect a test client directly to your server in the same process, enabling fast, reliable tests that run in CI/CD pipelines.

Prerequisites

  • A working MCP server with tools to test
  • Node.js 18+ and npm installed
  • Vitest or Jest installed as a test framework
  • Basic understanding of unit and integration testing concepts

Step-by-step guide

1

Set up the testing framework and install dependencies

Install Vitest (or Jest) and the test utilities. Vitest is recommended for TypeScript MCP projects because it supports ESM natively and has excellent TypeScript integration. Configure the test script in package.json. Create a tests/ directory for your test files.

typescript
1npm install -D vitest @types/node
2
3# In package.json, add:
4# "scripts": { "test": "vitest run", "test:watch": "vitest" }
5
6# Create test directory
7mkdir tests

Expected result: Vitest installed and configured with a test script in package.json.

2

Create in-memory transports for process-internal testing

Instead of spawning the server as a child process, connect the client and server in the same process using in-memory transports. Create a linked pair of transports where data written to one appears as input on the other. This eliminates process management, makes tests faster, and allows direct inspection of server state.

typescript
1// tests/helpers/in-memory-transport.ts
2import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
3
4export class InMemoryTransport implements Transport {
5 private otherEnd: InMemoryTransport | null = null;
6 onmessage: ((message: any) => void) | null = null;
7 onclose: (() => void) | null = null;
8 onerror: ((error: Error) => void) | null = null;
9
10 static createPair(): [InMemoryTransport, InMemoryTransport] {
11 const a = new InMemoryTransport();
12 const b = new InMemoryTransport();
13 a.otherEnd = b;
14 b.otherEnd = a;
15 return [a, b];
16 }
17
18 async start(): Promise<void> {}
19
20 async send(message: any): Promise<void> {
21 // Simulate async delivery
22 await new Promise(resolve => setTimeout(resolve, 0));
23 this.otherEnd?.onmessage?.(JSON.parse(JSON.stringify(message)));
24 }
25
26 async close(): Promise<void> {
27 this.otherEnd?.onclose?.();
28 this.otherEnd = null;
29 }
30}

Expected result: An InMemoryTransport pair that connects client and server in the same process.

3

Write integration tests that verify the full protocol flow

Create a test helper that starts your server, connects a client via in-memory transport, and provides the client to each test. Use beforeEach/afterEach to set up and tear down connections. Test tool discovery (listTools), tool execution (callTool), and error handling. Verify that tool responses match expected formats and content.

typescript
1// tests/server.test.ts
2import { describe, it, expect, beforeEach, afterEach } from "vitest";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { InMemoryTransport } from "./helpers/in-memory-transport.js";
6import { z } from "zod";
7
8describe("MCP Server Integration Tests", () => {
9 let client: Client;
10 let server: McpServer;
11
12 beforeEach(async () => {
13 // Create server with test tools
14 server = new McpServer({ name: "test-server", version: "1.0.0" });
15
16 server.tool("echo", "Echo the input back", {
17 message: z.string(),
18 }, async ({ message }) => ({
19 content: [{ type: "text", text: `Echo: ${message}` }],
20 }));
21
22 server.tool("divide", "Divide two numbers", {
23 a: z.number(), b: z.number(),
24 }, async ({ a, b }) => {
25 if (b === 0) return { content: [{ type: "text", text: "Error: Division by zero" }], isError: true };
26 return { content: [{ type: "text", text: String(a / b) }] };
27 });
28
29 // Connect via in-memory transport
30 const [clientTransport, serverTransport] = InMemoryTransport.createPair();
31 client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
32
33 await server.connect(serverTransport);
34 await client.connect(clientTransport);
35 });
36
37 afterEach(async () => {
38 await client.close();
39 });
40
41 it("should discover all registered tools", async () => {
42 const { tools } = await client.listTools();
43 expect(tools).toHaveLength(2);
44 expect(tools.map(t => t.name)).toContain("echo");
45 expect(tools.map(t => t.name)).toContain("divide");
46 });
47
48 it("should call echo tool and return correct result", async () => {
49 const result = await client.callTool({ name: "echo", arguments: { message: "hello" } });
50 const text = (result.content as any[])[0].text;
51 expect(text).toBe("Echo: hello");
52 });
53
54 it("should handle division by zero with isError", async () => {
55 const result = await client.callTool({ name: "divide", arguments: { a: 10, b: 0 } });
56 expect((result as any).isError).toBe(true);
57 expect((result.content as any[])[0].text).toContain("Division by zero");
58 });
59});

Expected result: Integration tests that verify tool discovery, execution, and error handling via the MCP protocol.

4

Write unit tests for tool handler functions in isolation

For complex tool handlers with business logic, test the handler function directly without the MCP protocol overhead. Export tool handlers as standalone functions and test them with standard unit test patterns. This is faster than integration tests and lets you test edge cases more thoroughly. Unit tests verify the logic; integration tests verify the protocol wiring.

typescript
1// src/tools/search.ts — export handler for unit testing
2import { z } from "zod";
3
4export async function searchHandler({ pattern, directory }: {
5 pattern: string;
6 directory: string;
7}) {
8 const regex = new RegExp(pattern, "i");
9 // ... search logic
10 return { content: [{ type: "text" as const, text: "results" }] };
11}
12
13// tests/tools/search.test.ts
14import { describe, it, expect } from "vitest";
15import { searchHandler } from "../../src/tools/search.js";
16
17describe("searchHandler", () => {
18 it("should return results for valid pattern", async () => {
19 const result = await searchHandler({ pattern: "test", directory: "." });
20 expect(result.content).toHaveLength(1);
21 expect(result.content[0].type).toBe("text");
22 });
23
24 it("should handle invalid regex gracefully", async () => {
25 // If your handler catches regex errors:
26 const result = await searchHandler({ pattern: "[invalid", directory: "." });
27 expect((result as any).isError).toBe(true);
28 });
29});

Expected result: Unit tests that verify tool handler logic independently from the MCP protocol.

5

Set up CI/CD pipeline for automated MCP server testing

Configure your CI/CD pipeline (GitHub Actions, GitLab CI, etc.) to run MCP server tests on every push and pull request. The tests use in-memory transports, so they do not need external services. Add a test job that installs dependencies, builds the TypeScript, and runs vitest. RapidDev recommends also running MCP Inspector smoke tests in CI for additional protocol-level verification.

typescript
1# .github/workflows/test.yml
2name: MCP Server Tests
3on: [push, pull_request]
4
5jobs:
6 test:
7 runs-on: ubuntu-latest
8 steps:
9 - uses: actions/checkout@v4
10 - uses: actions/setup-node@v4
11 with:
12 node-version: 20
13 cache: npm
14 - run: npm ci
15 - run: npx tsc --noEmit # Type check
16 - run: npm test # Run vitest
17
18 # Optional: smoke test with MCP Inspector
19 smoke:
20 runs-on: ubuntu-latest
21 needs: test
22 steps:
23 - uses: actions/checkout@v4
24 - uses: actions/setup-node@v4
25 with:
26 node-version: 20
27 cache: npm
28 - run: npm ci && npx tsc
29 - name: Verify server starts and lists tools
30 run: |
31 timeout 10 npx @modelcontextprotocol/inspector node dist/index.js --list-tools || true

Expected result: CI pipeline that runs MCP server tests and type checks on every push.

Complete working example

tests/server.test.ts
1import { describe, it, expect, beforeEach, afterEach } from "vitest";
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4import { z } from "zod";
5
6// Simple in-memory transport pair
7class InMemoryTransport {
8 otherEnd: InMemoryTransport | null = null;
9 onmessage: ((msg: any) => void) | null = null;
10 onclose: (() => void) | null = null;
11 onerror: ((err: Error) => void) | null = null;
12 static createPair(): [InMemoryTransport, InMemoryTransport] {
13 const a = new InMemoryTransport(); const b = new InMemoryTransport();
14 a.otherEnd = b; b.otherEnd = a; return [a, b];
15 }
16 async start() {}
17 async send(msg: any) {
18 await new Promise(r => setTimeout(r, 0));
19 this.otherEnd?.onmessage?.(JSON.parse(JSON.stringify(msg)));
20 }
21 async close() { this.otherEnd?.onclose?.(); this.otherEnd = null; }
22}
23
24describe("MCP Server", () => {
25 let client: Client;
26 let server: McpServer;
27
28 beforeEach(async () => {
29 server = new McpServer({ name: "test", version: "1.0.0" });
30 server.tool("greet", "Greet a user", { name: z.string() },
31 async ({ name }) => ({ content: [{ type: "text", text: `Hello, ${name}!` }] }));
32 server.tool("add", "Add numbers", { a: z.number(), b: z.number() },
33 async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }));
34 server.tool("fail", "Always fails", {},
35 async () => ({ content: [{ type: "text", text: "Error: intentional" }], isError: true }));
36
37 const [ct, st] = InMemoryTransport.createPair();
38 client = new Client({ name: "test", version: "1.0.0" }, { capabilities: {} });
39 await server.connect(st as any);
40 await client.connect(ct as any);
41 });
42
43 afterEach(async () => { await client.close(); });
44
45 it("lists all tools", async () => {
46 const { tools } = await client.listTools();
47 expect(tools.map(t => t.name).sort()).toEqual(["add", "fail", "greet"]);
48 });
49
50 it("calls greet tool", async () => {
51 const r = await client.callTool({ name: "greet", arguments: { name: "World" } });
52 expect((r.content as any[])[0].text).toBe("Hello, World!");
53 });
54
55 it("calls add tool", async () => {
56 const r = await client.callTool({ name: "add", arguments: { a: 3, b: 4 } });
57 expect((r.content as any[])[0].text).toBe("7");
58 });
59
60 it("handles error results", async () => {
61 const r = await client.callTool({ name: "fail", arguments: {} });
62 expect((r as any).isError).toBe(true);
63 });
64
65 it("returns tool input schemas", async () => {
66 const { tools } = await client.listTools();
67 const greet = tools.find(t => t.name === "greet")!;
68 expect(greet.inputSchema.properties).toHaveProperty("name");
69 });
70});

Common mistakes when writing automated tests for an MCP server

Why it's a problem: Spawning real server processes in tests, making tests slow and flaky

How to avoid: Use in-memory transports to connect client and server in the same process. Tests run in milliseconds instead of seconds.

Why it's a problem: Only testing happy paths and ignoring error handling

How to avoid: Write explicit tests for invalid inputs, missing parameters, and tools that return isError: true.

Why it's a problem: Not cleaning up client connections in afterEach, causing test interference

How to avoid: Always call client.close() in afterEach to reset state between tests.

Why it's a problem: Testing tool handlers through the MCP protocol when a simple unit test would suffice

How to avoid: Export tool handlers as standalone functions and unit test them directly for business logic. Reserve integration tests for protocol-level verification.

Best practices

  • Use in-memory transports for fast, reliable tests without process management
  • Write both unit tests (handler logic) and integration tests (protocol flow)
  • Test tool discovery to verify all tools are registered with correct schemas
  • Test error paths explicitly — invalid inputs, division by zero, missing files
  • Deep-clone messages in in-memory transports to prevent shared reference bugs
  • Run tests in CI on every push to catch regressions early
  • Add type checking (tsc --noEmit) as a separate CI step
  • Export tool handlers as named functions to make them independently testable

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

Show me how to write automated tests for an MCP server in TypeScript using Vitest. Create in-memory transports to connect client and server in the same process, write integration tests for tool discovery and execution, and set up a GitHub Actions CI pipeline.

MCP Prompt

Set up automated testing for my MCP server. Create InMemoryTransport for in-process testing, write Vitest tests that verify listTools and callTool behavior including error handling, and add a GitHub Actions workflow.

Frequently asked questions

Should I use Vitest or Jest for MCP server tests?

Vitest is recommended because it natively supports ESM (which the MCP SDK uses) and has excellent TypeScript integration. Jest requires additional ESM configuration that can be finicky.

How do I test MCP servers that depend on external services (databases, APIs)?

Mock external dependencies in unit tests. For integration tests, use test databases or API stubs. The in-memory transport pattern isolates the MCP protocol layer from external dependencies.

Can I test MCP resources and prompts with the same pattern?

Yes. Use client.listResources(), client.readResource(), client.listPrompts(), and client.getPrompt() in your integration tests with the same in-memory transport setup.

How many tests should I write per tool?

At minimum: one test for successful execution with valid inputs, one for each error condition, and one for edge cases (empty input, large input, special characters). Complex tools may need 5-10 tests.

Can RapidDev help set up testing infrastructure for MCP servers?

Yes, RapidDev helps teams establish testing patterns, CI/CD pipelines, and quality gates for MCP server projects. They can set up the in-memory transport pattern and write initial test suites.

Do I need to test the MCP protocol itself?

No. Trust the SDK to handle protocol correctness. Focus your tests on tool handler logic, error handling, and the integration between your tools and the MCP server registration.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.