Guide · Testing
MCP server unit testing
Unit testing an MCP server requires a different approach from testing a REST API. A REST handler receives a plain req object and returns a plain response — you can call it as a function. An MCP tool handler runs inside a server that speaks a protocol: the client sends an initialise request, the server negotiates capabilities, and only then can tool calls proceed. InMemoryTransport is the SDK's solution: it creates a linked pair of transports that let a real server and a real client communicate in-process, through the full MCP protocol, with no network and no ports. This guide covers the InMemoryTransport pattern, how to inject fake dependencies so tool handlers don't reach real databases or APIs, and how to assert on the structured content responses the MCP protocol returns.
TL;DR
Call InMemoryTransport.createLinkedPair() to get [serverTransport, clientTransport]. Connect your server to serverTransport, connect a test Client to clientTransport, then call client.callTool(). Tool responses are { content: [{type: 'text', text: '...'}], isError?: boolean }. Use dependency injection to swap real databases and HTTP clients for fakes. Close both server and client in afterEach to prevent transport leaks between tests.
Why InMemoryTransport
The MCP SDK offers two transports for production: StdioServerTransport (for tools launched by a desktop client like Claude) and StreamableHTTPServerTransport / SSEServerTransport (for remotely-hosted servers). Both require either spawning a child process or binding an HTTP port — neither is suitable for unit tests. InMemoryTransport is a first-party alternative that passes MCP messages through an in-process channel. The server and client negotiate capabilities using the exact same code path as production — the only difference is the messages travel through a JavaScript object instead of a socket.
| Transport | For tests | For production | Requires port / process |
|---|---|---|---|
InMemoryTransport | Yes — use this | No | No |
StdioServerTransport | Manual only | Local MCP clients | Yes — subprocess |
SSEServerTransport | Possible but slow | HTTP/SSE servers | Yes — HTTP port |
StreamableHTTPServerTransport | Possible but slow | HTTP servers (MCP 2025) | Yes — HTTP port |
Installing the SDK
npm install @modelcontextprotocol/sdk
npm install --save-dev vitest @vitest/coverage-v8
The SDK ships TypeScript types and ships pre-built CommonJS and ESM bundles. InMemoryTransport is exported from the main package entry point in SDK v1.x.
Building a testable server factory
A unit-testable MCP server takes its external dependencies as parameters instead of importing them at the top of the file. This lets tests pass a fake database or HTTP client without any module-level patching.
// src/server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
export interface ServerDeps {
fetchWeather: (city: string) => Promise<{ temp: number; condition: string }>;
db: { getUser: (id: string) => Promise<{ name: string } | null> };
}
export function createServer(deps: ServerDeps): Server {
const server = new Server(
{ name: 'my-mcp-server', version: '1.0.0' },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'get_weather',
description: 'Get current weather for a city',
inputSchema: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
},
{
name: 'get_user',
description: 'Get user profile by ID',
inputSchema: {
type: 'object',
properties: { id: { type: 'string' } },
required: ['id'],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'get_weather') {
const city = args?.city as string;
if (!city) {
return { content: [{ type: 'text', text: 'city argument is required' }], isError: true };
}
const weather = await deps.fetchWeather(city);
return {
content: [{
type: 'text',
text: `${city}: ${weather.temp}°C, ${weather.condition}`,
}],
};
}
if (name === 'get_user') {
const id = args?.id as string;
const user = await deps.db.getUser(id);
if (!user) {
return { content: [{ type: 'text', text: `User ${id} not found` }], isError: true };
}
return { content: [{ type: 'text', text: `User: ${user.name}` }] };
}
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
});
return server;
}
The deps parameter is the key to testability. In production, you pass real implementations: the live weather API client and the SQLite database. In tests, you pass fakes.
Writing unit tests with InMemoryTransport
// src/server.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer, type ServerDeps } from './server.js';
// Fake dependencies — no network, no disk
const fakeDeps: ServerDeps = {
fetchWeather: async (city) => {
if (city === 'London') return { temp: 15, condition: 'cloudy' };
throw new Error('Unknown city');
},
db: {
getUser: async (id) => {
if (id === 'u1') return { name: 'Alice' };
return null;
},
},
};
describe('MCP server — tool handlers', () => {
let client: Client;
beforeEach(async () => {
// Create a linked pair: messages written to one end arrive at the other
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const server = createServer(fakeDeps);
await server.connect(serverTransport);
client = new Client(
{ name: 'test-client', version: '1.0.0' },
{ capabilities: {} }
);
await client.connect(clientTransport);
});
afterEach(async () => {
await client.close();
});
it('returns weather for a known city', async () => {
const result = await client.callTool({
name: 'get_weather',
arguments: { city: 'London' },
});
expect(result.isError).toBeFalsy();
expect(result.content).toHaveLength(1);
expect(result.content[0]).toMatchObject({ type: 'text', text: 'London: 15°C, cloudy' });
});
it('returns isError when city argument is missing', async () => {
const result = await client.callTool({ name: 'get_weather', arguments: {} });
expect(result.isError).toBe(true);
expect((result.content[0] as { type: string; text: string }).text).toContain('required');
});
it('returns isError when user not found', async () => {
const result = await client.callTool({
name: 'get_user',
arguments: { id: 'nonexistent' },
});
expect(result.isError).toBe(true);
expect((result.content[0] as { type: string; text: string }).text).toContain('not found');
});
it('returns user name for a known ID', async () => {
const result = await client.callTool({ name: 'get_user', arguments: { id: 'u1' } });
expect(result.isError).toBeFalsy();
expect((result.content[0] as { type: string; text: string }).text).toBe('User: Alice');
});
it('lists all tools', async () => {
const { tools } = await client.listTools();
expect(tools).toHaveLength(2);
expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(['get_weather', 'get_user']));
});
});
Each test gets a fresh server and client in beforeEach. The linked pair is discarded after each test via client.close(). Because InMemoryTransport passes messages synchronously (no real I/O), tests complete in under a millisecond each.
Testing error paths — upstream failures
Unit tests should cover what happens when dependencies fail, not just when they succeed. Swap the fake for one that throws to test your error handling path.
it('handles upstream weather API failure gracefully', async () => {
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
// Override one method to simulate an upstream outage
const failingDeps: ServerDeps = {
...fakeDeps,
fetchWeather: async () => { throw new Error('API timeout'); },
};
const server = createServer(failingDeps);
await server.connect(serverTransport);
const failClient = new Client({ name: 'fail-client', version: '1.0.0' }, { capabilities: {} });
await failClient.connect(clientTransport);
// A well-implemented tool handler catches the error and returns isError: true
// rather than throwing — throwing causes the MCP client to receive a protocol error
const result = await failClient.callTool({ name: 'get_weather', arguments: { city: 'London' } });
expect(result.isError).toBe(true);
await failClient.close();
});
There is an important distinction: a tool handler that returns { isError: true } is telling the LLM client "the tool ran but the operation failed, here is the error detail." A tool handler that throws produces a protocol-level error that the MCP SDK converts to a JSON-RPC error response — the LLM client receives a different error shape and typically cannot recover gracefully. Catch upstream exceptions in your handlers and return isError: true content instead.
Test lifecycle and server shutdown
The server created in beforeEach does not need to be explicitly closed if you close the client — closing the client transport triggers the server's connection-close handler. However, if your server registers cleanup logic (database close, interval clear), you should expose a shutdown() method and call it in afterEach. This mirrors the graceful shutdown sequence you test in production.
// Extended server factory with lifecycle
export function createServer(deps: ServerDeps): { server: Server; shutdown: () => Promise<void> } {
const server = new Server(/* ... */);
const interval = setInterval(() => deps.db.getUser('health-check'), 60_000);
return {
server,
shutdown: async () => {
clearInterval(interval);
},
};
}
// In tests:
let teardown: () => Promise<void>;
beforeEach(async () => {
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const { server, shutdown } = createServer(fakeDeps);
teardown = shutdown;
await server.connect(serverTransport);
// ... connect client
});
afterEach(async () => {
await client.close();
await teardown();
});
What unit tests catch vs. what AliveMCP catches
Unit tests with InMemoryTransport verify that your tool handler logic is correct: the right computation runs, the right content is returned, errors are handled. What they cannot verify is whether your deployed server is reachable, whether the MCP protocol handshake succeeds over the network, or whether a database migration completed correctly before traffic arrived. AliveMCP probes the live initialize and tools/list endpoints on your deployed server every 60 seconds — the complement to local unit testing, not a replacement for it.
Related questions
Does InMemoryTransport test the real MCP protocol?
Yes. InMemoryTransport is a real transport that serialises and deserialises MCP messages. The server and client go through the full initialize / initialized handshake, capability negotiation, and tools/call request-response cycle. The only difference from production is that messages travel through an in-process channel rather than a network socket. Protocol-level bugs — wrong message shape, missing fields, incorrect capability negotiation — are caught by unit tests that use InMemoryTransport.
How do I test tools that return image or resource content?
The MCP content model supports TextContent (type: 'text'), ImageContent (type: 'image', with data base64 string and mimeType), and EmbeddedResource (type: 'resource'). Assert on result.content[0].type first, then narrow the type to access type-specific fields. For image content, verify the mimeType and that data is a valid base64 string — testing the full image data byte-for-byte is usually not worth it unless the transformation logic is complex.
Should I use Jest or Vitest?
See MCP server Vitest for a full comparison. Vitest handles ESM imports natively (the MCP SDK ships ESM), runs faster than Jest, and requires no additional configuration for TypeScript. Jest requires ts-jest or babel-jest and extra transformIgnorePatterns to handle the SDK's ESM output. For new MCP server projects, Vitest is the lower-friction choice.
Can I test tools/list separately from tools/call?
Yes — client.listTools() sends the tools/list request and returns a { tools: Tool[] } response. Testing the tool list separately from the call handlers is useful for catching schema changes: if you add a required parameter to a tool but forget to add it to the inputSchema, the list test catches the mismatch. Consider keeping a snapshot of your tools/list response (see MCP server test coverage) to detect unintentional schema drift.
Further reading
- MCP server Vitest — native ESM test runner for TypeScript MCP servers
- MCP server mocking — mock external APIs and databases in tool handlers
- MCP server test coverage — measuring and reporting coverage for MCP servers
- MCP Inspector — interactive tool debugger for manual and exploratory testing
- MCP server dependency injection — structure for testable tool handlers
- MCP server integration testing — testing with real infrastructure
- MCP server error handling — when to return isError vs. throw
- MCP server graceful shutdown — shutdown lifecycle that unit tests should mirror
- AliveMCP — external MCP protocol monitoring for deployed servers