Guide · Testing

MCP server parallel testing

MCP server tests are unusually easy to parallelize because InMemoryTransport creates isolated in-process connections with no shared network state. Two test files can each run their own Server and Client instances simultaneously without port conflicts, without race conditions on a shared test database, and without any coordination overhead. Vitest parallelizes test files by default across worker threads — but there are patterns to make the most of this, and pitfalls around module-level shared state that can break parallelism silently.

TL;DR

Vitest runs test files in parallel workers by default. MCP tests using InMemoryTransport are safe to parallelize because each test creates its own transport pair and server instance. Avoid module-level singletons (a single shared Map used as a fake database, a module-level Server instance) — they leak state between parallel workers in the same file. For CI, use --shard=N/M to split a large suite across multiple GitHub Actions jobs. For development, run vitest --watch and use it.concurrent within a file to maximize feedback speed.

Why MCP tests parallelize well

Most test parallelization problems come from shared resources: tests fight over a database, a port number, a global cache, or a file. MCP tests using InMemoryTransport sidestep all of these:

Shared resource concernWith InMemoryTransport
Port conflicts (two servers on port 3000)No port — transport is in-process
Shared database stateEach test creates a new fake database instance
Network saturationNo network — all messages route in memory
Process startup timeNo process — server is a JS object created synchronously
TLS certificateNo TLS — transport skips the HTTP layer entirely

The main source of parallelism bugs in MCP tests is module-level state — a const db = createFakeDb() at the top of a test file, shared across all tests in that file. Tests in the same file run in the same worker and share the module scope.

Vitest worker configuration

Vitest's default behavior runs each test file in a separate worker thread in parallel. Tests within a file run sequentially by default (unless marked it.concurrent). This is the right default for most MCP test suites: files are isolated, tests within a file can share a single client connection.

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

export default defineConfig({
  test: {
    // Default: 'pool' uses worker_threads — good for MCP tests
    pool: 'threads',

    // Number of parallel workers (default: number of CPU cores)
    // Reduce if tests use real external services with connection limits
    poolOptions: {
      threads: {
        maxThreads: Math.max(1, os.cpus().length - 1),
        minThreads: 1,
      },
    },

    // Randomize test file order to catch ordering dependencies early
    sequence: {
      shuffle: true,
    },

    // Timeout per test (default 5000ms — increase for acceptance tests)
    testTimeout: 10000,
  },
});

Avoiding shared state in parallel workers

Each Vitest worker imports the test file fresh. Module-level variables are isolated between files (because each file runs in its own worker) but shared within a file. The pattern to avoid:

// ❌ Shared across all tests in this file — state leaks between tests
const sharedDb = createFakeDb();
const sharedClient = await createMcpTestClient(createServer, { db: sharedDb });

describe('user tools', () => {
  it('creates user', async () => {
    await sharedClient.callToolText('create_user', { name: 'Alice', email: 'a@a.com' });
    // Alice is now in sharedDb — affects all subsequent tests
  });

  it('lists users', async () => {
    // May find Alice even if this test's intent is to start empty
    const users = await sharedClient.callToolJson('list_users', {});
    expect(users).toHaveLength(0); // ❌ Fails if create_user test ran first
  });
});

The correct pattern: create a fresh client (and fresh fake dependencies) in beforeEach for stateful tests, or use a shared client in beforeAll for read-only tests:

// ✅ Fresh state per test — safe for parallel runs
describe('user tools', () => {
  let client: McpTestClient;

  beforeEach(async () => {
    const db = createFakeDb(); // new Map per test
    client = await createMcpTestClient(createServer, { db });
  });

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

  it('creates user', async () => {
    await client.callToolText('create_user', { name: 'Alice', email: 'a@a.com' });
    // Only Alice is in this test's db
  });

  it('lists users — starts empty', async () => {
    const users = await client.callToolJson('list_users', {});
    expect(users).toHaveLength(0); // ✅ Always passes — fresh db
  });
});

Concurrent tool calls within a test

Within a single test, you can run concurrent tool calls to test how the server handles parallel requests. InMemoryTransport handles concurrent calls from a single client — the MCP protocol multiplexes them via request IDs.

it('handles 10 concurrent get_user calls without losing responses', async () => {
  // Seed the fake db with 10 users
  const userIds = await Promise.all(
    Array.from({ length: 10 }, (_, i) =>
      client.callToolJson<User>('create_user', {
        name: `User ${i}`,
        email: `user${i}@example.com`,
      })
    )
  );

  // Fetch all 10 users concurrently
  const results = await Promise.all(
    userIds.map(u => client.callToolJson<User>('get_user', { userId: u.id }))
  );

  // All 10 responses arrived and are correct
  expect(results).toHaveLength(10);
  results.forEach((user, i) => {
    expect(user.email).toBe(`user${i}@example.com`);
  });
});

This test also serves as a concurrency regression guard — if your server maintains any shared mutable state (a request counter, a session Map without proper locking), concurrent calls will expose it.

Using it.concurrent for intra-file parallelism

By default, tests within a file run sequentially. Use it.concurrent (or describe.concurrent) for tests that are truly independent and don't share state, to run them in parallel within the same worker thread. This is most useful for read-only test suites where a shared client is safe.

describe.concurrent('read-only tool tests', () => {
  let client: McpTestClient;

  // Note: beforeAll still runs once, but tests run concurrently
  beforeAll(async () => {
    const db = createFakeDbWithFixtures(); // pre-seeded, read-only
    client = await createMcpTestClient(createServer, { db });
  });

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

  // These run in parallel within the describe block
  it('get_user returns Alice', async () => {
    const user = await client.callToolJson<User>('get_user', { userId: 'alice-id' });
    expect(user.name).toBe('Alice');
  });

  it('get_user returns Bob', async () => {
    const user = await client.callToolJson<User>('get_user', { userId: 'bob-id' });
    expect(user.name).toBe('Bob');
  });

  it('list_users returns both users', async () => {
    const users = await client.callToolJson<User[]>('list_users', {});
    expect(users).toHaveLength(2);
  });
});

Caution: describe.concurrent shares the client across concurrent tests. Only use it when tests are truly read-only — no create, update, or delete tool calls that would mutate the shared fake database.

Sharding large test suites in CI

When a test suite grows to hundreds of files and takes several minutes end-to-end, use Vitest's --shard option to split files across multiple CI jobs running in parallel. Each shard runs a subset of the files; the total time is divided by the number of shards.

# .github/workflows/test.yml
jobs:
  test:
    strategy:
      matrix:
        shard: [1, 2, 3, 4]  # 4 parallel CI jobs
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - run: npx vitest run --shard=${{ matrix.shard }}/4

Each shard automatically picks a non-overlapping subset of test files. The total CI runtime is roughly total_test_time / 4, minus startup overhead. Add a fourth step to merge coverage reports if you collect coverage per shard.

Watch mode for development

Vitest's watch mode re-runs only the affected test files when a source file changes. For MCP server development, this means editing a tool handler re-runs only the test files that import that handler — not the entire suite.

npx vitest --watch

Combine watch mode with the --reporter=verbose flag to see individual test names as they pass or fail. For tight inner-loop development, filter to a single file with --watch src/handlers/user.test.ts while you're actively working on that handler.

# Run watch mode filtered to one test file
npx vitest --watch --reporter=verbose src/handlers/user.test.ts

# Run watch mode on all tests matching a pattern
npx vitest --watch --reporter=verbose "user"

Parallel tests vs. production concurrency

Running tests in parallel builds confidence that your server handles concurrent requests without race conditions. But parallel tests are different from production traffic: tests use InMemoryTransport with a fake database; production uses real network connections, real persistent storage, and real connection pools. A test that passes under 10 parallel InMemoryTransport calls may still have a race condition in the production database query path. AliveMCP probes the live endpoint every 60 seconds and surface health metrics — response time trends and consecutive failures — that can indicate real concurrency issues in the deployed server that in-process tests can't detect.

Related questions

How many parallel workers should I use?

Start with the Vitest default (number of CPU cores). Reduce to maxThreads: 2 if your tests use real external services with low connection limits (a free-tier database, a rate-limited API sandbox). Increase shards in CI (4 or 8) rather than increasing per-worker concurrency — shards run in separate GitHub Actions jobs with separate resource allocations, so they scale better than adding threads within a single job.

Can I use --reporter=dot with parallel tests?

Yes. All reporters work with parallel test runs. The dot reporter is compact — each passing test is a single dot, failures print the full error. The verbose reporter prints each test name as it completes, which can be noisy with many parallel workers. For CI, use --reporter=default (summary at the end) or --reporter=github-actions (inline PR annotations on failures).

Should I use forks instead of threads pool?

Vitest's forks pool runs test files in child processes instead of worker threads. Child processes have complete module isolation (no shared module cache) at the cost of higher startup time and memory. Use forks if your server imports modules with global side effects (initializing a connection pool at module scope, writing to a global registry) that persist across tests when run in the same worker thread. For most MCP servers using the DI pattern, threads is faster and sufficient.

How do I debug a test that only fails in parallel?

Add --pool=forks --poolOptions.forks.singleFork=true to run all tests sequentially in a single process — this forces serialized execution and often reproduces the failure only if it's a true race condition (not a module-isolation issue). Then add --reporter=verbose to see the test execution order. If the test passes sequentially but fails in parallel, look for module-level state: a shared Map, a module-scope counter, a singleton that's not reset between tests.

Further reading