MCP Server Testing Skill
You are an expert in testing Model Context Protocol (MCP) servers. When the user asks you to write tests for MCP servers, validate MCP tools, test transport layers, or verify MCP integrations, follow these detailed instructions to produce comprehensive, production-ready test suites.
Core Principles
- Transport-layer isolation -- Test stdio and SSE transports independently before testing full server behavior to isolate transport-specific bugs from business logic issues.
- Schema-first validation -- Every MCP tool must have its input and output schemas validated against the JSON Schema specification before testing functional behavior.
- Stateful conversation testing -- MCP servers maintain session state; tests must verify correct behavior across multi-turn interactions including context window management and resource lifecycle.
- Error boundary coverage -- Test every error code defined in the MCP specification including parse errors, invalid requests, method not found, invalid params, and internal errors.
- Tool invocation fidelity -- Validate that tool calls produce deterministic results for identical inputs, handle edge cases gracefully, and respect timeout constraints.
- Resource lifecycle management -- Test resource creation, reading, updating, subscription, and cleanup to ensure no resource leaks occur during server operation.
- Protocol compliance verification -- Ensure all JSON-RPC 2.0 message formats, capability negotiation, and protocol version handshakes conform to the MCP specification.
Project Structure
tests/
mcp/
unit/
tools/
tool-schema.test.ts
tool-execution.test.ts
tool-error-handling.test.ts
resources/
resource-read.test.ts
resource-subscribe.test.ts
resource-templates.test.ts
prompts/
prompt-list.test.ts
prompt-get.test.ts
prompt-arguments.test.ts
integration/
transport/
stdio-transport.test.ts
sse-transport.test.ts
streamable-http.test.ts
session/
initialization.test.ts
capability-negotiation.test.ts
multi-turn.test.ts
lifecycle/
server-startup.test.ts
graceful-shutdown.test.ts
reconnection.test.ts
e2e/
full-flow.test.ts
concurrent-clients.test.ts
error-recovery.test.ts
fixtures/
mock-tools.ts
mock-resources.ts
sample-requests.ts
sample-responses.ts
helpers/
mcp-test-client.ts
transport-factory.ts
assertion-helpers.ts
config/
vitest.mcp.config.ts
MCP Test Client Helper
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { spawn, ChildProcess } from 'child_process';
interface MCPTestClientOptions {
transport: 'stdio' | 'sse';
serverCommand?: string;
serverArgs?: string[];
serverUrl?: string;
timeout?: number;
}
export class MCPTestClient {
private client: Client;
private serverProcess: ChildProcess | null = null;
private transport: StdioClientTransport | SSEClientTransport;
constructor(private options: MCPTestClientOptions) {
this.client = new Client(
{ name: 'mcp-test-client', version: '1.0.0' },
{ capabilities: {} }
);
}
async connect(): Promise<void> {
if (this.options.transport === 'stdio') {
const command = this.options.serverCommand || 'node';
const args = this.options.serverArgs || ['dist/index.js'];
this.transport = new StdioClientTransport({
command,
args,
env: { ...process.env, NODE_ENV: 'test' },
});
} else {
const url = this.options.serverUrl || 'http://localhost:3001/sse';
this.transport = new SSEClientTransport(new URL(url));
}
await this.client.connect(this.transport);
}
async listTools(): Promise<any> {
return this.client.request({ method: 'tools/list' }, {} as any);
}
async callTool(name: string, args: Record<string, unknown>): Promise<any> {
return this.client.request(
{
method: 'tools/call',
params: { name, arguments: args },
},
{} as any
);
}
async listResources(): Promise<any> {
return this.client.request({ method: 'resources/list' }, {} as any);
}
async readResource(uri: string): Promise<any> {
return this.client.request(
{
method: 'resources/read',
params: { uri },
},
{} as any
);
}
async listPrompts(): Promise<any> {
return this.client.request({ method: 'prompts/list' }, {} as any);
}
async getPrompt(name: string, args?: Record<string, string>): Promise<any> {
return this.client.request(
{
method: 'prompts/get',
params: { name, arguments: args },
},
{} as any
);
}
async disconnect(): Promise<void> {
await this.client.close();
if (this.serverProcess) {
this.serverProcess.kill('SIGTERM');
this.serverProcess = null;
}
}
}
export function createTestClient(
options: Partial<MCPTestClientOptions> = {}
): MCPTestClient {
return new MCPTestClient({
transport: 'stdio',
serverCommand: 'npx',
serverArgs: ['tsx', 'src/index.ts'],
timeout: 10000,
...options,
});
}
Tool Schema Validation Tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import Ajv from 'ajv';
import { createTestClient, MCPTestClient } from '../../helpers/mcp-test-client';
describe('MCP Tool Schema Validation', () => {
let client: MCPTestClient;
const ajv = new Ajv({ strict: false, allErrors: true });
beforeAll(async () => {
client = createTestClient();
await client.connect();
});
afterAll(async () => {
await client.disconnect();
});
it('should list all available tools with valid schemas', async () => {
const result = await client.listTools();
expect(result.tools).toBeDefined();
expect(Array.isArray(result.tools)).toBe();
(result..).();
( tool result.) {
(tool.).();
( tool.).();
(tool..).();
(tool.).();
( tool.).();
(tool.) {
(tool..).();
isValid = ajv.(tool.);
(isValid).();
}
}
});
(, () => {
result = client.();
names = result..( t.);
uniqueNames = (names);
(uniqueNames.).(names.);
});
(, () => {
result = client.();
( tool result.) {
(tool.?.) {
(.(tool..)).();
( requiredProp tool..) {
(tool..).(requiredProp);
}
}
}
});
(, () => {
result = client.();
validTypes = [, , , , , , ];
( tool result.) {
(tool.?.) {
( [propName, propSchema] .(tool..)) {
schema = propSchema ;
(schema.) {
types = .(schema.) ? schema. : [schema.];
( types) {
(validTypes).();
}
}
}
}
}
});
});
Tool Execution Tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestClient, MCPTestClient } from '../../helpers/mcp-test-client';
describe('MCP Tool Execution', () => {
let client: MCPTestClient;
beforeAll(async () => {
client = createTestClient();
await client.connect();
});
afterAll(async () => {
await client.disconnect();
});
it('should execute a tool with valid arguments', async () => {
const tools = await client.listTools();
const firstTool = tools.tools[0];
const minimalArgs: Record<string, unknown> = {};
if (firstTool.inputSchema?.required) {
for (const prop of firstTool.inputSchema.required) {
const propSchema = firstTool.inputSchema.properties[prop];
minimalArgs[prop] = (propSchema);
}
}
result = client.(firstTool., minimalArgs);
(result).();
(result.).();
(.(result.)).();
( item result.) {
([, , ]).(item.);
}
});
(, () => {
result = client.(, {});
(result.).();
(result.).();
(result.[].).();
});
(, () => {
tools = client.();
toolWithRequired = tools..(
t.?.?. >
);
(toolWithRequired) {
result = client.(toolWithRequired., {});
(result.).();
}
});
(, () => {
tools = client.();
firstTool = tools.[];
result = client.(firstTool., {
: ,
: ,
} );
(result).();
});
(, () => {
startTime = .();
= ;
{
.([
client.(, {}),
(
( ( ()), )
),
]);
} (: ) {
elapsed = .() - startTime;
(elapsed).( + );
}
});
});
(): {
(schema?.) {
:
schema. ? schema.[] : ;
:
:
schema. ?? ;
:
;
:
[];
:
{};
:
;
}
}
Transport Testing
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
describe('MCP Stdio Transport', () => {
let serverProcess: ChildProcess;
beforeEach(() => {
serverProcess = spawn('npx', ['tsx', 'src/index.ts'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, NODE_ENV: 'test' },
});
});
afterEach(() => {
if (serverProcess) {
serverProcess.kill('SIGTERM');
}
});
it('should respond to initialize request via stdio', async () => {
const initRequest = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
: { : , : },
},
});
response = (serverProcess, initRequest);
parsed = .(response);
(parsed.).();
(parsed.).();
(parsed.).();
(parsed..).();
(parsed..).();
(parsed..).();
});
(, () => {
response = (serverProcess, );
parsed = .(response);
(parsed.).();
(parsed..).(-);
});
(, () => {
request = .({
: ,
: ,
: ,
: {},
});
response = (serverProcess, request);
parsed = .(response);
(parsed.).();
(parsed..).(-);
});
(, () => {
requests = .({ : },
.({
: ,
: i + ,
: ,
: {},
})
);
( req requests) {
serverProcess.!.(req + );
}
: [] = [];
<>( {
buffer = ;
serverProcess.!.(, {
buffer += data.();
lines = buffer.().();
( line lines) {
{
responses.(.(line));
} {}
}
(responses. >= ) ();
});
(resolve, );
});
(responses.).();
ids = responses.( r.).();
(ids).([, , , , ]);
});
(, () => {
exitPromise = < | >( {
serverProcess.(, (code));
});
serverProcess.();
exitCode = exitPromise;
(exitCode).();
});
});
(): <> {
( {
timer = ( ( ()), timeout);
process.!.(, {
(timer);
(data.().());
});
process.!.(message + );
});
}
SSE Transport Testing
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
describe('MCP SSE Transport', () => {
let serverProcess: ChildProcess;
const SERVER_URL = 'http://localhost:3001';
beforeAll(async () => {
serverProcess = spawn('npx', ['tsx', 'src/index.ts', '--transport', 'sse'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, NODE_ENV: 'test', PORT: '3001' },
});
await waitForServer(SERVER_URL, 10000);
});
afterAll(() => {
if (serverProcess) {
serverProcess.kill('SIGTERM');
}
});
it('should establish SSE connection at /sse endpoint', async () => {
const response = await (, {
: { : },
});
(response.).();
(response..()).();
});
(, () => {
initRequest = {
: ,
: ,
: ,
: {
: ,
: {},
: { : , : },
},
};
response = (, {
: ,
: { : },
: .(initRequest),
});
(response.).();
});
(, () => {
response = (, {
: { : },
});
(response.)..();
});
(, () => {
connections = .(
.({ : },
(, {
: { : },
})
)
);
( conn connections) {
(conn.).();
}
});
(, () => {
controller = ();
response = (, {
: { : },
: controller.,
});
reader = response.!.();
decoder = ();
receivedData = ;
readPromise = <>( (resolve) => {
() {
{ done, value } = reader.();
(done) ;
receivedData += decoder.(value);
(receivedData.()) {
(receivedData);
;
}
}
});
result = .([
readPromise,
<>( ( (), )),
]);
controller.();
(result)..();
});
});
(): <> {
start = .();
(.() - start < timeout) {
{
(url);
;
} {
( (resolve, ));
}
}
();
}
Resource Testing
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestClient, MCPTestClient } from '../../helpers/mcp-test-client';
describe('MCP Resource Operations', () => {
let client: MCPTestClient;
beforeAll(async () => {
client = createTestClient();
await client.connect();
});
afterAll(async () => {
await client.disconnect();
});
it('should list all available resources', async () => {
const result = await client.listResources();
expect(result.resources).toBeDefined();
expect(Array.isArray(result.resources)).toBe(true);
for (const resource of result.resources) {
expect(resource.uri).toBeDefined();
expect(typeof resource.).();
(resource.).();
}
});
(, () => {
resources = client.();
(resources.. > ) {
firstResource = resources.[];
result = client.(firstResource.);
(result.).();
(.(result.)).();
(result..).();
( content result.) {
(content.).();
(content. || content.).();
}
}
});
(, () => {
{
client.();
expect.();
} (: ) {
(error).();
}
});
(, () => {
resources = client.();
( resource resources.) {
(resource.) {
( resource.).();
(resource.).();
}
}
});
});
Prompt Testing
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestClient, MCPTestClient } from '../../helpers/mcp-test-client';
describe('MCP Prompt Operations', () => {
let client: MCPTestClient;
beforeAll(async () => {
client = createTestClient();
await client.connect();
});
afterAll(async () => {
await client.disconnect();
});
it('should list all available prompts', async () => {
const result = await client.listPrompts();
expect(result.prompts).toBeDefined();
expect(Array.isArray(result.prompts)).toBe(true);
for (const prompt of result.prompts) {
expect(prompt.name).toBeDefined();
expect(typeof prompt.).();
}
});
(, () => {
prompts = client.();
(prompts.. > ) {
firstPrompt = prompts.[];
: <, > = {};
(firstPrompt.) {
( arg firstPrompt.) {
(arg.) {
args[arg.] = ;
}
}
}
result = client.(firstPrompt., args);
(result.).();
(.(result.)).();
( message result.) {
([, ]).(message.);
(message.).();
}
}
});
(, () => {
prompts = client.();
promptWithArgs = prompts..(
p.?.( a.)
);
(promptWithArgs) {
{
client.(promptWithArgs., {});
expect.();
} (: ) {
(error).();
}
}
});
});
Session Initialization and Capability Negotiation
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { spawn, ChildProcess } from 'child_process';
describe('MCP Session Initialization', () => {
let serverProcess: ChildProcess;
beforeEach(() => {
serverProcess = spawn('npx', ['tsx', 'src/index.ts'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, NODE_ENV: 'test' },
});
});
afterEach(() => {
serverProcess?.kill('SIGTERM');
});
it('should complete full initialization handshake', async () => {
const initRequest = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: { roots: { listChanged: true } },
: { : , : },
},
};
response = (serverProcess, initRequest);
(response..).();
(response...).();
(response..).();
initializedNotification = {
: ,
: ,
};
serverProcess.!.(.(initializedNotification) + );
});
(, () => {
initRequest = {
: ,
: ,
: ,
: {
: ,
: {
: { : },
: {},
},
: { : , : },
},
};
response = (serverProcess, initRequest);
capabilities = response..;
(capabilities.) {
( capabilities.).();
}
(capabilities.) {
( capabilities.).();
}
(capabilities.) {
( capabilities.).();
}
});
(, () => {
toolsRequest = {
: ,
: ,
: ,
: {},
};
response = (serverProcess, toolsRequest);
(response.).();
});
(, () => {
initRequest = {
: ,
: ,
: ,
: {
: ,
: {},
: { : , : },
},
};
response = (serverProcess, initRequest);
(response.) {
(response..).();
} {
(response..)..();
}
});
});
(): <> {
( {
timeout = ( ( ()), );
process.!.(, {
(timeout);
{
(.(data.().()));
} (e) {
(e);
}
});
process.!.(.(message) + );
});
}
End-to-End Flow Tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestClient, MCPTestClient } from '../helpers/mcp-test-client';
describe('MCP Full End-to-End Flow', () => {
let client: MCPTestClient;
beforeAll(async () => {
client = createTestClient();
await client.connect();
});
afterAll(async () => {
await client.disconnect();
});
it('should complete a full tool discovery and execution flow', async () => {
const tools = await client.listTools();
expect(tools.tools.length).toBeGreaterThan(0);
const selectedTool = tools.tools[0];
expect(selectedTool.name).toBeDefined();
expect(selectedTool.inputSchema).toBeDefined();
: <, > = {};
(selectedTool.?.) {
( prop selectedTool..) {
schema = selectedTool..[prop];
args[prop] = (schema);
}
}
result = client.(selectedTool., args);
(result.).();
(result..).();
});
(, () => {
resources = client.();
(resources.. > ) {
resource = resources.[];
content = client.(resource.);
(content.).();
(content..).();
}
});
(, () => {
prompts = client.();
(prompts.. > ) {
prompt = prompts.[];
: <, > = {};
(prompt.) {
( arg prompt.) {
args[arg.] = ;
}
}
result = client.(prompt., args);
(result.).();
(result..).();
}
});
(, () => {
tools = client.();
tool = tools.[];
results = [];
( i = ; i < ; i++) {
result = client.(tool., {});
results.(result);
}
(results.).();
( result results) {
(result.).();
}
});
(, () => {
tools1 = client.();
resources = client.();
tools2 = client.();
(tools1..).(tools2..);
(tools1..( t.).()).(
tools2..( t.).()
);
});
});
(): {
(schema?.) {
:
schema. ? schema.[] : ;
:
;
:
;
:
;
:
[];
:
{};
:
;
}
}
Vitest Configuration for MCP Tests
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/mcp/**/*.test.ts'],
testTimeout: 30000,
hookTimeout: 15000,
pool: 'forks',
poolOptions: {
forks: {
singleFork: true,
},
},
setupFiles: ['tests/mcp/setup.ts'],
reporters: ['verbose'],
env: {
NODE_ENV: 'test',
},
},
});
Best Practices
- Always test initialization before operations -- MCP servers require a proper handshake sequence. Never skip the initialize/initialized exchange in tests.
- Use isolated server instances per test suite -- Spawn a fresh server process for each describe block to avoid state leakage between test suites.
- Validate JSON-RPC envelope structure -- Every response must include jsonrpc, id (for requests), and either result or error. Never assume the structure.
- Test both happy path and error paths for every tool -- Each tool should have tests for valid inputs, missing required fields, type mismatches, and boundary values.
- Implement transport-agnostic test helpers -- Write test utilities that abstract the transport layer so the same logical tests can run against stdio and SSE.
- Test resource URI patterns -- Verify that resource URIs follow consistent patterns and that template parameters are properly substituted.
- Measure and assert on response times -- MCP servers in production have timeout constraints. Include performance assertions in integration tests.
- Test concurrent client scenarios -- Multiple AI agents may connect to the same MCP server. Verify that concurrent sessions do not interfere with each other.
- Verify notification delivery -- Test that servers correctly emit notifications for resource changes, tool list updates, and progress events.
- Maintain a fixture library of valid and invalid requests -- Reusable request fixtures reduce duplication and ensure consistency across test files.
Anti-Patterns
- Testing only the happy path -- Skipping error cases means production failures will be unhandled. Always test malformed inputs, missing fields, and invalid types.
- Hardcoding server URLs in tests -- Use environment variables or configuration objects so tests work across development, CI, and staging environments.
- Ignoring transport-specific behaviors -- Stdio and SSE have different failure modes. A test passing on stdio does not guarantee it passes on SSE.
- Reusing server processes across unrelated tests -- Shared state causes flaky tests. Each test suite should manage its own server lifecycle.
- Not testing the initialization handshake -- Assuming the server is ready without verifying the handshake can mask critical protocol compliance bugs.
- Ignoring JSON-RPC error codes -- The MCP spec defines specific error codes. Tests should verify the correct error code, not just that an error occurred.
- Testing tools without validating their schemas first -- A tool with an invalid schema will produce confusing runtime errors. Always validate schemas before testing execution.
- Not testing server shutdown behavior -- Servers that do not shut down cleanly leak resources and can cause port conflicts in CI.
- Skipping pagination testing -- Tools, resources, and prompts may be paginated. Tests that only check the first page miss pagination bugs.
- Not testing with realistic payloads -- Using minimal test data misses issues with large responses, deeply nested objects, and special characters in content.