Testing MCP Client Integrations: Mocking Servers, Tool Selection, and Context Injection

Testing MCP Client Integrations: Mocking Servers, Tool Selection, and Context Injection

Most MCP testing guides focus on the server side: does the server expose correct tools, does it handle errors, does it perform under load.

But if you're building an application that consumes MCP servers — an AI assistant, an agent orchestrator, a developer tool — the client side needs tests too. Your application makes decisions about which tools to call, how to inject context, and how to handle MCP responses. Those decisions can be wrong.

Here's how to test MCP client integrations without depending on a running server.

The MCP Client Testing Problem

Testing MCP clients is harder than testing servers for one reason: you don't control what comes back.

When you test an MCP server, you control the inputs and assert on the outputs. When you test an MCP client, the "server" is an external dependency — Claude, a third-party MCP server, or your own service. Making your client tests depend on a real server means:

  • Tests are slow (network round trips)
  • Tests are flaky (server availability, rate limits)
  • Tests are expensive (API calls cost money)
  • Tests are non-deterministic (AI responses vary)

The solution is mocking: replace the real MCP server with a controlled test double that returns predictable responses. Then your client tests become fast, deterministic, and cheap.

Setting Up an MCP Server Mock

The MCP SDK's Server class lets you create a minimal mock server in-process.

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import {
  ListToolsRequestSchema,
  CallToolRequestSchema
} from '@modelcontextprotocol/sdk/types.js';

function createMockServer(config: {
  tools: Array<{ name: string; description: string; inputSchema: object }>;
  handlers: Record<string, (args: any) => Promise<{ content: any[]; isError?: boolean }>>;
}) {
  const server = new Server(
    { name: 'mock-server', version: '1.0' },
    { capabilities: { tools: {} } }
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: config.tools
  }));

  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const handler = config.handlers[request.params.name];
    if (!handler) {
      return {
        content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }],
        isError: true
      };
    }
    return handler(request.params.arguments);
  });

  return server;
}

async function createTestPair(mockServer: Server) {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  await mockServer.connect(serverTransport);
  
  const client = new Client({ name: 'test-client', version: '1.0' });
  await client.connect(clientTransport);
  
  return client;
}

Now you can create a mock server for any scenario in a few lines:

const mockServer = createMockServer({
  tools: [
    {
      name: 'search',
      description: 'Search the web',
      inputSchema: {
        type: 'object',
        properties: {
          query: { type: 'string' },
          limit: { type: 'number' }
        },
        required: ['query']
      }
    }
  ],
  handlers: {
    search: async ({ query, limit = 10 }) => ({
      content: [{
        type: 'text',
        text: JSON.stringify({
          results: [
            { title: `Result for: ${query}`, url: 'https://example.com' }
          ]
        })
      }]
    })
  }
});

const client = await createTestPair(mockServer);

Testing Tool Selection Logic

If your application selects which MCP tool to call based on user intent or context, test the selection logic:

import { selectTool } from '../lib/tool-selector';

describe('tool selection', () => {
  const tools = [
    {
      name: 'search-web',
      description: 'Search the internet for current information',
      inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }
    },
    {
      name: 'read-file',
      description: 'Read a file from the local filesystem',
      inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] }
    },
    {
      name: 'run-query',
      description: 'Run a SQL query against the database',
      inputSchema: { type: 'object', properties: { sql: { type: 'string' } }, required: ['sql'] }
    }
  ];

  it('selects search tool for web queries', () => {
    const selected = selectTool('What is the latest version of Node.js?', tools);
    expect(selected?.name).toBe('search-web');
  });

  it('selects file tool for file operations', () => {
    const selected = selectTool('Read the contents of README.md', tools);
    expect(selected?.name).toBe('read-file');
  });

  it('selects database tool for data queries', () => {
    const selected = selectTool('How many users signed up last month?', tools);
    expect(selected?.name).toBe('run-query');
  });

  it('returns null when no tool matches', () => {
    const selected = selectTool('What is 2+2?', tools);
    // Simple arithmetic needs no tool
    expect(selected).toBeNull();
  });
});

If your tool selection uses an LLM, mock the LLM call:

import { vi } from 'vitest';
import * as llmModule from '../lib/llm';

describe('LLM-based tool selection', () => {
  beforeEach(() => {
    vi.spyOn(llmModule, 'callLLM').mockImplementation(async (prompt) => {
      if (prompt.includes('search')) return '{"tool": "search-web", "args": {"query": "test"}}';
      return '{"tool": null}';
    });
  });

  it('calls LLM with tool descriptions', async () => {
    await selectToolWithLLM('Search for Node.js docs', tools);
    expect(llmModule.callLLM).toHaveBeenCalledWith(
      expect.stringContaining('search-web')
    );
  });
});

Testing Context Injection

When your application injects context into MCP tool calls — user preferences, session data, environment configuration — test that the injection works correctly and that sensitive data is handled properly.

describe('context injection', () => {
  it('injects user locale into tool arguments', async () => {
    const mockServer = createMockServer({
      tools: [{ name: 'get-weather', description: '...', inputSchema: {} }],
      handlers: {
        'get-weather': async (args) => {
          // Capture what args were passed
          capturedArgs = args;
          return { content: [{ type: 'text', text: '{}' }] };
        }
      }
    });

    let capturedArgs: any;
    const client = await createTestPair(mockServer);

    const context = { userLocale: 'en-US', units: 'imperial' };
    await callToolWithContext(client, 'get-weather', { city: 'New York' }, context);

    expect(capturedArgs).toMatchObject({
      city: 'New York',
      locale: 'en-US',
      units: 'imperial'
    });
  });

  it('does not inject sensitive session data into tool args', async () => {
    const mockServer = createMockServer({
      tools: [{ name: 'search', description: '...', inputSchema: {} }],
      handlers: {
        search: async (args) => {
          capturedArgs = args;
          return { content: [{ type: 'text', text: '{}' }] };
        }
      }
    });

    let capturedArgs: any;
    const client = await createTestPair(mockServer);

    const sensitiveContext = {
      userId: 'user-123',
      sessionToken: 'secret-token', // Should NOT be injected
      userPreference: 'dark-mode'   // OK to inject
    };
    await callToolWithContext(client, 'search', { query: 'test' }, sensitiveContext);

    expect(capturedArgs).not.toHaveProperty('sessionToken');
    expect(capturedArgs.query).toBe('test');
  });
});

Testing Client Error Handling

Your client code needs to handle MCP errors gracefully — both isError: true responses and protocol-level errors.

describe('client error handling', () => {
  it('handles isError response without crashing', async () => {
    const mockServer = createMockServer({
      tools: [{ name: 'failing-tool', description: '...', inputSchema: {} }],
      handlers: {
        'failing-tool': async () => ({
          content: [{ type: 'text', text: 'Rate limit exceeded' }],
          isError: true
        })
      }
    });

    const client = await createTestPair(mockServer);

    // Your client wrapper that handles errors
    const result = await myClient.callTool(client, 'failing-tool', {});
    
    expect(result.success).toBe(false);
    expect(result.error).toContain('Rate limit exceeded');
    // Should not throw
  });

  it('retries on transient errors', async () => {
    let callCount = 0;
    
    const mockServer = createMockServer({
      tools: [{ name: 'flaky-tool', description: '...', inputSchema: {} }],
      handlers: {
        'flaky-tool': async () => {
          callCount++;
          if (callCount < 3) {
            return { content: [{ type: 'text', text: 'Server busy' }], isError: true };
          }
          return { content: [{ type: 'text', text: 'Success!' }] };
        }
      }
    });

    const client = await createTestPair(mockServer);
    const result = await myClient.callToolWithRetry(client, 'flaky-tool', {}, { maxRetries: 3 });
    
    expect(result.success).toBe(true);
    expect(callCount).toBe(3);
  });

  it('does not retry on permanent errors', async () => {
    let callCount = 0;
    
    const mockServer = createMockServer({
      tools: [{ name: 'broken-tool', description: '...', inputSchema: {} }],
      handlers: {
        'broken-tool': async () => {
          callCount++;
          return {
            content: [{ type: 'text', text: 'Invalid argument: path must be absolute' }],
            isError: true
          };
        }
      }
    });

    const client = await createTestPair(mockServer);
    const result = await myClient.callToolWithRetry(client, 'broken-tool', {}, { maxRetries: 3 });
    
    // Validation errors should not be retried
    expect(result.success).toBe(false);
    expect(callCount).toBe(1); // Only tried once
  });

  it('handles server disconnection gracefully', async () => {
    const mockServer = createMockServer({ tools: [], handlers: {} });
    const client = await createTestPair(mockServer);
    
    // Simulate server going down
    await mockServer.close();
    
    // Client should get an error, not hang indefinitely
    const result = myClient.callTool(client, 'any-tool', {});
    await expect(result).rejects.toThrow();
  });
});

Testing Response Parsing

Your client parses MCP tool responses into your application's data model. Test the parsing logic independently.

import { parseSearchResponse } from '../lib/mcp-parsers';

describe('MCP response parsing', () => {
  it('parses search results correctly', () => {
    const mcpResponse = {
      content: [{
        type: 'text',
        text: JSON.stringify({
          results: [
            { title: 'Test Result', url: 'https://example.com', snippet: 'description' }
          ],
          total: 1
        })
      }],
      isError: false
    };

    const parsed = parseSearchResponse(mcpResponse);
    
    expect(parsed.results).toHaveLength(1);
    expect(parsed.results[0].title).toBe('Test Result');
    expect(parsed.results[0].url).toBe('https://example.com');
  });

  it('handles empty results', () => {
    const mcpResponse = {
      content: [{ type: 'text', text: JSON.stringify({ results: [], total: 0 }) }],
      isError: false
    };

    const parsed = parseSearchResponse(mcpResponse);
    expect(parsed.results).toHaveLength(0);
  });

  it('handles malformed JSON response', () => {
    const mcpResponse = {
      content: [{ type: 'text', text: 'not valid json {{{' }],
      isError: false
    };

    // Should not throw — should return a parse error
    const parsed = parseSearchResponse(mcpResponse);
    expect(parsed.error).toBeDefined();
    expect(parsed.results).toHaveLength(0);
  });

  it('handles unexpected response structure', () => {
    const mcpResponse = {
      content: [{ type: 'text', text: JSON.stringify({ unexpected_field: 'value' }) }],
      isError: false
    };

    const parsed = parseSearchResponse(mcpResponse);
    // Should degrade gracefully, not crash
    expect(parsed).toBeDefined();
  });
});

Testing Multi-Server Client Configurations

If your application connects to multiple MCP servers, test the routing logic:

describe('multi-server routing', () => {
  let filesystemClient: Client;
  let databaseClient: Client;
  let webClient: Client;
  let router: McpRouter;

  beforeAll(async () => {
    // Create mock servers for each capability
    filesystemClient = await createTestPair(createFilesystemMockServer());
    databaseClient = await createTestPair(createDatabaseMockServer());
    webClient = await createTestPair(createWebSearchMockServer());

    router = new McpRouter({
      filesystem: filesystemClient,
      database: databaseClient,
      web: webClient
    });
  });

  it('routes file operations to filesystem server', async () => {
    const result = await router.callTool('read-file', { path: '/tmp/test.txt' });
    expect(result.server).toBe('filesystem');
  });

  it('routes database queries to database server', async () => {
    const result = await router.callTool('run-query', { sql: 'SELECT 1' });
    expect(result.server).toBe('database');
  });

  it('routes ambiguous tools to the correct server by capability', async () => {
    // Both filesystem and database servers might have a "list" tool
    const result = await router.callTool('list', { path: '/tmp' });
    // Router should pick the most appropriate server
    expect(['filesystem', 'database']).toContain(result.server);
  });

  it('throws when no server handles the tool', async () => {
    await expect(
      router.callTool('unknown-tool', {})
    ).rejects.toThrow('No server found for tool: unknown-tool');
  });
});

Integration Test with a Real MCP Server

After unit tests with mocks, add a smaller set of integration tests that run against a real (local) server. These validate that your mock behavior matches reality.

// Only run these if a real server is available
const REAL_SERVER_URL = process.env.REAL_MCP_SERVER_URL;

describe.skipIf(!REAL_SERVER_URL)('integration with real server', () => {
  it('lists tools correctly', async () => {
    const client = await createHTTPClient(REAL_SERVER_URL!);
    const { tools } = await client.listTools();
    
    // Tool list from real server should match what your mock returns
    const toolNames = tools.map(t => t.name);
    expect(toolNames).toContain('search');
    
    await client.close();
  });
});

Set REAL_MCP_SERVER_URL in your local environment but not in CI. Mocks run everywhere; real server tests run when you need confidence before shipping.

The Client Testing Checklist

For each MCP tool your client uses:

  • Happy path: correct args → successful response → parsed correctly
  • Error path: isError: true → handled gracefully, not crashed
  • Missing tool: server doesn't have the tool → handled gracefully
  • Malformed response: response doesn't match expected schema → parse error returned
  • Retry behavior: transient errors retry, permanent errors don't
  • Context injection: correct fields injected, sensitive fields excluded
  • Tool selection: correct tool chosen for each intent type

MCP client code is as important as MCP server code. Test it with the same rigor.

Read more