Introduction
MCP servers are just Node.js programs, which means you can test them with familiar tools like Vitest or Jest. The key insight is to separate your tool logic from the MCP registration code so that business logic is unit-testable without running the full server.
Key Concepts
- Unit testing: Testing extracted handler functions in isolation, without the MCP server running.
- Integration testing: Using the MCP Inspector to send real tool calls and verify responses end-to-end.
- MCP Inspector: An interactive web UI for testing MCP servers — it connects to your server and lets you call tools, read resources, and test prompts.
- Mocking: Replacing external dependencies (databases, APIs, filesystems) with controlled substitutes during tests.
Real World Context
You build a tool that queries a database and formats the results. Before deploying it, you need confidence that the formatting logic handles edge cases (empty results, null values, very long strings). You also need to verify the tool integrates correctly with the MCP protocol. Unit tests cover the logic; the Inspector covers the integration.
Deep Dive
Extracting Logic for Unit Testing
The most important pattern for testable MCP servers is separating handler logic from tool registration. Instead of putting all your logic inside the handler callback, extract it into standalone functions:
typescript// Extract logic for unit testing async function calculateBmi(weightKg: number, heightM: number) { return weightKg / (heightM * heightM); } // Unit test test('calculateBmi returns correct value', () => { expect(calculateBmi(70, 1.75)).toBeCloseTo(22.86, 1); }); // Register in server server.registerTool('calculate-bmi', { description: 'Calculate Body Mass Index from weight and height', inputSchema: z.object({ weightKg: z.number(), heightM: z.number() }) }, async ({ weightKg, heightM }) => { const bmi = await calculateBmi(weightKg, heightM); return { content: [{ type: 'text', text: `BMI: ${bmi.toFixed(2)}` }] }; });
The calculateBmi function is a pure function that takes numbers and returns a number. You can test it with any testing framework without touching the MCP server.
Testing Edge Cases
Once your logic is extracted, write tests for boundary conditions:
typescriptdescribe('calculateBmi', () => { test('returns correct BMI for normal weight', () => { expect(calculateBmi(70, 1.75)).toBeCloseTo(22.86, 1); }); test('handles very low height', () => { expect(calculateBmi(70, 0.5)).toBeCloseTo(280, 0); }); test('handles zero height gracefully', () => { expect(calculateBmi(70, 0)).toBe(Infinity); }); });
These tests reveal that your function returns Infinity for zero height. You might want to add a guard in the handler to return an isError result for invalid inputs.
Integration Testing with MCP Inspector
The MCP Inspector is a web-based tool that connects to your server and lets you interact with it visually. Launch it with a single command:
bashnpx @modelcontextprotocol/inspector node server.js
The Inspector opens a web UI where you can browse your server's tools, resources, and prompts. You can send tool calls with custom parameters, inspect the JSON-RPC messages, and verify that error handling works as expected.
The Inspector is invaluable for testing scenarios that are hard to reproduce in unit tests, such as verifying that error messages are formatted correctly for LLM consumption or checking that resource URIs resolve properly.
Mocking External Dependencies
For tools that depend on databases, APIs, or the filesystem, use dependency injection to swap in mocks during testing:
typescript// Production: real database async function searchUsers(query: string, db: Database) { return db.query('SELECT * FROM users WHERE name LIKE ?', [`%${query}%`]); } // Test: mock database test('searchUsers returns matching results', async () => { const mockDb = { query: vi.fn().mockResolvedValue([ { id: 1, name: 'Alice' }, { id: 2, name: 'Alicia' } ]) }; const results = await searchUsers('Ali', mockDb as any); expect(results).toHaveLength(2); expect(mockDb.query).toHaveBeenCalledWith( 'SELECT * FROM users WHERE name LIKE ?', ['%Ali%'] ); });
By passing the database as a parameter, you can test the logic without a real database connection.
Testing Error Paths
Do not forget to test what happens when things go wrong:
typescripttest('returns isError when file does not exist', async () => { const result = await readFileTool('/nonexistent/path.txt'); expect(result.isError).toBe(true); expect(result.content[0].text).toContain('not found'); }); test('returns isError when API times out', async () => { vi.useFakeTimers(); const resultPromise = fetchDataTool('https://slow-api.example.com'); vi.advanceTimersByTime(11000); const result = await resultPromise; expect(result.isError).toBe(true); expect(result.content[0].text).toContain('timed out'); });
Error paths are often where bugs hide. Testing them ensures your server responds gracefully to every failure mode.
Common Pitfalls
- Testing the MCP handler directly — Handler callbacks are tightly coupled to the SDK. Extract your logic into standalone functions and test those instead.
- Skipping error path tests — Happy-path tests are not enough. Test what happens with invalid input, network failures, and empty results.
- Not using the Inspector before deployment — Unit tests verify logic, but they do not catch protocol-level issues. Always test with the Inspector to verify the full integration.
Best Practices
- Extract, do not embed — Keep handler callbacks thin. All business logic should live in standalone, testable functions.
- Test error messages for LLM readability — Your error messages are consumed by an AI model. Verify they contain enough context for the model to self-correct.
- Use the Inspector as your integration test suite — Run through every tool manually before deploying a new version.
Summary
- Extract handler logic into standalone functions for easy unit testing.
- Use the MCP Inspector (
npx @modelcontextprotocol/inspector) for interactive integration testing. - Mock external dependencies with dependency injection.
- Test error paths as thoroughly as happy paths.
- The Inspector catches protocol-level issues that unit tests miss.
Code Examples
// Extract logic for unit testing
async function calculateBmi(weightKg: number, heightM: number) {
return weightKg / (heightM * heightM);
}
// Unit test
test('calculateBmi returns correct value', () => {
expect(calculateBmi(70, 1.75)).toBeCloseTo(22.86, 1);
});
// Register in server
server.registerTool('calculate-bmi', {
description: 'Calculate Body Mass Index from weight and height',
inputSchema: z.object({ weightKg: z.number(), heightM: z.number() })
}, async ({ weightKg, heightM }) => {
const bmi = await calculateBmi(weightKg, heightM);
return { content: [{ type: 'text', text: `BMI: ${bmi.toFixed(2)}` }] };
});# Launch the MCP Inspector to test your server interactively
npx @modelcontextprotocol/inspector node server.js
# The Inspector opens a web UI where you can:
# - Browse available tools, resources, and prompts
# - Send tool calls with custom parameters
# - Inspect JSON-RPC request/response messages
# - Verify error handling behavior