Guide · Testing

MCP server test coverage

Test coverage measures which lines, branches, and functions in your source code were executed during the test suite. It does not measure whether the tests are meaningful — 100% coverage with useless assertions is possible — but it does catch gaps: a branch in a tool handler that has never been exercised is likely untested behavior. For MCP servers, the most valuable coverage target is branch coverage on tool handler logic — every early return, every error path, every conditional transformation. Startup and shutdown sequences are harder to cover fully and warrant lower thresholds. This guide covers coverage configuration with @vitest/coverage-v8, how to read the output, where to set thresholds, and what coverage cannot tell you.

TL;DR

Install @vitest/coverage-v8. Add coverage.include: ['src/**/*.ts'] in vitest.config.ts to surface files with zero tests. Set thresholds at 80% lines / 70% branches for MCP servers as a starting point. Run vitest run --coverage in CI and upload the coverage/lcov.info artifact. Coverage above 90% on tool handler files is achievable and worth targeting.

Coverage providers: V8 vs. Istanbul

Vitest supports two coverage providers. Both report line, branch, function, and statement coverage, but differ in how they instrument code.

ProviderPackageHow it worksAccuracySpeed
V8 (C8)@vitest/coverage-v8Uses Node.js's built-in V8 coverage — no source transformationVery accurate for TypeScript compiled to JS; may miss some branches in type-narrowing codeFast — no transform step
Istanbul@vitest/coverage-istanbulInstruments the source with counters via Babel transformMore accurate for complex conditional types; slowerSlower — transforms every file

For MCP servers written in TypeScript, @vitest/coverage-v8 is the right choice. It requires no extra configuration, works with the same esbuild transform that Vitest uses for tests, and is consistently faster than Istanbul. Use Istanbul if you encounter V8 inaccuracies in complex conditional logic.

npm install --save-dev @vitest/coverage-v8

Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    coverage: {
      provider: 'v8',

      // Report formats: text (terminal), html (browser), lcov (CI upload)
      reporter: ['text', 'html', 'lcov'],

      // Include ALL source files — without this, only files imported by tests
      // appear in the report; files with no tests at all are hidden
      include: ['src/**/*.ts'],

      // Exclude test files, type declaration files, and generated code
      exclude: [
        'src/**/*.test.ts',
        'src/**/*.spec.ts',
        'src/**/*.d.ts',
        'src/generated/**',
      ],

      // Fail CI if coverage drops below these thresholds.
      // The 'per-file' variant fails on any individual file that drops below.
      thresholds: {
        lines: 80,
        branches: 70,
        functions: 80,
        statements: 80,
        // Optionally enforce tighter coverage on the most important files:
        // 'src/tools/**': { branches: 90, lines: 90 },
      },

      // Include all matched files in the report even if no tests import them
      all: true,
    },
  },
});

The most important setting is all: true (or equivalently, setting include patterns). Without it, a file that no test ever imports gets a reported coverage of undefined — it disappears from the report as if it doesn't exist. A file with 0% coverage is worse than a file with 50% coverage, and hiding it is worse than showing it.

Reading the coverage output

Running vitest run --coverage prints a table to the terminal:

----------|---------|----------|---------|---------|
File      | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
src/      |   87.50 |    75.00 |   88.89 |   87.50 |
 server.ts|   91.30 |    83.33 |  100.00 |   91.30 |
 tools/   |         |          |         |         |
  weather |  100.00 |   100.00 |  100.00 |  100.00 |
  users.ts|   80.00 |    60.00 |   75.00 |   80.00 |
 db.ts    |   70.00 |    50.00 |   66.67 |   70.00 |
----------|---------|----------|---------|---------|

The columns to focus on for MCP servers:

The HTML report (coverage/index.html) shows which specific lines and branches are uncovered — open it in a browser to see which branches in users.ts are at 60%.

Coverage targets by file type

Not all MCP server code is equally testable. Setting a single global threshold at 90% causes frustration when startup and shutdown code — which requires stopping real servers and simulating signals — drags down the aggregate. Differentiate thresholds by file type.

Code areaRecommended branch coverage targetWhy
Tool handler logic (src/tools/)90%+Every conditional in a tool handler is a user-facing behavior path; all should be tested
Input validation (src/validation/)90%+Validation branches define what errors users see; cover all error cases
Database helpers (src/db/)70–80%Some paths only trigger on DB errors that require real infrastructure to reproduce
Server setup (src/server.ts)60–70%Startup errors and shutdown drain are hard to test in a unit context
Entry point (src/index.ts)20–40%The top-level main() function that binds ports and starts the server is integration-tested, not unit-tested

Vitest supports per-file or per-directory thresholds using glob patterns in vitest.config.ts. Use this to enforce higher coverage on your tool handler directory without requiring the same from startup boilerplate.

Schema snapshot testing

Coverage metrics don't catch a different category of regression: unintentional schema changes. If you rename a tool or add a required parameter, existing LLM integrations break — no test fails, but coverage remains the same. Snapshot testing fills this gap.

// src/tools.snapshot.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 } from './server.js';

describe('tool schema snapshot', () => {
  let client: Client;

  beforeEach(async () => {
    const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
    await createServer(fakeDeps).connect(serverTransport);
    client = new Client({ name: 'snapshot-client', version: '1.0.0' }, { capabilities: {} });
    await client.connect(clientTransport);
  });

  afterEach(() => client.close());

  it('tool schemas match the committed snapshot', async () => {
    const { tools } = await client.listTools();
    // Sort for stable comparison across Node.js versions
    const sorted = tools.sort((a, b) => a.name.localeCompare(b.name));
    expect(sorted).toMatchSnapshot();
  });
});

The first run creates a snapshot file. Subsequent runs compare against it. When you intentionally change a schema, run vitest run --update-snapshots and commit the updated snapshot. An unintentional change fails the test.

Coverage in CI

# .github/workflows/ci.yml
- name: Run tests with coverage
  run: npx vitest run --coverage

- name: Upload HTML coverage report
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: coverage-${{ github.sha }}
    path: coverage/
    retention-days: 30

# Optional: fail PR if coverage drops from the base branch
- name: Coverage comment on PR
  uses: davelosert/vitest-coverage-report-action@v2
  if: github.event_name == 'pull_request'
  with:
    json-summary-path: coverage/coverage-summary.json

The vitest-coverage-report-action posts a coverage diff comment on pull requests, showing which files gained or lost coverage. This is more actionable than a single threshold gate — a PR that drops coverage from 85% to 84% may be acceptable, while one that drops a single file from 100% to 60% warrants review.

What coverage cannot tell you

High coverage does not mean the server works in production. Specific failure modes that tests with 100% coverage miss:

Coverage is a necessary but not sufficient condition for a reliable MCP server. Combine it with integration tests that use real infrastructure and AliveMCP for continuous production monitoring.

Related questions

My coverage is stuck at 70% because of the SIGTERM handler. Should I lower the threshold?

Rather than lowering the global threshold, mark the signal handler with a /* c8 ignore next */ comment if it is genuinely untestable in a unit context. V8 coverage respects these comments. The same applies to any code path that requires a real process signal or OS-level behavior. Move signal handler tests to an integration test suite where you can actually send SIGTERM to a child process, and exclude them from the unit coverage report.

What's the difference between statement coverage and line coverage?

A line can contain multiple statements — for example, const x = a ? b : c; is one line but contains a ternary (two branches). Statement coverage counts each statement individually; line coverage marks a line covered if any statement on it ran. For MCP servers, branch coverage is the most useful metric because most bugs live in conditional branches, not in straightforward assignment statements. Line and statement coverage often move together and are less informative.

How do I exclude third-party code from the coverage report?

Coverage only runs over files matched by include patterns, so node_modules is excluded by default. If your project has generated files (e.g., Prisma client output in src/generated/), add them to the exclude array in vitest.config.ts. Generated code is correct by definition (the generator is responsible for it) and inflates your coverage numbers if included.

Further reading