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
Set up the testing framework and install dependencies
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.
1npm install -D vitest @types/node23# In package.json, add:4# "scripts": { "test": "vitest run", "test:watch": "vitest" }56# Create test directory7mkdir testsExpected result: Vitest installed and configured with a test script in package.json.
Create in-memory transports for process-internal testing
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.
1// tests/helpers/in-memory-transport.ts2import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";34export 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;910 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 }1718 async start(): Promise<void> {}1920 async send(message: any): Promise<void> {21 // Simulate async delivery22 await new Promise(resolve => setTimeout(resolve, 0));23 this.otherEnd?.onmessage?.(JSON.parse(JSON.stringify(message)));24 }2526 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.
Write integration tests that verify the full protocol flow
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.
1// tests/server.test.ts2import { 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";78describe("MCP Server Integration Tests", () => {9 let client: Client;10 let server: McpServer;1112 beforeEach(async () => {13 // Create server with test tools14 server = new McpServer({ name: "test-server", version: "1.0.0" });1516 server.tool("echo", "Echo the input back", {17 message: z.string(),18 }, async ({ message }) => ({19 content: [{ type: "text", text: `Echo: ${message}` }],20 }));2122 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 });2829 // Connect via in-memory transport30 const [clientTransport, serverTransport] = InMemoryTransport.createPair();31 client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });3233 await server.connect(serverTransport);34 await client.connect(clientTransport);35 });3637 afterEach(async () => {38 await client.close();39 });4041 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 });4748 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 });5354 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.
Write unit tests for tool handler functions in isolation
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.
1// src/tools/search.ts — export handler for unit testing2import { z } from "zod";34export async function searchHandler({ pattern, directory }: {5 pattern: string;6 directory: string;7}) {8 const regex = new RegExp(pattern, "i");9 // ... search logic10 return { content: [{ type: "text" as const, text: "results" }] };11}1213// tests/tools/search.test.ts14import { describe, it, expect } from "vitest";15import { searchHandler } from "../../src/tools/search.js";1617describe("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 });2324 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.
Set up CI/CD pipeline for automated MCP server testing
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.
1# .github/workflows/test.yml2name: MCP Server Tests3on: [push, pull_request]45jobs:6 test:7 runs-on: ubuntu-latest8 steps:9 - uses: actions/checkout@v410 - uses: actions/setup-node@v411 with:12 node-version: 2013 cache: npm14 - run: npm ci15 - run: npx tsc --noEmit # Type check16 - run: npm test # Run vitest1718 # Optional: smoke test with MCP Inspector19 smoke:20 runs-on: ubuntu-latest21 needs: test22 steps:23 - uses: actions/checkout@v424 - uses: actions/setup-node@v425 with:26 node-version: 2027 cache: npm28 - run: npm ci && npx tsc29 - name: Verify server starts and lists tools30 run: |31 timeout 10 npx @modelcontextprotocol/inspector node dist/index.js --list-tools || trueExpected result: CI pipeline that runs MCP server tests and type checks on every push.
Complete working example
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";56// Simple in-memory transport pair7class 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}2324describe("MCP Server", () => {25 let client: Client;26 let server: McpServer;2728 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 }));3637 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 });4243 afterEach(async () => { await client.close(); });4445 it("lists all tools", async () => {46 const { tools } = await client.listTools();47 expect(tools.map(t => t.name).sort()).toEqual(["add", "fail", "greet"]);48 });4950 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 });5455 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 });5960 it("handles error results", async () => {61 const r = await client.callTool({ name: "fail", arguments: {} });62 expect((r as any).isError).toBe(true);63 });6465 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation