Guide · Testing

MCP server mock client

Every MCP server integration test needs the same four lines: create a transport pair, connect the server, create a client, connect the client. Repeated across 10 test files, that's 40 lines of identical plumbing — plus 10 places to forget afterAll(() => client.close()). A mock client helper extracts this into a factory function you call once per test file or test block: it returns a connected Client instance with cleanup registered automatically, optional typed wrappers around callTool, and assertion helpers for schema validation.

TL;DR

Create a createMcpTestClient(serverFactory, deps?) function in test/helpers/mcp-client.ts. It creates a linked InMemoryTransport pair, calls serverFactory(deps), connects both ends, and returns the client. Register afterAll(() => client.close()) inside the helper. Add typed wrappers like callToolText(name, args) that call client.callTool() and extract the text content — eliminating cast noise in test assertions. Add assertSchemaIncludes(toolName, partialSchema) to make schema regression tests one line each.

The basic mock client factory

The factory returns a client with the transport already connected. It accepts a serverFactory function and optional dependencies so different test files can use different fakes without configuring the transport separately.

// test/helpers/mcp-client.ts
import { afterAll } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';

export async function createMcpTestClient<TDeps>(
  serverFactory: (deps: TDeps) => Server,
  deps: TDeps,
): Promise<Client> {
  const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
  const server = serverFactory(deps);
  await server.connect(serverTransport);

  const client = new Client(
    { name: 'mock-test-client', version: '1.0.0' },
    { capabilities: {} },
  );
  await client.connect(clientTransport);

  // Register cleanup so tests don't need to call client.close() manually
  afterAll(async () => {
    await client.close();
  });

  return client;
}

Usage in a test file:

// user-service.test.ts
import { describe, it, beforeAll, expect } from 'vitest';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { createMcpTestClient } from '../test/helpers/mcp-client.js';
import { createServer } from './server.js';
import { fakeDb } from './test/fakes.js';

describe('user-service-mcp', () => {
  let client: Client;

  beforeAll(async () => {
    client = await createMcpTestClient(createServer, { db: fakeDb });
  });

  it('lists the get_user tool', async () => {
    const { tools } = await client.listTools();
    expect(tools.map(t => t.name)).toContain('get_user');
  });

  it('returns user data', async () => {
    const result = await client.callTool({ name: 'get_user', arguments: { userId: 'user-1' } });
    expect(result.isError).toBeFalsy();
  });
});

Adding typed callTool wrappers

The raw client.callTool() returns CallToolResult with a generic content array. Test assertions that extract the text value require a type cast on every line: (result.content[0] as { text: string }).text. Typed wrappers encapsulate the cast and the array access.

// test/helpers/mcp-client.ts — extended
export class McpTestClient {
  constructor(private client: Client) {}

  async listTools() {
    return this.client.listTools();
  }

  /** Call a tool and return the first text content item, or throw on isError */
  async callToolText(name: string, args: Record<string, unknown>): Promise<string> {
    const result = await this.client.callTool({ name, arguments: args });
    if (result.isError) {
      const msg = result.content
        .filter(c => c.type === 'text')
        .map(c => (c as { text: string }).text)
        .join(' ');
      throw new Error(`Tool ${name} returned isError: ${msg}`);
    }
    const textItem = result.content.find(c => c.type === 'text') as
      | { type: 'text'; text: string }
      | undefined;
    if (!textItem) throw new Error(`Tool ${name} returned no text content`);
    return textItem.text;
  }

  /** Call a tool and return isError=true result, asserting it failed */
  async callToolExpectError(name: string, args: Record<string, unknown>): Promise<string> {
    const result = await this.client.callTool({ name, arguments: args });
    if (!result.isError) throw new Error(`Expected tool ${name} to return isError but it succeeded`);
    return result.content
      .filter(c => c.type === 'text')
      .map(c => (c as { text: string }).text)
      .join(' ');
  }

  /** Parse the first text content item as JSON */
  async callToolJson<T = unknown>(name: string, args: Record<string, unknown>): Promise<T> {
    const text = await this.callToolText(name, args);
    return JSON.parse(text) as T;
  }

  close() {
    return this.client.close();
  }
}

export async function createMcpTestClient<TDeps>(
  serverFactory: (deps: TDeps) => Server,
  deps: TDeps,
): Promise<McpTestClient> {
  const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
  const server = serverFactory(deps);
  await server.connect(serverTransport);

  const rawClient = new Client(
    { name: 'mock-test-client', version: '1.0.0' },
    { capabilities: {} },
  );
  await rawClient.connect(clientTransport);

  const client = new McpTestClient(rawClient);
  afterAll(async () => client.close());
  return client;
}

With this wrapper, tests become readable without casts:

it('returns parsed user JSON', async () => {
  const user = await client.callToolJson<{ name: string }>(
    'get_user', { userId: 'user-1' }
  );
  expect(user.name).toBe('Alice');
});

it('returns error message for missing user', async () => {
  const msg = await client.callToolExpectError('get_user', { userId: 'not-found' });
  expect(msg).toMatch(/No user found/);
});

Schema assertion helper

Tool inputSchema regressions — removing a required field, changing a type — are a common source of breaking changes in MCP servers. A schema assertion helper makes the regression test a single line and produces a clear error when the schema drifts.

// test/helpers/mcp-client.ts — schema assertions
import { z } from 'zod';

export async function assertToolSchema(
  client: McpTestClient,
  toolName: string,
  expectedSchema: Record<string, unknown>,
): Promise<void> {
  const { tools } = await client.listTools();
  const tool = tools.find(t => t.name === toolName);
  if (!tool) throw new Error(`Tool '${toolName}' not found in listTools response`);

  // Deep partial match — expected schema is a subset of actual schema
  expect(tool.inputSchema).toMatchObject(expectedSchema);
}

export async function assertToolList(
  client: McpTestClient,
  expectedNames: string[],
): Promise<void> {
  const { tools } = await client.listTools();
  const actualNames = tools.map(t => t.name).sort();
  expect(actualNames).toEqual([...expectedNames].sort());
}

Usage:

it('get_user schema has required userId string field', async () => {
  await assertToolSchema(client, 'get_user', {
    type: 'object',
    properties: { userId: { type: 'string' } },
    required: ['userId'],
  });
});

it('server exposes exactly the expected tools', async () => {
  await assertToolList(client, ['get_user', 'list_users', 'delete_user']);
});

The assertToolList assertion is particularly valuable: it fails if a tool is added or removed without a corresponding update to the test. This is the most basic form of tool contract testing.

Using Zod to validate tool responses in tests

Beyond asserting the text content of a tool response, you can validate the JSON structure of complex responses using Zod. This catches bugs where a tool returns the right shape for simple inputs but drops fields for edge-case inputs.

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

it('get_user returns a complete user object', async () => {
  const text = await client.callToolText('get_user', { userId: 'user-1' });
  const parsed = UserSchema.safeParse(JSON.parse(text));
  expect(parsed.success).toBe(true);
  if (!parsed.success) {
    // Vitest will show this in the failure output
    console.error('Response schema violation:', parsed.error.format());
  }
});

Snapshot testing tool lists

Vitest's snapshot testing is a low-friction way to detect unintentional changes to your tool list or inputSchemas. On the first run, Vitest writes a snapshot file; on subsequent runs, it compares against the stored snapshot and fails if anything changed.

import { expect, it } from 'vitest';

it('tool list matches snapshot', async () => {
  const { tools } = await client.listTools();
  // Normalize — only snapshot the fields that matter
  const normalized = tools.map(({ name, description, inputSchema }) => ({
    name,
    description,
    inputSchema,
  }));
  expect(normalized).toMatchSnapshot();
});

When you intentionally add, remove, or change a tool, run vitest --update-snapshots to write the new baseline. The snapshot diff in CI serves as an audit trail for tool surface changes. This is related to MCP server snapshot testing — applying Vitest snapshots to the full tool manifest.

Per-test vs. shared client

The factory pattern works at two granularities. Shared client (one per describe block, created in beforeAll) is faster — the transport connect overhead runs once. Per-test client (created in beforeEach) gives full test isolation — tests that mutate server state (a session store, a fake database) can't interfere with each other. Choose based on whether your server has mutable state.

PatternSpeedIsolationBest for
Shared client (beforeAll)Fast — one connectTests share server stateRead-only tools, stateless servers
Per-test client (beforeEach)Slower — connect per testFull isolationStateful servers, session management tests
Parallel clients (Promise.all)FastSeparate transport pairsConcurrency testing

What a mock client tests vs. what AliveMCP monitors

A mock client running via InMemoryTransport tests the correctness of your server's MCP logic: tool registration, handler routing, input validation, response format, and error messages. It runs in-process with no network, no TLS, no HTTP. What it cannot test is whether the deployed server accepts connections from the outside world, whether your hosting platform's port is reachable, or whether the MCP endpoint passes the full initialize handshake over a real network connection. AliveMCP fills that gap — it probes the production endpoint every 60 seconds and alerts you the moment the deployed server stops responding, no code required.

Related questions

Should the mock client helper be in devDependencies or the test directory?

Put it in test/helpers/ alongside other test utilities and keep it out of the production bundle. It depends on @modelcontextprotocol/sdk (already in your dependencies) and Vitest's afterAll (only meaningful in test context). If you use TypeScript path aliases, add "@test/*": ["test/*"] so imports are clean: import { createMcpTestClient } from '@test/helpers/mcp-client.js'.

How do I test tool behavior that depends on headers or session tokens?

If your server reads headers from the request context, you'll need to pass them through the server's session mechanism. With InMemoryTransport, the client can set headers on the connect() call using clientTransport.start() options. Alternatively, pass the header values through the deps object — a deps.session = { token: 'test-token' } fake is simpler to control than HTTP header injection in tests.

Can I use the mock client helper with Jest instead of Vitest?

Yes — replace afterAll, beforeAll, and expect imports from Vitest with Jest's globals. The InMemoryTransport and Client imports are from @modelcontextprotocol/sdk and work the same in either test runner. The only difference is error message formatting — Jest and Vitest format expect().toMatchObject() failures slightly differently.

How do I mock the mock client for unit tests that call my mock client?

If you have code that calls McpTestClient.callToolJson(), you can stub the class: vi.spyOn(client, 'callToolJson').mockResolvedValue({ name: 'stub-result' }). But for MCP server tests, prefer using real InMemoryTransport over mocking the client — the entire point of the mock client is to test through the real protocol stack. Mocking the mock client replaces protocol testing with pure unit testing of your test code itself.

Further reading