Guide · Testing
MCP server Vitest
The @modelcontextprotocol/sdk is published as ESM — it uses import syntax and ships .js extension imports that browsers and Node.js native ESM resolve correctly. Jest was designed for CommonJS and requires ts-jest or babel-jest plus transformIgnorePatterns surgery to handle ESM packages — a common source of "cannot use import statement outside a module" errors when setting up MCP server tests. Vitest is built on Vite, natively understands TypeScript and ESM without additional transforms, and resolves MCP SDK imports out of the box. This guide covers Vitest configuration for MCP servers, the vi.mock() API for replacing external dependencies, coverage with @vitest/coverage-v8, and running tests in CI.
TL;DR
Install vitest and @vitest/coverage-v8. Create vitest.config.ts with test.environment: 'node'. Use vi.mock('./my-module.js') at the top of test files to replace imports. Run vitest run --coverage in CI. No ts-jest, no transformIgnorePatterns, no Babel config needed.
Vitest vs. Jest for MCP server projects
| Concern | Vitest | Jest |
|---|---|---|
| ESM packages (MCP SDK) | Works natively | Requires transformIgnorePatterns + ts-jest |
| TypeScript source files | Works natively via esbuild | Requires ts-jest or @babel/preset-typescript |
| Node.js APIs (InMemoryTransport) | test.environment: 'node' | testEnvironment: 'node' |
| Module mocking | vi.mock() — same API as Jest | jest.mock() |
| Coverage | @vitest/coverage-v8 (C8, zero config) | --coverage via Istanbul |
| Watch mode | vitest (default watch in dev) | jest --watch |
| Parallel tests | Worker threads (default) | Worker threads (--experimental-vm-modules) |
| Ecosystem | Vite-native; shares vite.config | Independent; separate config |
For projects that already use Vite on the frontend, Vitest shares the same config file. For Node-only MCP servers with no frontend, Vitest is still simpler because vitest.config.ts requires fewer entries than the equivalent jest.config.ts for an ESM + TypeScript project.
Installation
npm install --save-dev vitest @vitest/coverage-v8
Add test scripts to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
vitest (without run) launches in watch mode, re-running affected tests on file changes. vitest run exits after a single pass — use this in CI.
vitest.config.ts for MCP servers
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Node environment — MCP servers are not browser code
environment: 'node',
// Show detailed output for each test in CI
reporters: process.env.CI ? ['verbose'] : ['default'],
// Coverage configuration
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
// Only measure coverage over application source, not generated or test files
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
// Fail CI if coverage drops below thresholds
thresholds: {
lines: 80,
branches: 70,
functions: 80,
},
},
// Global test timeout — MCP handshake over InMemoryTransport is fast,
// but real integration tests against a started HTTP server may need longer
testTimeout: 10_000,
},
});
The include and exclude patterns under coverage control which files appear in the coverage report. Without include, Vitest only reports coverage for files that were imported by a test — files that have no tests at all are silently excluded. Setting include: ['src/**/*.ts'] ensures uncovered files appear with 0% coverage rather than being hidden.
Test file structure for MCP servers
// src/server.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from './server.js';
// vi.mock replaces the module before any imports in the test file run.
// The factory function returning mock implementations is hoisted by Vitest.
vi.mock('./weather-client.js', () => ({
fetchWeather: vi.fn().mockResolvedValue({ temp: 15, condition: 'cloudy' }),
}));
describe('get_weather tool', () => {
let client: Client;
beforeEach(async () => {
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
const server = createServer(); // uses the mocked weather client
await server.connect(serverTransport);
client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: {} });
await client.connect(clientTransport);
});
afterEach(async () => {
await client.close();
vi.clearAllMocks(); // reset call counts between tests
});
it('returns formatted weather', async () => {
const result = await client.callTool({ name: 'get_weather', arguments: { city: 'London' } });
expect(result.isError).toBeFalsy();
expect((result.content[0] as { type: string; text: string }).text).toMatch(/15°C/);
});
});
Mocking module-level imports with vi.mock()
When a tool handler imports an HTTP client or database at the module level, vi.mock() replaces that import before the test file runs. The mock factory function is hoisted to the top of the file automatically.
// Full mock replacement — all exports become vi.fn()
vi.mock('./db.js', () => ({
getUser: vi.fn(),
createUser: vi.fn(),
}));
// Partial mock — keep real implementations, override specific methods
vi.mock('./db.js', async (importOriginal) => {
const real = await importOriginal<typeof import('./db.js')>();
return {
...real,
getUser: vi.fn().mockResolvedValue({ id: 'u1', name: 'Alice' }),
};
});
// Access the mock in tests to assert call counts and arguments
import { getUser } from './db.js';
it('calls getUser with the correct ID', async () => {
await client.callTool({ name: 'get_user', arguments: { id: 'u1' } });
expect(getUser).toHaveBeenCalledWith('u1');
expect(getUser).toHaveBeenCalledTimes(1);
});
For external HTTP APIs, prefer Mock Service Worker (msw) over vi.mock() on the HTTP client directly. msw intercepts at the network layer and works with any HTTP client, while vi.mock() is coupled to the specific import path used in the source file.
Testing async tool handlers
All MCP tool calls are async — client.callTool() returns a Promise. Vitest natively handles async test functions; no extra setup is needed beyond marking the test function async.
it('returns isError when the upstream API times out', async () => {
// Override the mock to simulate a timeout
const { fetchWeather } = await import('./weather-client.js');
vi.mocked(fetchWeather).mockRejectedValueOnce(new Error('Request timeout'));
const result = await client.callTool({ name: 'get_weather', arguments: { city: 'London' } });
// A well-implemented handler returns isError: true rather than throwing
expect(result.isError).toBe(true);
expect((result.content[0] as { type: string; text: string }).text).toContain('timeout');
});
Vitest's default test timeout is 5 seconds. MCP tests with InMemoryTransport complete in microseconds, so the default is fine. For integration tests that start a real HTTP server, set testTimeout: 30_000 in the test file or globally in vitest.config.ts.
Running in CI
# .github/workflows/test.yml (relevant excerpt)
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
The @vitest/coverage-v8 reporter writes coverage/lcov.info, which most CI platforms accept for coverage tracking. If coverage drops below the thresholds in vitest.config.ts, Vitest exits with a non-zero code and fails the CI step. See MCP server test coverage for guidance on setting useful thresholds.
Related questions
Can I use Vitest with a monorepo that has multiple MCP servers?
Yes — use Vitest workspaces. Create a vitest.workspace.ts at the repo root that points to each package's config: export default ['packages/*/vitest.config.ts']. Each package runs its own tests in isolation, but a single vitest run --workspace command runs all of them. Coverage is aggregated across packages if you configure coverage.all: true in the root config.
Does Vitest support snapshot testing?
Yes. expect(value).toMatchSnapshot() and expect(value).toMatchInlineSnapshot() work identically to Jest. For MCP servers, tool schema snapshots are useful: calling client.listTools() and snapshotting the result catches unintentional schema changes between commits. Run vitest run --update-snapshots to update snapshots after intentional changes.
How do I test MCP servers that use worker threads?
Vitest runs tests in worker threads by default. If your MCP server code also creates worker threads (e.g., for CPU-intensive tools), set test.pool: 'forks' in vitest.config.ts to run tests in child processes instead. This avoids worker-thread nesting issues and gives each test file a clean Node.js process.
I'm getting "ERR_REQUIRE_ESM" when importing the MCP SDK in Jest. Should I switch to Vitest?
That error means Jest is trying to load an ESM file with require(). The fix requires adding @modelcontextprotocol/sdk to transformIgnorePatterns and configuring ts-jest with useESM: true. It works but adds configuration complexity. Switching to Vitest eliminates the issue entirely — the SDK's ESM imports resolve without any transform configuration.
Further reading
- MCP server unit testing — InMemoryTransport patterns this config enables
- MCP server mocking — vi.mock() patterns for external dependencies
- MCP server test coverage — coverage thresholds and reporting
- MCP Inspector — interactive debugging companion to automated tests
- MCP server CI/CD — running Vitest in GitHub Actions pipelines
- MCP server TypeScript — TypeScript configuration this test setup targets
- MCP server integration testing — extending Vitest tests to real infrastructure
- AliveMCP — production monitoring for your tested and deployed MCP server