Guide · Testing
MCP server mocking
MCP tool handlers are functions that call external things: databases, third-party APIs, file systems, message queues. In a test, you want to run the handler logic without hitting those real dependencies — both because they're slow (network roundtrips) and because they're fragile (API rate limits, test data drift). There are two distinct mocking concerns in an MCP server: mocking the MCP connection (so you don't spin up an HTTP server for every unit test) and mocking the tool handler's dependencies (so tests don't reach the real database or API). This guide covers both layers, with patterns for dependency injection, vi.mock(), Mock Service Worker (msw), in-memory SQLite, and ioredis-mock.
TL;DR
Mock the MCP connection with InMemoryTransport.createLinkedPair() — no port, no HTTP server. Mock the tool dependencies by passing fake implementations as constructor arguments (dependency injection) or with vi.mock(). For HTTP APIs inside tool handlers, use msw (Mock Service Worker) to intercept at the network layer. For database-backed tools, use better-sqlite3 with ':memory:' as the database path.
Layer 1 — mock the MCP connection
InMemoryTransport from the MCP SDK provides a linked pair of transports that communicate in-process. The server and client run in the same Node.js process; no port is opened, no HTTP requests are made. This is the foundation of every MCP unit test.
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
await myServer.connect(serverTransport);
await testClient.connect(clientTransport);
// From here, testClient.callTool() goes through the full MCP protocol
// but entirely within the current process — no network involved
See MCP server unit testing for the full pattern. This page focuses on mocking the external dependencies that your tool handlers call once the MCP layer is already handled by InMemoryTransport.
Layer 2 — mock with dependency injection
The cleanest approach for tool handler dependencies is dependency injection: pass the database, HTTP client, or any other external object as a parameter to your server factory, rather than importing it at the top of the handler file. In tests, pass a fake. In production, pass the real thing.
// src/server.ts — deps as a parameter, not a module-level import
export interface Deps {
stripe: { createPaymentIntent: (amount: number) => Promise<{ id: string }> };
db: { getAccount: (id: string) => Promise<{ balance: number } | null> };
}
export function createServer(deps: Deps): Server {
// ... register tool handlers that call deps.stripe and deps.db
}
// Production entry point:
// import Stripe from 'stripe';
// import { openDatabase } from './db.js';
// createServer({ stripe: new Stripe(process.env.STRIPE_KEY!), db: openDatabase() });
// Test:
const fakeDeps: Deps = {
stripe: {
createPaymentIntent: vi.fn().mockResolvedValue({ id: 'pi_test_123' }),
},
db: {
getAccount: vi.fn().mockResolvedValue({ balance: 10000 }),
},
};
const server = createServer(fakeDeps);
With this pattern, test assertions can check that the right methods were called with the right arguments — not just that the tool returned the correct content.
it('calls stripe with the correct amount', async () => {
await client.callTool({ name: 'create_payment', arguments: { amount: 5000 } });
expect(fakeDeps.stripe.createPaymentIntent).toHaveBeenCalledWith(5000);
});
Layer 2 — mock with vi.mock()
When refactoring an existing codebase to use dependency injection is impractical, vi.mock() replaces the module at the import level. Vitest hoists vi.mock() calls to the top of the file, so the mock is in place before any test code runs.
// Replaces the entire module — every export becomes a vi.fn()
vi.mock('./stripe-client.js', () => ({
createPaymentIntent: vi.fn().mockResolvedValue({ id: 'pi_test_123' }),
}));
// Access the mock to add per-test behaviour
import { createPaymentIntent } from './stripe-client.js';
it('returns isError when Stripe rejects the payment', async () => {
vi.mocked(createPaymentIntent).mockRejectedValueOnce(new Error('Card declined'));
const result = await client.callTool({ name: 'create_payment', arguments: { amount: 5000 } });
expect(result.isError).toBe(true);
});
Call vi.clearAllMocks() in afterEach to reset call counts and return values between tests. Without this, a mock configured in one test bleeds into the next.
Mocking HTTP APIs with Mock Service Worker (msw)
vi.mock() replaces a specific import. If your tool handler uses fetch, axios, node-fetch, or any other HTTP client, you would need to mock each one separately. Mock Service Worker intercepts at the network layer — it catches any HTTP request regardless of which library made it.
npm install --save-dev msw
// test/setup.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const mswServer = setupServer(
// Intercept GET requests to the weather API
http.get('https://api.openweathermap.org/data/2.5/weather', ({ request }) => {
const url = new URL(request.url);
const city = url.searchParams.get('q');
if (city === 'London') {
return HttpResponse.json({
main: { temp: 288.15 }, // Kelvin
weather: [{ description: 'cloudy' }],
});
}
return new HttpResponse(null, { status: 404 });
}),
);
// In vitest.config.ts:
// test.setupFiles: ['./test/setup.ts']
// test.globalSetup: './test/global-setup.ts'
// test/global-setup.ts
import { mswServer } from './setup.js';
export function setup() { mswServer.listen({ onUnhandledRequest: 'error' }); }
export function teardown() { mswServer.close(); }
Setting onUnhandledRequest: 'error' causes tests to fail if any HTTP request is made that doesn't match a handler. This catches tool handlers that make unexpected API calls — useful for verifying that test isolation is complete and no production API is being called.
Mocking databases with in-memory SQLite
For tool handlers that interact with a SQLite database, use ':memory:' as the database path. An in-memory SQLite database is created fresh for each test, has no file I/O overhead, and is discarded when the database handle is closed — no cleanup, no stale test data.
// test/helpers/db.ts
import Database from 'better-sqlite3';
import { initSchema } from '../../src/db.js';
export function createTestDb(): Database.Database {
const db = new Database(':memory:'); // in-memory — no file created
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
initSchema(db);
return db;
}
// In tests:
describe('get_user tool', () => {
let db: Database.Database;
beforeEach(() => {
db = createTestDb();
// Seed test data
db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run('u1', 'Alice');
});
afterEach(() => {
db.close(); // in-memory DB is discarded — no cleanup needed
});
it('returns the user name', async () => {
const server = createServer({ db });
// ... connect InMemoryTransport, create client
const result = await client.callTool({ name: 'get_user', arguments: { id: 'u1' } });
expect((result.content[0] as { text: string }).text).toBe('User: Alice');
});
});
In-memory SQLite is significantly faster than file-based SQLite in tests because there is no fsync — writes complete without waiting for the kernel to flush to disk. A test suite that creates and seeds a database for each test typically runs in milliseconds.
Mocking Redis with ioredis-mock
For tool handlers that use Redis for caching or rate limiting, ioredis-mock provides an in-memory Redis implementation with the same API as ioredis.
npm install --save-dev ioredis-mock
// In tests — replace the real ioredis client with the mock
vi.mock('ioredis', () => {
const { default: MockRedis } = await import('ioredis-mock');
return { default: MockRedis };
});
// Or pass it directly via dependency injection:
import MockRedis from 'ioredis-mock';
const fakeRedis = new MockRedis();
const server = createServer({ redis: fakeRedis, db: createTestDb() });
Note that ioredis-mock does not implement Lua scripts (the eval command). If your rate limiter or distributed lock uses a Lua script, you have two options: (1) test those paths in integration tests with a real Redis, or (2) abstract the Lua logic behind a named function and test the function directly.
What not to mock
Not every external call needs a mock. Over-mocking produces tests that pass regardless of whether the real implementation is correct — mocked tests passed but production fails is a common anti-pattern.
| Dependency | Mock in unit tests? | Use real in integration tests? |
|---|---|---|
| Third-party payment API (Stripe, Paddle) | Yes — rate limited, costs money, slow | Sandbox environment only |
| Your own database | Use in-memory SQLite instead of full mock | Yes — real DB, migrated schema |
| Redis cache | ioredis-mock for unit; skip for integration if possible | Yes — real Redis, Lua scripts included |
| Internal helper functions | No — test them directly | No |
Node.js built-ins (fs, crypto) | No — mock only if the file path is environment-specific | No |
Related questions
How do I mock the MCP client itself to test my server's error responses?
You don't need to mock the MCP client — InMemoryTransport provides a real client connected to your server in-process. To test error responses, call client.callTool() with invalid arguments or with a tool name that doesn't exist, and assert on the result. To simulate upstream failures, configure the dependency (database, HTTP client) to throw an error, then assert that the tool returns isError: true with a meaningful message.
Should I mock time-based operations like timeouts and rate limits?
For rate limiters that use timestamps, consider accepting a clock dependency (a function that returns the current timestamp) and passing Date.now in production and a fake in tests. Vitest also provides vi.useFakeTimers(), which replaces Date.now, setTimeout, and setInterval globally. Call vi.useRealTimers() in afterEach to restore real timers. This is useful for testing token-bucket or sliding-window rate limiters without actually waiting for the time window to elapse.
My tool handler makes multiple HTTP requests. Do I need a handler for each?
Yes — each unique URL pattern needs an msw handler. You can share handler collections across test files using a handlers.ts file and reset or override specific handlers per-test with mswServer.use(). For example, the base handlers file might define the "happy path" responses, and individual tests override specific handlers to return error responses.
Further reading
- MCP server unit testing — the InMemoryTransport pattern that makes mocking worth doing
- MCP server Vitest — vi.mock() and the test runner this guide assumes
- MCP server dependency injection — structuring handlers so mocking is easy
- MCP server integration testing — where real dependencies replace mocks
- MCP server SQLite — the real database that in-memory SQLite mimics
- MCP server Redis — the cache layer that ioredis-mock replaces in tests
- MCP server error handling — what your tool handler should do when the mocked dep throws
- AliveMCP — production monitoring that tests with mocks cannot cover