Testing MCP Servers: A Complete Guide

February 2025 ยท 12 min read

You've built an MCP server. But how do you know it works? This guide covers everything from quick manual testing to comprehensive test suites that catch bugs before your users do.

Quick Validation with MCP Inspector

The fastest way to test your MCP server is the MCP Inspector โ€” a web-based tool that lets you interact with your server in real-time. It shows exactly what tools, resources, and prompts your server exposes.

Installing MCP Inspector

# Install globally
npm install -g @modelcontextprotocol/inspector

# Or run directly with npx
npx @modelcontextprotocol/inspector

Running the Inspector

# For a Python server
npx @modelcontextprotocol/inspector python your_server.py

# For a TypeScript/Node server
npx @modelcontextprotocol/inspector node build/index.js

# For an installed package
npx @modelcontextprotocol/inspector npx -y your-mcp-server

The Inspector opens a web interface (usually at localhost:5173) where you can:

  • Browse all exposed tools, resources, and prompts
  • Execute tools with custom arguments
  • View raw JSON-RPC messages
  • Test error handling
  • Inspect resource contents

๐Ÿ’ก Pro Tip

Keep the Inspector running during development. It's the quickest way to verify changes without restarting your MCP client.

Manual Testing Techniques

Sometimes you need to test the raw protocol. Here's how to talk to your MCP server directly.

Using stdio Directly

MCP servers communicate over stdin/stdout. You can pipe JSON-RPC messages directly:

# Initialize the connection
echo '{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test", "version": "1.0"}}}' | python your_server.py

# List available tools
echo '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}' | python your_server.py

# Call a specific tool
echo '{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "your_tool", "arguments": {"arg1": "value"}}}' | python your_server.py

Testing with a Simple Script

#!/usr/bin/env python3
"""Quick test script for MCP servers."""
import subprocess
import json

def send_message(proc, message):
    """Send a JSON-RPC message and get response."""
    proc.stdin.write(json.dumps(message) + "\n")
    proc.stdin.flush()
    response = proc.stdout.readline()
    return json.loads(response)

# Start the server
proc = subprocess.Popen(
    ["python", "your_server.py"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    text=True
)

# Initialize
response = send_message(proc, {
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
        "protocolVersion": "2024-11-05",
        "capabilities": {},
        "clientInfo": {"name": "test", "version": "1.0"}
    }
})
print("Initialize:", response)

# List tools
response = send_message(proc, {
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list",
    "params": {}
})
print("Tools:", response)

proc.terminate()

Unit Testing in Python (FastMCP)

FastMCP makes testing straightforward. Here's how to write proper unit tests for your tools.

Basic Test Structure

# test_server.py
import pytest
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

# Your server code
from server import mcp  # assuming your FastMCP instance is called 'mcp'

@pytest.fixture
async def client():
    """Create a test client connected to the server."""
    server_params = StdioServerParameters(
        command="python",
        args=["server.py"]
    )
    
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            yield session

@pytest.mark.asyncio
async def test_tools_list(client):
    """Test that tools are properly exposed."""
    result = await client.list_tools()
    tool_names = [t.name for t in result.tools]
    
    assert "my_tool" in tool_names
    assert "another_tool" in tool_names

@pytest.mark.asyncio
async def test_tool_execution(client):
    """Test tool execution with valid input."""
    result = await client.call_tool("my_tool", {"input": "test"})
    
    assert len(result.content) > 0
    assert result.content[0].type == "text"
    assert "expected output" in result.content[0].text

Testing Tools Directly (Without Protocol)

For faster unit tests, you can test your tool functions directly without going through the MCP protocol:

# server.py
from fastmcp import FastMCP

mcp = FastMCP("test-server")

@mcp.tool()
def calculate_sum(a: int, b: int) -> int:
    """Add two numbers together."""
    return a + b

@mcp.tool()
async def fetch_data(url: str) -> str:
    """Fetch data from a URL."""
    import httpx
    async with httpx.AsyncClient() as client:
        response = await client.get(url)
        return response.text

# test_tools.py
import pytest
from server import calculate_sum, fetch_data

def test_calculate_sum():
    """Direct unit test for calculate_sum."""
    assert calculate_sum(2, 3) == 5
    assert calculate_sum(-1, 1) == 0
    assert calculate_sum(0, 0) == 0

def test_calculate_sum_type_error():
    """Test that invalid types raise errors."""
    with pytest.raises(TypeError):
        calculate_sum("not", "numbers")

@pytest.mark.asyncio
async def test_fetch_data(httpx_mock):
    """Test fetch_data with mocked HTTP."""
    httpx_mock.add_response(
        url="https://api.example.com/data",
        text="mocked response"
    )
    
    result = await fetch_data("https://api.example.com/data")
    assert result == "mocked response"

Testing Error Handling

@pytest.mark.asyncio
async def test_tool_invalid_input(client):
    """Test that invalid input returns proper error."""
    with pytest.raises(Exception) as exc_info:
        await client.call_tool("my_tool", {"invalid": "params"})
    
    assert "required parameter" in str(exc_info.value).lower()

@pytest.mark.asyncio
async def test_tool_handles_api_error(client, httpx_mock):
    """Test graceful handling of API failures."""
    httpx_mock.add_response(status_code=500)
    
    result = await client.call_tool("fetch_data", {"url": "https://api.example.com"})
    
    # Should return error message, not crash
    assert "error" in result.content[0].text.lower()

Unit Testing in TypeScript

For TypeScript MCP servers, here's how to set up testing with Jest or Vitest.

Setting Up Vitest

# Install dependencies
npm install -D vitest @types/node

# package.json
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Writing Tests

// src/tools.ts - Your tool implementations
export function calculateSum(a: number, b: number): number {
  return a + b;
}

export async function fetchData(url: string): Promise<string> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.text();
}

// src/tools.test.ts
import { describe, it, expect, vi } from 'vitest';
import { calculateSum, fetchData } from './tools';

describe('calculateSum', () => {
  it('adds positive numbers', () => {
    expect(calculateSum(2, 3)).toBe(5);
  });

  it('handles negative numbers', () => {
    expect(calculateSum(-1, 1)).toBe(0);
  });

  it('handles zero', () => {
    expect(calculateSum(0, 0)).toBe(0);
  });
});

describe('fetchData', () => {
  it('returns response text on success', async () => {
    // Mock fetch
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      text: () => Promise.resolve('mocked data'),
    });

    const result = await fetchData('https://api.example.com');
    expect(result).toBe('mocked data');
  });

  it('throws on HTTP error', async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 500,
    });

    await expect(fetchData('https://api.example.com'))
      .rejects.toThrow('HTTP 500');
  });
});

Testing the Full Server

// src/server.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { spawn, ChildProcess } from 'child_process';

describe('MCP Server Integration', () => {
  let serverProcess: ChildProcess;

  beforeAll(async () => {
    serverProcess = spawn('node', ['build/index.js'], {
      stdio: ['pipe', 'pipe', 'pipe'],
    });
    
    // Wait for server to be ready
    await new Promise(resolve => setTimeout(resolve, 1000));
  });

  afterAll(() => {
    serverProcess.kill();
  });

  it('responds to initialize', async () => {
    const message = JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'initialize',
      params: {
        protocolVersion: '2024-11-05',
        capabilities: {},
        clientInfo: { name: 'test', version: '1.0' },
      },
    });

    serverProcess.stdin!.write(message + '\n');

    const response = await new Promise<string>((resolve) => {
      serverProcess.stdout!.once('data', (data) => {
        resolve(data.toString());
      });
    });

    const parsed = JSON.parse(response);
    expect(parsed.result).toBeDefined();
    expect(parsed.result.serverInfo).toBeDefined();
  });
});

Integration Testing

Integration tests verify your server works correctly with real MCP clients. Here's a pattern for end-to-end testing.

# test_integration.py
import pytest
import asyncio
from pathlib import Path

@pytest.fixture(scope="module")
def event_loop():
    """Create event loop for the test module."""
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()

@pytest.fixture(scope="module")
async def mcp_client():
    """Start server and create client for integration tests."""
    from mcp import ClientSession, StdioServerParameters
    from mcp.client.stdio import stdio_client
    
    server_params = StdioServerParameters(
        command="python",
        args=[str(Path(__file__).parent.parent / "server.py")],
        env={
            "API_KEY": "test_key",  # Test credentials
            "DEBUG": "true"
        }
    )
    
    async with stdio_client(server_params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            yield session

class TestIntegration:
    """Integration test suite."""
    
    @pytest.mark.asyncio
    async def test_full_workflow(self, mcp_client):
        """Test a complete user workflow."""
        # Step 1: List available tools
        tools = await mcp_client.list_tools()
        assert len(tools.tools) > 0
        
        # Step 2: Use first tool
        first_tool = tools.tools[0]
        result = await mcp_client.call_tool(
            first_tool.name,
            {"input": "test data"}
        )
        assert result.content
        
        # Step 3: Read a resource
        resources = await mcp_client.list_resources()
        if resources.resources:
            content = await mcp_client.read_resource(
                resources.resources[0].uri
            )
            assert content.contents
    
    @pytest.mark.asyncio
    async def test_concurrent_requests(self, mcp_client):
        """Test that server handles concurrent requests."""
        tasks = [
            mcp_client.call_tool("my_tool", {"input": f"test_{i}"})
            for i in range(10)
        ]
        
        results = await asyncio.gather(*tasks)
        
        assert len(results) == 10
        assert all(r.content for r in results)

CI/CD Pipeline Setup

Automate your tests with GitHub Actions. Here's a complete workflow:

# .github/workflows/test.yml
name: Test MCP Server

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test-python:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      
      - name: Install dependencies
        run: |
          pip install -e .
          pip install pytest pytest-asyncio pytest-httpx
      
      - name: Run tests
        run: pytest -v --tb=short
      
      - name: Test with MCP Inspector
        run: |
          npm install -g @modelcontextprotocol/inspector
          timeout 10 npx @modelcontextprotocol/inspector python server.py &
          sleep 5
          curl -s http://localhost:5173/api/tools | jq .

  test-typescript:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build
        run: npm run build
      
      - name: Run tests
        run: npm test
      
      - name: Lint
        run: npm run lint

Mocking External Dependencies

Your MCP server probably calls external APIs. Here's how to mock them properly.

Python with pytest-httpx

# conftest.py
import pytest

@pytest.fixture
def mock_github_api(httpx_mock):
    """Mock GitHub API responses."""
    httpx_mock.add_response(
        url="https://api.github.com/repos/owner/repo",
        json={
            "name": "repo",
            "full_name": "owner/repo",
            "stargazers_count": 100
        }
    )
    
    httpx_mock.add_response(
        url="https://api.github.com/repos/owner/repo/issues",
        json=[
            {"number": 1, "title": "Bug fix", "state": "open"},
            {"number": 2, "title": "Feature", "state": "closed"}
        ]
    )
    
    return httpx_mock

# test_github_tools.py
@pytest.mark.asyncio
async def test_get_repo_info(client, mock_github_api):
    """Test GitHub repo info tool with mocked API."""
    result = await client.call_tool(
        "get_repo_info",
        {"owner": "owner", "repo": "repo"}
    )
    
    assert "100" in result.content[0].text  # Star count
    assert "owner/repo" in result.content[0].text

TypeScript with vi.mock

// test/mocks.ts
import { vi } from 'vitest';

export const mockFetch = vi.fn();

// Reset before each test
beforeEach(() => {
  mockFetch.mockReset();
  global.fetch = mockFetch;
});

// Helper to mock specific endpoints
export function mockGitHubAPI() {
  mockFetch.mockImplementation((url: string) => {
    if (url.includes('/repos/')) {
      return Promise.resolve({
        ok: true,
        json: () => Promise.resolve({
          name: 'repo',
          stargazers_count: 100,
        }),
      });
    }
    
    return Promise.resolve({
      ok: false,
      status: 404,
    });
  });
}

Testing Best Practices

1. Test the Happy Path First

Start with tests that verify normal operation. Edge cases and error handling come second.

2. Test Tool Descriptions

LLMs use your tool descriptions to decide when to call them. Test that descriptions are accurate and helpful.

def test_tool_descriptions(client):
    tools = await client.list_tools()
    
    for tool in tools.tools:
        assert len(tool.description) > 20
        assert tool.inputSchema is not None

3. Test Parameter Validation

Ensure invalid parameters are rejected with clear error messages, not cryptic crashes.

4. Test Timeouts and Cancellation

Long-running tools should handle timeouts gracefully. Test that they can be cancelled.

5. Use Fixtures for Common Setup

Don't repeat server startup code. Use fixtures to share setup across tests.

6. Test with Real LLM Clients (Occasionally)

Unit tests with mocks are fast. But occasionally test with Claude Desktop to catch integration issues.

Quick Reference: Test Commands

# Python
pytest                          # Run all tests
pytest -v                       # Verbose output
pytest -x                       # Stop on first failure
pytest --cov=src               # With coverage
pytest -k "test_tool"          # Run specific tests

# TypeScript  
npm test                        # Run tests
npm run test:watch             # Watch mode
npm run test:coverage          # With coverage

# MCP Inspector
npx @modelcontextprotocol/inspector python server.py
npx @modelcontextprotocol/inspector node build/index.js

Wrapping Up

Good testing is the difference between "it works on my machine" and "it works." For MCP servers specifically:

  • Start with MCP Inspector for quick validation
  • Unit test your tool functions directly for speed
  • Integration test the full protocol for confidence
  • Automate with CI/CD to catch regressions

A well-tested MCP server is one you can ship with confidence.