Complete testing and debugging toolkit for Model Context Protocol (MCP) servers
Build reliable MCP servers with comprehensive testing utilities, intelligent snapshot testing, and stdio-safe debug logging.
Developing MCP servers comes with unique challenges:
- ❌ Testing is hard - No built-in test utilities for MCP servers
- ❌ Snapshots break - Timestamps and IDs change on every run
- ❌ Logging breaks stdio -
console.log()corrupts JSON-RPC communication - ❌ Manual assertions - Repetitive boilerplate for common checks
mcp-dev-kit solves all of these:
- ✅ MCPTestClient - Full-featured test client for MCP servers
- ✅ Smart snapshots - Auto-exclude dynamic fields (timestamps, IDs)
- ✅ Custom matchers - Readable assertions for tools, resources, prompts
- ✅ Safe logging - Debug without breaking JSON-RPC protocol
npm install --save-dev mcp-dev-kit vitest1. Create vitest.setup.ts:
import { installMCPMatchers } from 'mcp-dev-kit/matchers';
installMCPMatchers();2. Configure vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./vitest.setup.ts'],
},
});3. Write tests (server.test.ts):
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { MCPTestClient } from 'mcp-dev-kit/client';
describe('My MCP Server', () => {
let client: MCPTestClient;
beforeAll(async () => {
client = new MCPTestClient({
command: 'node',
args: ['./my-server.js'],
});
await client.connect();
});
afterAll(async () => {
await client.disconnect();
});
it('should list available tools', async () => {
const tools = await client.listTools();
expect(tools).toHaveLength(2);
expect(tools[0]).toHaveToolProperty('name', 'echo');
});
it('should execute tools successfully', async () => {
const result = await client.callTool('echo', { message: 'hello' });
await expect(result).toReturnToolResult('hello');
});
it('should have stable response structure', async () => {
const result = await client.callTool('list_files', { path: '/' });
expect(result).toMatchToolResponseSnapshot();
});
});4. Add debug logging to your server:
// At the top of your MCP server file
import 'mcp-dev-kit/logger';
// Now console.log works without breaking JSON-RPC!
console.log('Server started');
console.error('Connection error', error);5. Run tests:
npx vitestComprehensive test client for spawning and testing MCP servers via stdio.
import { MCPTestClient } from 'mcp-dev-kit/client';
const client = new MCPTestClient({
command: 'node',
args: ['./my-server.js'],
env: { DEBUG: 'true' },
timeout: 30000,
});
await client.connect();
// Test server capabilities
const serverInfo = client.getServerInfo();
const capabilities = client.getServerCapabilities();
// List and call tools
const tools = await client.listTools();
const result = await client.callTool('my-tool', { param: 'value' });
// List and read resources
const resources = await client.listResources();
const content = await client.readResource('file://config.json');
// List and get prompts
const prompts = await client.listPrompts();
const prompt = await client.getPrompt('greeting', { name: 'Alice' });
// Helper methods for common patterns
const toolResult = await client.expectToolCallSuccess('my-tool', { input: 'test' });
const error = await client.expectToolCallError('bad-tool', {});
await client.disconnect();Key Features:
- Automatic process lifecycle management
- Request/response matching with timeouts
- Server notification handling
- Comprehensive error handling
- TypeScript-first with full type safety
Readable, expressive assertions for MCP-specific testing.
import { installMCPMatchers } from 'mcp-dev-kit/matchers';
installMCPMatchers();Available Matchers:
// Tool assertions
await expect(client).toHaveTool('echo');
const tools = await client.listTools();
expect(tools[0]).toHaveToolProperty('description', 'Echoes back the message');
expect(tools[0]).toMatchToolSchema({
type: 'object',
required: ['message']
});
// Resource assertions
await expect(client).toHaveResource('config://app.json');
const resources = await client.listResources();
expect(resources[0]).toHaveProperty('uri', 'config://app.json');
// Prompt assertions
await expect(client).toHavePrompt('greeting');
const prompts = await client.listPrompts();
expect(prompts[0]).toHaveProperty('name', 'greeting');
// Tool result assertions
const result = await client.callTool('echo', { message: 'test' });
await expect(result).toReturnToolResult('test');
await expect(client.callTool('unknown', {})).toThrowToolError();Benefits:
- Clear, self-documenting test code
- Better error messages when tests fail
- Reduces boilerplate in test files
- Type-safe with TypeScript
MCP-aware snapshot testing with intelligent field exclusion.
MCP server responses often contain dynamic data that changes on every run:
- Timestamps (
2024-11-03T10:30:00.000Z) - Request IDs (
abc123) - Execution times (
42.5ms) - Auto-increment IDs, file inodes, git SHAs
Regular snapshot testing would fail on every run. mcp-dev-kit automatically excludes these fields while capturing the stable response structure.
import { installMCPMatchers } from 'mcp-dev-kit/matchers';
installMCPMatchers();
describe('File System Server', () => {
it('should return consistent file listing structure', async () => {
const result = await client.callTool('list_files', { path: '/project' });
// Timestamps, IDs, and dynamic fields automatically excluded!
expect(result).toMatchToolResponseSnapshot();
});
it('should have stable tool definitions', async () => {
const tools = await client.listTools();
// Captures tool schemas for regression detection
expect(tools).toMatchToolListSnapshot();
});
it('should snapshot custom data structures', async () => {
const data = {
users: [...],
timestamp: new Date().toISOString(), // Auto-excluded
requestId: 'abc123', // Auto-excluded
};
expect(data).toMatchMCPSnapshot();
});
});toMatchMCPSnapshot(options?) - Generic snapshot matcher for any MCP data
expect(serverResponse).toMatchMCPSnapshot();
expect(data).toMatchMCPSnapshot({ exclude: ['user.id', 'files.*.size'] });toMatchToolResponseSnapshot(options?) - For tool call results
const result = await client.callTool('query_database', { query: 'SELECT * FROM orders' });
expect(result).toMatchToolResponseSnapshot();toMatchToolListSnapshot(options?) - For tool definitions
const tools = await client.listTools();
expect(tools).toMatchToolListSnapshot();toMatchResourceListSnapshot(options?) - For resource listings
const resources = await client.listResources();
expect(resources).toMatchResourceListSnapshot();toMatchPromptListSnapshot(options?) - For prompt definitions
const prompts = await client.listPrompts();
expect(prompts).toMatchPromptListSnapshot();These fields are automatically excluded from all snapshots:
timestamprequestIdexecutionTimecacheKey_meta.timestampserverInfo.startedAtserverInfo.uptime
Example:
// Original response
{
"users": [...],
"timestamp": "2024-11-03T10:30:00.000Z", // ❌ Excluded
"requestId": "abc123", // ❌ Excluded
"executionTime": 42.5 // ❌ Excluded
}
// Snapshot (only stable data)
{
"users": [...] // ✅ Captured
}Exclude additional fields using glob patterns:
// Exclude file system-specific fields
expect(result).toMatchToolResponseSnapshot({
exclude: ['files.*.size', 'files.*.inode', 'files.*.modified']
});
// Exclude all auto-increment IDs
expect(data).toMatchMCPSnapshot({
exclude: ['*.id', '*.userId', 'rows.*.orderId']
});
// Exclude nested timestamps with custom names
expect(response).toMatchMCPSnapshot({
exclude: ['data.users.*.createdAt', 'metadata.generatedAt']
});Pattern Syntax:
field- Excludes top-level fieldnested.field- Excludes nested fieldarray.*.field- Excludes field from all array itemsdata.users.*.createdAt- ExcludescreatedAtfrom all users indata.users
Snapshot testing with property exclusion adds negligible overhead:
| Data Size | Normalization Overhead | Notes |
|---|---|---|
| 10-100 items | < 50 microseconds | Typical MCP responses |
| 1000 items | < 50 microseconds | Large responses |
| 5000 items | < 50 microseconds | Extra-large responses |
| Deep nesting (10+ levels) | ~1-2 milliseconds | Rare in practice |
Performance Characteristics:
- Scales well with data SIZE - More items ≈ similar overhead
- Degrades with NESTING DEPTH - Deeper structures = slower
- Production-ready - < 1% overhead for typical MCP responses
Note on Benchmarks: Our benchmarks measure JIT-optimized code after warmup. Real-world "cold start" performance may vary slightly. All measurements exclude snapshot file I/O (handled by Vitest).
Feedback Welcome! I'm actively testing this feature and would love your feedback! If you experience performance issues or have suggestions, please open an issue on GitHub.
When you intentionally change your server's response format:
# Review what changed
npm test
# Update snapshots after verifying changes are correct
npm test -- -u✅ DO:
- Snapshot server response structure to catch regressions
- Use smart defaults for common dynamic fields
- Snapshot small-to-medium datasets (10-1000 items)
- Combine snapshots with explicit assertions for critical properties
- Review snapshot diffs before accepting changes
- Split large responses into focused, smaller snapshots
❌ DON'T:
- Snapshot without excluding dynamic fields (timestamps, IDs, etc.)
- Create multi-megabyte snapshots (split into smaller tests instead)
- Snapshot implementation details that may change frequently
- Blindly update snapshots with
-uflag without reviewing - Use snapshots as a replacement for explicit assertions
Example: Combined Approach
it('should return valid user list', async () => {
const result = await client.callTool('list_users', {});
const parsed = JSON.parse(result.content[0]?.text || '{}');
// Explicit assertions for critical properties
expect(parsed.users).toHaveLength(50);
expect(parsed.users[0]).toHaveProperty('name');
expect(parsed.users[0]).toHaveProperty('email');
// Snapshot for structure regression detection
expect(result).toMatchToolResponseSnapshot();
});See examples/snapshot-example/ for complete working examples with benchmarks.
Safe debug logging that doesn't break JSON-RPC stdio communication.
MCP servers communicate via JSON-RPC over stdio. Every message must be a single line of JSON on stdout:
{"jsonrpc":"2.0","method":"tools/list","params":{...}}\n
If you write anything else to stdout (like console.log()), it corrupts the stream:
Server starting... ← Breaks protocol!
{"jsonrpc":"2.0","method":"tools/list","params":{...}}\n
Result: SyntaxError: Unexpected token 'S'
mcp-dev-kit redirects all console output to stderr, keeping stdout clean:
- stdout = pure JSON-RPC (protocol)
- stderr = all your logs (debugging)
// At the top of your MCP server file
import 'mcp-dev-kit/logger';
// Now console.log works without breaking JSON-RPC!
console.log('Server started', { port: 3000 });
console.info('Configuration loaded');
console.warn('Deprecated feature used');
console.error('Connection failed', error);Opt-out:
MCP_DEV_KIT_NO_AUTO_PATCH=true node server.jsFor more control, create a custom logger instance:
import { createLogger } from 'mcp-dev-kit';
const logger = createLogger({
timestamps: true,
colors: true,
level: 'info', // Only show info and above
logFile: './server.log', // Optional file output
});
logger.info('Server starting...');
logger.warn('Configuration may need updating');
logger.error('Connection failed', { reason: 'timeout' });
// Cleanup when done
await logger.close();- Auto-patching - Just import and console.log works
- Colored output - Color-coded log levels (auto-detects TTY)
- Timestamps - ISO8601 timestamps on all logs
- Object formatting - Pretty-print objects with
util.inspect() - File logging - Optional async file output
- Cleanup - Graceful restoration of original console
- Zero overhead - Lightweight, uses picocolors (7 KB)
Log Levels:
const logger = createLogger({ level: 'warn' });
logger.debug('Not shown');
logger.info('Not shown');
logger.warn('Shown'); // ✓
logger.error('Shown'); // ✓Colors:
createLogger({ colors: false }); // Force disable
createLogger({ colors: true }); // Force enable
// Auto-detected by default based on process.stderr.isTTYTimestamps:
createLogger({ timestamps: false }); // Disable
// ISO8601 format: 2024-11-03T12:34:56.789ZFile Logging:
const logger = createLogger({
logFile: './server.log',
});
logger.info('This goes to both stderr and server.log');
// Flush pending writes
await logger.close();Organize your tests by MCP capabilities:
describe('My MCP Server', () => {
let client: MCPTestClient;
beforeAll(async () => {
client = new MCPTestClient({ command: 'node', args: ['./server.js'] });
await client.connect();
});
afterAll(async () => {
await client.disconnect();
});
describe('Server Initialization', () => {
it('should expose correct server info', () => {
const info = client.getServerInfo();
expect(info.name).toBe('my-server');
expect(info.version).toBe('1.0.0');
});
it('should declare required capabilities', () => {
const caps = client.getServerCapabilities();
expect(caps.tools).toBeDefined();
});
});
describe('Tools', () => {
it('should list all available tools', async () => {
const tools = await client.listTools();
expect(tools).toHaveLength(3);
expect(tools.map(t => t.name)).toEqual(['echo', 'calculate', 'search']);
});
it('should execute tools successfully', async () => {
const result = await client.callTool('echo', { message: 'test' });
expect(result.content[0]?.text).toBe('test');
});
it('should handle tool errors gracefully', async () => {
const error = await client.expectToolCallError('calculate', { invalid: 'params' });
expect(error.message).toContain('Invalid parameters');
});
it('should have stable tool schemas', async () => {
const tools = await client.listTools();
expect(tools).toMatchToolListSnapshot();
});
});
describe('Resources', () => {
it('should list available resources', async () => {
await expect(client).toHaveResource('config://app.json');
});
it('should read resource content', async () => {
const content = await client.readResource('config://app.json');
expect(content.contents[0]?.text).toContain('version');
});
});
describe('Prompts', () => {
it('should provide defined prompts', async () => {
await expect(client).toHavePrompt('greeting');
});
it('should render prompts with arguments', async () => {
const prompt = await client.getPrompt('greeting', { name: 'Alice' });
expect(prompt.messages[0]?.content.text).toContain('Alice');
});
});
});✅ DO:
- Test all MCP capabilities - Tools, resources, prompts
- Use descriptive test names - Clearly state what's being tested
- Combine matchers and snapshots - Explicit assertions + structure validation
- Test error cases - Don't just test happy paths
- Clean up resources - Always disconnect client in
afterAll - Use timeouts appropriately - Set reasonable timeouts for slow operations
- Test server lifecycle - Test initialization and shutdown
❌ DON'T:
- Don't share client state - Each test suite should have its own client
- Don't skip error testing - Error handling is critical
- Don't test implementation details - Test public API only
- Don't create flaky tests - Avoid timing-dependent assertions
- Don't ignore snapshots - Review snapshot changes carefully
- Don't hardcode system-specific paths - Use relative paths or env vars
Always test error conditions:
it('should validate tool parameters', async () => {
const error = await client.expectToolCallError('calculate', {
// Missing required parameter
});
expect(error.code).toBe(-32602); // Invalid params
expect(error.message).toContain('Required parameter');
});
it('should handle resource not found', async () => {
await expect(
client.readResource('nonexistent://resource')
).rejects.toThrow('Resource not found');
});
it('should reject unknown tools', async () => {
await expect(
client.callTool('unknown-tool', {})
).rejects.toThrow();
});Test response times for critical operations:
it('should respond quickly to tool calls', async () => {
const start = Date.now();
await client.callTool('quick-operation', {});
const duration = Date.now() - start;
expect(duration).toBeLessThan(1000); // < 1 second
});Test real-world workflows:
it('should handle complete user workflow', async () => {
// 1. List available tools
const tools = await client.listTools();
expect(tools.length).toBeGreaterThan(0);
// 2. Get resource for context
const config = await client.readResource('config://app.json');
const settings = JSON.parse(config.contents[0]?.text || '{}');
// 3. Execute tool with context
const result = await client.callTool('process', {
mode: settings.defaultMode,
});
expect(result.content[0]?.text).toBeTruthy();
// 4. Verify result structure
expect(result).toMatchToolResponseSnapshot();
});See examples/ directory for complete examples:
- snapshot-example/ - Complete snapshot testing with benchmarks
- logger/basic-usage.ts - Auto-patch console
- logger/manual-setup.ts - Custom logger instance
- logger/file-logging.ts - Log to file
- logger/mcp-server-example.ts - Real MCP server
Run examples:
npm install -g tsx
tsx examples/logger/basic-usage.tsclass MCPTestClient {
constructor(options: {
command: string;
args?: string[];
env?: Record<string, string>;
timeout?: number;
});
// Connection management
connect(): Promise<void>;
disconnect(): Promise<void>;
// Server info
getServerInfo(): ServerInfo;
getServerCapabilities(): ServerCapabilities;
// Tools
listTools(): Promise<Tool[]>;
callTool(name: string, args: unknown): Promise<CallToolResult>;
expectToolCallSuccess(name: string, args: unknown): Promise<string>;
expectToolCallError(name: string, args: unknown): Promise<Error>;
// Resources
listResources(): Promise<Resource[]>;
readResource(uri: string): Promise<ReadResourceResult>;
// Prompts
listPrompts(): Promise<Prompt[]>;
getPrompt(name: string, args?: unknown): Promise<GetPromptResult>;
}interface LoggerOptions {
enabled?: boolean; // Enable/disable logger (default: true)
timestamps?: boolean; // Show timestamps (default: true)
colors?: boolean; // Force colors on/off (default: auto-detect)
level?: 'debug'|'info'|'warn'|'error'; // Min level (default: 'debug')
stream?: WritableStream; // Custom output (default: process.stderr)
logFile?: string; // Optional file output
}
function createLogger(options?: LoggerOptions): DebugLogger;
function patchConsole(options?: LoggerOptions): void;
function unpatchConsole(): void;// Installation
function installMCPMatchers(): void;
// Tool matchers
expect(client).toHaveTool(name: string);
expect(tool).toHaveToolProperty(property: string, value?: any);
expect(tool).toMatchToolSchema(schema: object);
expect(result).toReturnToolResult(expected: any);
expect(promise).toThrowToolError();
// Resource matchers
expect(client).toHaveResource(uri: string);
// Prompt matchers
expect(client).toHavePrompt(name: string);
// Snapshot matchers
expect(data).toMatchMCPSnapshot(options?: { exclude?: string[] });
expect(result).toMatchToolResponseSnapshot(options?: { exclude?: string[] });
expect(tools).toMatchToolListSnapshot(options?: { exclude?: string[] });
expect(resources).toMatchResourceListSnapshot(options?: { exclude?: string[] });
expect(prompts).toMatchPromptListSnapshot(options?: { exclude?: string[] });Increase the timeout:
const client = new MCPTestClient({
command: 'node',
args: ['./server.js'],
timeout: 60000, // 60 seconds
});Check if you're excluding enough dynamic fields:
expect(result).toMatchToolResponseSnapshot({
exclude: [
'timestamp',
'requestId',
'files.*.modified',
'data.*.generatedAt',
]
});Check your log level:
createLogger({ level: 'debug' }); // Show everythingColors only work when stderr is a TTY. Force enable/disable:
createLogger({ colors: true }); // Always color
createLogger({ colors: false }); // Never colorVerify your server is using stdio transport and responding to initialize:
// Server must respond to initialize request
server.setRequestHandler(InitializeRequestSchema, async (request) => {
return {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'my-server', version: '1.0.0' },
};
});- Node.js >= 18.0.0
- TypeScript >= 5.0.0 (if using TypeScript)
- Vitest >= 1.0.0 (for testing features)
See CONTRIBUTING.md for development setup and guidelines.
MIT © gnana997
- @modelcontextprotocol/sdk - Official MCP SDK
- Model Context Protocol - Protocol specification
- node-stdio-jsonrpc - JSON-RPC 2.0 over stdio
Built with ❤️ for the MCP community
Found this useful? Star it on GitHub ⭐