How to Write Tests for MCP Server Tools: A Complete Guide

You can test MCP server tools by mocking the McpServer instance, capturing the registered handler functions, and invoking them directly with JSON inputs to verify their return structures.

Model Context Protocol (MCP) servers expose functionality to LLMs through tools – small, well-defined operations registered via server.registerTool(name, config, handler). Because these tools are pure functions that accept JSON-serializable arguments and return deterministic responses, you can unit test them without running a live MCP daemon. The canonical reference for this pattern is the test suite located at src/everything/__tests__/tools.test.ts in the official servers repository.

Understanding the MCP Tool Testing Pattern

The testing strategy follows a four-step pattern used consistently across the codebase:

  1. Create a mock McpServer using a plain object with spied methods.
  2. Capture the handler that registerTool receives in a Map for later invocation.
  3. Invoke the handler with expected input arguments and optional mock request metadata.
  4. Assert the returned structure, verifying the content array shape and any side effects like logging or notifications.

This approach isolates tool logic from transport concerns, allowing you to test validation, business logic, and side effects independently.

Setting Up the Test Environment

Creating a Mock McpServer

The foundation of every tool test is a helper function that constructs a typed mock server. This mock must expose registerTool and optionally registerResource, getClientCapabilities, and notification methods.

import { describe, it, expect, vi } from 'vitest';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

function createMockServer() {
  const handlers = new Map<string, Function>();
  
  const mockServer = {
    registerTool: vi.fn((name: string, _config: any, handler: Function) => {
      handlers.set(name, handler);
    }),
    registerResource: vi.fn(),
    server: { 
      getClientCapabilities: vi.fn(() => ({})), 
      notification: vi.fn() 
    },
    sendLoggingMessage: vi.fn(),
    sendResourceUpdated: vi.fn(),
  } as unknown as McpServer;
  
  return { mockServer, handlers };
}

This helper captures every registered handler in a Map, allowing tests to retrieve and invoke them directly without going through the MCP protocol layer.

Testing Basic Tool Handlers

Testing the Echo Tool

The echo tool demonstrates the simplest case: a handler that returns text content based on input arguments.

import { registerEchoTool } from '../tools/echo.js';

describe('echo tool', () => {
  it('should echo back the message', async () => {
    const { mockServer, handlers } = createMockServer();
    registerEchoTool(mockServer);
    
    const handler = handlers.get('echo')!;
    const result = await handler({ message: 'Hello, World!' });
    
    expect(result).toEqual({ 
      content: [{ type: 'text', text: 'Echo: Hello, World!' }] 
    });
  });
});

This test verifies that the handler correctly structures its return value with a content array containing a text object, which is the required MCP tool response format.

Testing Input Validation and Error Handling

Validating Tool Input (get-sum)

Tools often enforce schemas using Zod or similar validators. Test these boundaries by passing invalid inputs and asserting that the handler throws appropriate errors.

import { registerGetSumTool } from '../tools/get-sum.js';

it('rejects invalid input', async () => {
  const { mockServer, handlers } = createMockServer();
  registerGetSumTool(mockServer);
  const handler = handlers.get('get-sum')!;

  await expect(handler({})).rejects.toThrow();
  await expect(handler({ a: 'x', b: 5 })).rejects.toThrow();
  await expect(handler({ a: 1 })).rejects.toThrow();
});

These assertions ensure that the tool's input validation correctly rejects malformed arguments, missing required fields, and type mismatches before executing business logic.

Testing Tools with Side Effects

Simulated Logging Toggle (toggle-simulated-logging)

Some tools interact with server state or trigger notifications. Capture these side effects by spying on mock server methods like sendLoggingMessage.

import { registerToggleSimulatedLoggingTool } from '../tools/toggle-simulated-logging.js';

it('starts and stops logging correctly', async () => {
  const { mockServer, handlers } = createMockServer();
  registerToggleSimulatedLoggingTool(mockServer);
  const handler = handlers.get('toggle-simulated-logging')!;

  // First call starts logging
  const start = await handler({}, { sessionId: 'sess-1' });
  expect(start.content[0].text).toContain('Started');

  // Second call stops logging
  const stop = await handler({}, { sessionId: 'sess-1' });
  expect(stop.content[0].text).toContain('Stopped');
});

This pattern tests stateful behavior by invoking the same handler twice and verifying that the internal state toggles between calls, while also demonstrating how to pass request metadata (like sessionId) as a second argument.

Testing Conditional Tool Registration

Client Capability Guards (sampling)

Advanced tools like sampling or elicitation only register if the client advertises support via capabilities. Mock getClientCapabilities to test these conditional paths.

import { registerTriggerSamplingRequestTool } from '../tools/trigger-sampling-request.js';

it('registers only when client supports sampling', () => {
  const handlers = new Map<string, Function>();
  const mockServer = {
    registerTool: vi.fn((name, _cfg, h) => handlers.set(name, h)),
    server: { 
      getClientCapabilities: vi.fn(() => ({ sampling: {} })) 
    },
  } as unknown as McpServer;

  registerTriggerSamplingRequestTool(mockServer);
  
  expect(mockServer.registerTool).toHaveBeenCalledWith(
    'trigger-sampling-request',
    expect.objectContaining({ title: expect.any(String) }),
    expect.any(Function)
  );
});

This test verifies that the tool correctly checks for client capabilities before registration, ensuring compatibility with the MCP protocol's capability negotiation system.

Testing Resource-Producing Tools

Gzip File Resource (gzip-file-as-resource)

Tools that generate dynamic resources require verifying both the return structure and any calls to registerResource.

import { registerGZipFileAsResourceTool } from '../tools/gzip-file-as-resource.js';

it('compresses a data URI into a resource link', async () => {
  const mockServer = {
    registerTool: vi.fn(),
    registerResource: vi.fn(),
  } as unknown as McpServer;

  let handler: Function | null = null;
  (mockServer.registerTool as any).mockImplementation((_n, _c, h) => (handler = h));

  registerGZipFileAsResourceTool(mockServer);

  const dataUri = `data:text/plain;base64,${Buffer.from('test').toString('base64')}`;
  const result = await handler!({ 
    name: 'test.txt.gz', 
    data: dataUri, 
    outputType: 'resourceLink' 
  });

  expect(result.content[0].type).toBe('resource_link');
  expect(result.content[0].uri).toContain('test.txt.gz');
});

This demonstrates testing binary data processing and resource registration, verifying that the tool correctly transforms input data and returns a properly formatted resource link.

Summary

  • Mock the server using a plain object with vi.fn() spies for registerTool, registerResource, and notification methods to isolate unit tests from transport concerns.
  • Capture handlers in a Map during registration so you can invoke them directly with JSON inputs without running an MCP daemon.
  • Assert content structure by verifying that handlers return objects with a content array (or structuredContent) containing properly typed elements.
  • Test validation logic by passing invalid inputs and asserting that handlers throw errors or reject promises.
  • Verify side effects by checking calls to sendLoggingMessage, notification, or internal state changes when testing stateful tools.
  • Check conditional registration by mocking getClientCapabilities to ensure tools only register when the client supports specific features like sampling.

Frequently Asked Questions

Do I need a running MCP server to test tools?

No. According to the modelcontextprotocol/servers source code, you can test tools in complete isolation by mocking the McpServer instance and capturing the handler functions registered via registerTool. This allows you to invoke tool logic directly without establishing an MCP transport connection or running a daemon.

How do I mock client capabilities in tests?

Create a mock server object that includes a server.getClientCapabilities method returning the specific capability flags you want to test. For example, return { sampling: {} } to test sampling tools or {} to test behavior when capabilities are absent. Then assert whether registerTool was called based on the capability check.

What should I assert when testing an MCP tool?

Assert three specific things: first, that the return value contains a content array with objects having type and appropriate content fields (like text or uri); second, that input validation correctly rejects malformed arguments by throwing errors; and third, that any side effects—such as calls to sendLoggingMessage or state toggles—occur as expected.

How do I test tools that interact with external APIs?

Follow the same mock server pattern, but additionally mock the external API clients or HTTP requests within the tool's handler. Since you capture the handler via registerTool, you can use standard mocking libraries like vi.fn() or jest.mock() to intercept HTTP calls, then invoke the handler and assert that it processes the mocked API responses correctly into the MCP content format.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →