Back to Tutorials
IntermediateDevOps

The MCP Testing Crisis (And How We Fixed It): Complete Tutorial for MCP Test Kit

The Model Context Protocol ecosystem had a critical gap: no simple, production-ready testing framework. Learn why we built @crashbytes/mcp-test-kit and how to test your MCP servers properly with automated testing, CI/CD integration, and regression protection.

by Michael Eakins
21 min read
12/13/2025

Prerequisites

  • Node.js and npm installed
  • Basic understanding of testing concepts
  • Familiarity with MCP protocol
  • TypeScript knowledge helpful but not required

What You'll Learn

  • Build automated test suites for MCP servers
  • Integrate MCP testing into CI/CD pipelines
  • Implement regression protection for AI infrastructure
  • Deploy production-ready MCP servers with confidence
  • Use @crashbytes/mcp-test-kit for comprehensive testing

Technologies Covered

MCPTestingNode.jsTypeScriptTest AutomationVitestJest

If you have built an MCP server, you have probably experienced the following scenario: you spin up the @modelcontextprotocol/inspector, click through your tools manually, verify they work, ship to production, and pray nothing breaks. There is no automated test suite. No CI/CD validation. No regression protection. Just hope.

This is insane. We would never ship a REST API this way. We would never deploy a database without migration tests. But somehow, the Model Context Protocol ecosystem has been operating in the Stone Age of software quality assurance.

I built @crashbytes/mcp-test-kit because I got tired of shipping untested MCP servers and discovering bugs in production. This tutorial explains why the MCP ecosystem desperately needed a proper testing framework, what makes this kit different from existing solutions, and how to use it to build bulletproof MCP servers.

The MCP Testing Crisis Nobody Talks About

The Model Context Protocol is fundamentally changing how AI systems interact with external tools and data. Claude Desktop, Cursor, and dozens of other clients now use MCP to connect to everything from file systems to databases to custom business logic. The ecosystem is exploding.

But there is a dirty secret: most MCP servers have zero automated tests.

Why Manual Testing Doesn't Scale

The official MCP Inspector is a brilliant tool for manual exploration of your server's capabilities. You launch it with npx @modelcontextprotocol/inspector, point it at your server, and click buttons to call tools and read resources. For initial development, this is perfect.

The problem is what happens next. Your MCP server goes into production. You add new tools. You refactor resource providers. You update dependencies. How do you know you didn't break existing functionality? You launch the Inspector again and manually test every single tool. Then you test every resource. Then every prompt template. Then every edge case.

This does not work at scale. Consider a production MCP server with:

  • 15 tools with varying input schemas
  • 8 resources with different URI patterns
  • 5 prompt templates with multiple variables
  • 100+ distinct test cases for edge conditions

Manual testing would take hours for each deployment. You will skip tests. You will miss regressions. Production will break.

The Existing Solutions Fall Short

Before @crashbytes/mcp-test-kit, the MCP ecosystem had several testing approaches, each with critical limitations:

Option 1: MCP Inspector (Manual Testing)

  • Pro: Official tool, great for exploration
  • Con: Completely manual, no automation, no CI/CD integration
  • Con: No regression detection across releases
  • Con: Cannot test error conditions or edge cases systematically

Option 2: mcp-test-client

  • Pro: Basic programmatic testing
  • Con: Limited assertion helpers, lots of boilerplate
  • Con: No built-in support for snapshot testing
  • Con: Clunky API for common patterns

Option 3: mcp-testing-kit (ThoughtSpot)

  • Pro: Creates dummy transport layer
  • Con: Requires manual server instance management
  • Con: No standardized matchers for common assertions
  • Con: Limited documentation and examples

Option 4: mcp-dev-kit

  • Pro: Snapshot testing and custom matchers
  • Con: Complex setup with multiple configuration files
  • Con: Tight coupling to Vitest (cannot use with Jest, Mocha, etc.)
  • Con: Overkill for simple servers

What Was Missing

After trying all these solutions, I realized what the MCP ecosystem actually needed:

  1. Zero-config simplicity: Install one package, write tests immediately
  2. Framework agnostic: Work with Vitest, Jest, Mocha, or any test runner
  3. Ergonomic API: Testing common patterns should be trivial
  4. Production-ready: Handle connection lifecycle, errors, timeouts automatically
  5. Type-safe: Full TypeScript support with inference
  6. Fast: Lightweight, no unnecessary dependencies

That is why I built @crashbytes/mcp-test-kit.

What Makes @crashbytes/mcp-test-kit Different

The kit is built on three core principles:

1. Simplicity First

You should be able to install the package and write your first test in under 60 seconds. No configuration files. No setup boilerplate. No framework lock-in.

import { createTestClient } from '@crashbytes/mcp-test-kit'
import { describe, it, expect } from 'vitest' // or jest, or mocha

describe('Calculator MCP Server', () => {
  it('should add two numbers', async () => {
    const client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })

    const result = await client.callTool('add', { a: 5, b: 3 })
    expect(result.content[0].text).toBe('8')

    await client.disconnect()
  })
})

That is it. No dummy transports to configure. No mock layers to set up. No lifecycle hooks to remember. The kit handles everything.

2. Ergonomic API Design

Common testing patterns should require minimal code. The API is designed around what you actually need to test:

// List all available tools
const tools = await client.listTools()

// Call a tool with parameters
const result = await client.callTool('search', { query: 'test' })

// Read a resource
const resource = await client.readResource('file:///data.json')

// Get a prompt template
const prompt = await client.getPrompt('greeting', { name: 'Alice' })

// Assert tool responses
await client.expectToolSuccess('add', { a: 1, b: 2 })
await client.expectToolError('divide', { a: 1, b: 0 })

Each method returns properly typed results. TypeScript inference works out of the box.

3. Framework Agnostic

The kit does not care whether you use Vitest, Jest, Mocha, or even a custom test runner. It provides the primitives for testing MCP servers, and you compose them with your preferred assertion library.

This matters because:

  • Teams have existing test infrastructure
  • Migration costs are a real barrier to adoption
  • Different projects have different requirements
  • Framework lock-in creates technical debt

Installation and Quick Start

Let me show you how to get started in less than 5 minutes.

Prerequisites

You need:

  • Node.js 18 or later
  • An MCP server (already built or one you are creating)
  • A test framework (Vitest, Jest, Mocha, or similar)

Step 1: Install the Package

Install @crashbytes/mcp-test-kit from npm:

npm install --save-dev @crashbytes/mcp-test-kit

That is the only installation step. No peer dependencies. No configuration files.

Step 2: Write Your First Test

Create a test file next to your MCP server:

// server.test.ts
import { createTestClient } from '@crashbytes/mcp-test-kit'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'

describe('My MCP Server', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/index.js'],
      timeout: 5000, // optional: increase for slow servers
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should list available tools', async () => {
    const tools = await client.listTools()
    expect(tools.length).toBeGreaterThan(0)
    expect(tools[0]).toHaveProperty('name')
    expect(tools[0]).toHaveProperty('description')
  })

  it('should execute tools successfully', async () => {
    const result = await client.callTool('echo', { message: 'hello' })
    expect(result.content[0].text).toBe('hello')
  })
})

Step 3: Run Your Tests

npm test

The kit automatically:

  • Spawns your MCP server as a subprocess
  • Establishes stdio transport connection
  • Handles JSON-RPC protocol communication
  • Cleans up resources on disconnect
  • Captures stderr for debugging

Complete Testing Patterns

Now let me show you every testing pattern you will need in production.

Testing Tools

Tools are the core of MCP servers. Here is how to test them comprehensively:

describe('Tool Testing', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should list all available tools', async () => {
    const tools = await client.listTools()

    // Verify tool structure
    expect(tools).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          name: expect.any(String),
          description: expect.any(String),
          inputSchema: expect.any(Object),
        }),
      ])
    )

    // Verify specific tools exist
    const toolNames = tools.map(t => t.name)
    expect(toolNames).toContain('calculate')
    expect(toolNames).toContain('search')
  })

  it('should validate tool input schemas', async () => {
    const tools = await client.listTools()
    const calculator = tools.find(t => t.name === 'calculate')

    // Verify Zod schema structure
    expect(calculator.inputSchema).toHaveProperty('type', 'object')
    expect(calculator.inputSchema.properties).toHaveProperty('operation')
    expect(calculator.inputSchema.properties).toHaveProperty('a')
    expect(calculator.inputSchema.properties).toHaveProperty('b')
    expect(calculator.inputSchema.required).toContain('operation')
  })

  it('should execute tools and return results', async () => {
    const result = await client.callTool('calculate', {
      operation: 'add',
      a: 10,
      b: 5,
    })

    expect(result).toHaveProperty('content')
    expect(result.content).toHaveLength(1)
    expect(result.content[0].type).toBe('text')
    expect(result.content[0].text).toBe('15')
  })

  it('should handle tool errors gracefully', async () => {
    // Test invalid input
    await expect(
      client.callTool('calculate', { operation: 'divide', a: 1, b: 0 })
    ).rejects.toThrow('Division by zero')

    // Test missing required params
    await expect(
      client.callTool('calculate', { operation: 'add' })
    ).rejects.toThrow()
  })

  it('should support complex tool parameters', async () => {
    const result = await client.callTool('search', {
      query: 'machine learning',
      filters: {
        dateRange: { start: '2025-01-01', end: '2025-12-31' },
        categories: ['ai', 'research'],
        limit: 10,
      },
    })

    const parsed = JSON.parse(result.content[0].text)
    expect(parsed.results).toBeDefined()
    expect(parsed.results.length).toBeLessThanOrEqual(10)
  })
})

Testing Resources

Resources expose data that clients can read. Test them like this:

describe('Resource Testing', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should list all available resources', async () => {
    const resources = await client.listResources()

    expect(resources).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          uri: expect.any(String),
          name: expect.any(String),
          mimeType: expect.any(String),
        }),
      ])
    )
  })

  it('should read resource contents', async () => {
    const resource = await client.readResource('file:///config.json')

    expect(resource.contents).toHaveLength(1)
    expect(resource.contents[0].mimeType).toBe('application/json')

    const config = JSON.parse(resource.contents[0].text)
    expect(config).toHaveProperty('version')
    expect(config).toHaveProperty('settings')
  })

  it('should support URI templates', async () => {
    // Test dynamic resource URIs
    const userResource = await client.readResource('user://alice')
    expect(userResource.contents[0].text).toContain('alice')

    const fileResource = await client.readResource('file:///data/2025-12.json')
    expect(fileResource.contents[0].mimeType).toBe('application/json')
  })

  it('should handle missing resources', async () => {
    await expect(
      client.readResource('file:///does-not-exist.json')
    ).rejects.toThrow('Resource not found')
  })

  it('should support binary resources', async () => {
    const image = await client.readResource('image:///logo.png')

    expect(image.contents[0].mimeType).toBe('image/png')
    expect(image.contents[0].blob).toBeDefined()

    // Verify base64-encoded image data
    const buffer = Buffer.from(image.contents[0].blob, 'base64')
    expect(buffer.length).toBeGreaterThan(0)
  })
})

Testing Prompts

Prompt templates are reusable message structures. Here is how to test them:

describe('Prompt Testing', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should list all available prompts', async () => {
    const prompts = await client.listPrompts()

    expect(prompts).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          name: expect.any(String),
          description: expect.any(String),
          arguments: expect.any(Array),
        }),
      ])
    )
  })

  it('should get prompt templates with variables', async () => {
    const prompt = await client.getPrompt('greeting', {
      name: 'Alice',
      language: 'en',
    })

    expect(prompt.messages).toHaveLength(1)
    expect(prompt.messages[0].role).toBe('user')
    expect(prompt.messages[0].content.text).toContain('Alice')
  })

  it('should validate required prompt arguments', async () => {
    await expect(
      client.getPrompt('greeting', {}) // missing required 'name'
    ).rejects.toThrow('Missing required argument: name')
  })

  it('should support multi-turn prompts', async () => {
    const prompt = await client.getPrompt('conversation', {
      topic: 'quantum computing',
      expertiseLevel: 'advanced',
    })

    expect(prompt.messages.length).toBeGreaterThan(1)
    expect(prompt.messages[0].role).toBe('user')
    expect(prompt.messages[1].role).toBe('assistant')
    expect(prompt.messages[2].role).toBe('user')
  })
})

Testing Error Conditions

Production MCP servers must handle errors gracefully:

describe('Error Handling', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should handle invalid tool names', async () => {
    await expect(client.callTool('nonexistent-tool', {})).rejects.toThrow(
      'Tool not found'
    )
  })

  it('should handle invalid resource URIs', async () => {
    await expect(client.readResource('invalid://uri')).rejects.toThrow()
  })

  it('should handle malformed parameters', async () => {
    await expect(
      client.callTool('calculate', {
        operation: 'add',
        a: 'not a number', // should be number
        b: 5,
      })
    ).rejects.toThrow()
  })

  it('should handle server crashes gracefully', async () => {
    // Trigger intentional server crash
    await expect(client.callTool('crash', {})).rejects.toThrow()

    // Verify client detects disconnection
    expect(client.isConnected()).toBe(false)
  })

  it('should timeout on slow operations', async () => {
    const slowClient = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
      timeout: 100, // 100ms timeout
    })

    await expect(
      slowClient.callTool('slow-operation', { delay: 5000 })
    ).rejects.toThrow('Timeout')

    await slowClient.disconnect()
  })
})

Testing Server Capabilities

The MCP protocol supports server capability negotiation:

describe('Server Capabilities', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should advertise server capabilities', async () => {
    const info = await client.getServerInfo()

    expect(info).toHaveProperty('name')
    expect(info).toHaveProperty('version')
    expect(info).toHaveProperty('capabilities')
  })

  it('should support tools capability', async () => {
    const info = await client.getServerInfo()
    expect(info.capabilities).toHaveProperty('tools')
  })

  it('should support resources capability', async () => {
    const info = await client.getServerInfo()
    expect(info.capabilities).toHaveProperty('resources')
  })

  it('should support prompts capability', async () => {
    const info = await client.getServerInfo()
    expect(info.capabilities).toHaveProperty('prompts')
  })

  it('should support sampling if enabled', async () => {
    const info = await client.getServerInfo()
    if (info.capabilities.sampling) {
      expect(info.capabilities.sampling).toHaveProperty('enabled', true)
    }
  })
})

Advanced Testing Strategies

Snapshot Testing for Stable Responses

Some responses should remain consistent across releases:

describe('Snapshot Testing', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should maintain stable tool list', async () => {
    const tools = await client.listTools()

    // Remove dynamic fields before snapshot
    const stableTools = tools.map(({ name, description, inputSchema }) => ({
      name,
      description,
      inputSchema,
    }))

    expect(stableTools).toMatchSnapshot()
  })

  it('should maintain stable resource structure', async () => {
    const resource = await client.readResource('config://defaults')
    const config = JSON.parse(resource.contents[0].text)

    // Snapshot structure, not values
    const structure = {
      keys: Object.keys(config),
      types: Object.fromEntries(
        Object.entries(config).map(([k, v]) => [k, typeof v])
      ),
    }

    expect(structure).toMatchSnapshot()
  })
})

Integration Testing with Real Services

Test your MCP server against actual external dependencies:

describe('Integration Tests', () => {
  let client
  let mockApiServer

  beforeAll(async () => {
    // Start mock external API
    mockApiServer = await startMockServer(3000)
  })

  afterAll(async () => {
    await mockApiServer.close()
  })

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
      env: {
        API_URL: 'http://localhost:3000',
      },
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should fetch data from external API', async () => {
    const result = await client.callTool('fetch-users', {})
    const users = JSON.parse(result.content[0].text)

    expect(users).toHaveLength(10)
    expect(users[0]).toHaveProperty('id')
    expect(users[0]).toHaveProperty('name')
  })

  it('should handle API failures gracefully', async () => {
    // Configure mock server to return 500 error
    mockApiServer.setError(500)

    await expect(client.callTool('fetch-users', {})).rejects.toThrow(
      'API request failed'
    )
  })
})

Performance Testing

Verify your MCP server meets performance requirements:

describe('Performance Tests', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  it('should list tools in less than 100ms', async () => {
    const start = Date.now()
    await client.listTools()
    const duration = Date.now() - start

    expect(duration).toBeLessThan(100)
  })

  it('should handle concurrent tool calls', async () => {
    const promises = Array.from({ length: 10 }, (_, i) =>
      client.callTool('echo', { message: `test-${i}` })
    )

    const results = await Promise.all(promises)
    expect(results).toHaveLength(10)
    results.forEach((result, i) => {
      expect(result.content[0].text).toBe(`test-${i}`)
    })
  })

  it('should not leak memory over many requests', async () => {
    const initialMemory = process.memoryUsage().heapUsed

    // Execute 1000 requests
    for (let i = 0; i < 1000; i++) {
      await client.callTool('echo', { message: 'test' })
    }

    const finalMemory = process.memoryUsage().heapUsed
    const growth = (finalMemory - initialMemory) / 1024 / 1024

    // Memory growth should be less than 10MB
    expect(growth).toBeLessThan(10)
  })
})

CI/CD Integration

The kit works seamlessly with any CI/CD pipeline. Here are examples for common platforms:

GitHub Actions

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

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Build server
        run: npm run build

      - name: Run tests
        run: npm test

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage/coverage-final.json

GitLab CI

# .gitlab-ci.yml
test:
  image: node:20
  stage: test
  script:
    - npm ci
    - npm run build
    - npm test
  coverage: '/^Statements\s*:\s*([^%]+)/'
  artifacts:
    reports:
      junit: coverage/junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

CircleCI

# .circleci/config.yml
version: 2.1

jobs:
  test:
    docker:
      - image: cimg/node:20.0
    steps:
      - checkout
      - run:
          name: Install dependencies
          command: npm ci
      - run:
          name: Build server
          command: npm run build
      - run:
          name: Run tests
          command: npm test
      - store_test_results:
          path: coverage

workflows:
  version: 2
  test:
    jobs:
      - test

Real-World Example: Complete Test Suite

Here is a complete test suite for a real MCP server that provides weather data:

// weather-server.test.ts
import { createTestClient } from '@crashbytes/mcp-test-kit'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'

describe('Weather MCP Server', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/index.js'],
      env: {
        WEATHER_API_KEY: process.env.WEATHER_API_KEY || 'test-key',
      },
      timeout: 10000,
    })
  })

  afterEach(async () => {
    await client.disconnect()
  })

  describe('Server Capabilities', () => {
    it('should provide server info', async () => {
      const info = await client.getServerInfo()

      expect(info.name).toBe('weather-mcp-server')
      expect(info.version).toMatch(/^\d+\.\d+\.\d+$/)
      expect(info.capabilities).toHaveProperty('tools')
      expect(info.capabilities).toHaveProperty('resources')
    })
  })

  describe('Weather Tools', () => {
    it('should list available weather tools', async () => {
      const tools = await client.listTools()

      const toolNames = tools.map(t => t.name)
      expect(toolNames).toContain('get-current-weather')
      expect(toolNames).toContain('get-forecast')
      expect(toolNames).toContain('get-alerts')
    })

    it('should get current weather for a location', async () => {
      const result = await client.callTool('get-current-weather', {
        location: 'San Francisco, CA',
      })

      const weather = JSON.parse(result.content[0].text)
      expect(weather).toHaveProperty('temperature')
      expect(weather).toHaveProperty('conditions')
      expect(weather).toHaveProperty('humidity')
      expect(weather.temperature).toBeGreaterThan(-50)
      expect(weather.temperature).toBeLessThan(150)
    })

    it('should get 7-day forecast', async () => {
      const result = await client.callTool('get-forecast', {
        location: 'New York, NY',
        days: 7,
      })

      const forecast = JSON.parse(result.content[0].text)
      expect(forecast.days).toHaveLength(7)
      forecast.days.forEach(day => {
        expect(day).toHaveProperty('date')
        expect(day).toHaveProperty('high')
        expect(day).toHaveProperty('low')
        expect(day).toHaveProperty('conditions')
      })
    })

    it('should handle invalid locations', async () => {
      await expect(
        client.callTool('get-current-weather', {
          location: 'NonexistentCity, XX',
        })
      ).rejects.toThrow('Location not found')
    })

    it('should get weather alerts', async () => {
      const result = await client.callTool('get-alerts', {
        location: 'Miami, FL',
      })

      const alerts = JSON.parse(result.content[0].text)
      expect(alerts).toHaveProperty('active')
      expect(Array.isArray(alerts.active)).toBe(true)
    })
  })

  describe('Weather Resources', () => {
    it('should list weather data resources', async () => {
      const resources = await client.listResources()

      const uris = resources.map(r => r.uri)
      expect(uris).toContain('weather://config')
      expect(uris).toContain('weather://locations')
    })

    it('should read configuration resource', async () => {
      const resource = await client.readResource('weather://config')

      expect(resource.contents[0].mimeType).toBe('application/json')

      const config = JSON.parse(resource.contents[0].text)
      expect(config).toHaveProperty('units')
      expect(config).toHaveProperty('refreshInterval')
    })

    it('should read saved locations resource', async () => {
      const resource = await client.readResource('weather://locations')

      const locations = JSON.parse(resource.contents[0].text)
      expect(Array.isArray(locations)).toBe(true)
      locations.forEach(loc => {
        expect(loc).toHaveProperty('name')
        expect(loc).toHaveProperty('coordinates')
      })
    })
  })

  describe('Error Handling', () => {
    it('should handle API rate limits', async () => {
      // Make multiple rapid requests to trigger rate limit
      const promises = Array.from({ length: 100 }, () =>
        client.callTool('get-current-weather', { location: 'Test' })
      )

      await expect(Promise.all(promises)).rejects.toThrow()
    })

    it('should handle network failures', async () => {
      const offlineClient = await createTestClient({
        command: 'node',
        args: ['./dist/index.js'],
        env: {
          WEATHER_API_URL: 'http://localhost:99999', // invalid
        },
      })

      await expect(
        offlineClient.callTool('get-current-weather', {
          location: 'Test',
        })
      ).rejects.toThrow()

      await offlineClient.disconnect()
    })
  })

  describe('Performance', () => {
    it('should respond to weather requests quickly', async () => {
      const start = Date.now()
      await client.callTool('get-current-weather', {
        location: 'London, UK',
      })
      const duration = Date.now() - start

      expect(duration).toBeLessThan(3000) // 3 seconds max
    })

    it('should handle concurrent requests', async () => {
      const locations = [
        'New York, NY',
        'Los Angeles, CA',
        'Chicago, IL',
        'Houston, TX',
        'Phoenix, AZ',
      ]

      const promises = locations.map(location =>
        client.callTool('get-current-weather', { location })
      )

      const results = await Promise.all(promises)
      expect(results).toHaveLength(5)
      results.forEach(result => {
        const weather = JSON.parse(result.content[0].text)
        expect(weather).toHaveProperty('temperature')
      })
    })
  })
})

Debugging Failed Tests

When tests fail, the kit provides helpful error messages:

// Example: Debugging a failed tool call
try {
  await client.callTool('calculate', { operation: 'divide', a: 1, b: 0 })
} catch (error) {
  console.error('Tool call failed:', error.message)
  // Error: Division by zero not allowed

  console.error('Tool input:', error.toolInput)
  // { operation: 'divide', a: 1, b: 0 }

  console.error('Server stderr:', error.serverLogs)
  // [Detailed server-side logs]
}

Common debugging patterns:

describe('Debugging Tests', () => {
  it('should log server output on failure', async () => {
    const client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
      captureStderr: true, // Capture server logs
    })

    try {
      await client.callTool('failing-tool', {})
    } catch (error) {
      // Server logs available in error.serverLogs
      console.log('Server output:', error.serverLogs)
    }

    await client.disconnect()
  })

  it('should inspect raw JSON-RPC messages', async () => {
    const client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
      debug: true, // Enable debug logging
    })

    // Messages logged to console:
    // [DEBUG] Sent: {"jsonrpc":"2.0","method":"tools/list","id":1}
    // [DEBUG] Received: {"jsonrpc":"2.0","id":1,"result":{...}}

    await client.callTool('test', {})
    await client.disconnect()
  })
})

Best Practices

After using the kit extensively, I have learned these best practices:

1. Always Use beforeEach and afterEach

Create a fresh client for each test to avoid state pollution:

describe('Best Practice: Client Lifecycle', () => {
  let client

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  afterEach(async () => {
    // Clean up to avoid resource leaks
    await client.disconnect()
  })

  it('test 1', async () => {
    // Fresh client for this test
  })

  it('test 2', async () => {
    // Fresh client for this test
  })
})

2. Test Edge Cases Explicitly

Do not just test the happy path:

describe('Best Practice: Edge Cases', () => {
  it('should handle empty strings', async () => {
    const result = await client.callTool('echo', { message: '' })
    expect(result.content[0].text).toBe('')
  })

  it('should handle very large inputs', async () => {
    const largeMessage = 'a'.repeat(1000000) // 1MB
    const result = await client.callTool('echo', { message: largeMessage })
    expect(result.content[0].text).toBe(largeMessage)
  })

  it('should handle unicode characters', async () => {
    const unicode = '你好世界 🌍 مرحبا'
    const result = await client.callTool('echo', { message: unicode })
    expect(result.content[0].text).toBe(unicode)
  })

  it('should handle null and undefined', async () => {
    await expect(client.callTool('echo', { message: null })).rejects.toThrow()
  })
})

3. Use TypeScript for Type Safety

The kit provides full TypeScript support:

import { createTestClient, MCPTestClient } from '@crashbytes/mcp-test-kit'

describe('Best Practice: Type Safety', () => {
  let client: MCPTestClient

  beforeEach(async () => {
    client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })
  })

  it('should have typed responses', async () => {
    const tools = await client.listTools()
    // TypeScript knows tools is Tool[]

    const result = await client.callTool('echo', { message: 'test' })
    // TypeScript knows result is CallToolResult

    expect(result.content[0].type).toBe('text')
  })
})

4. Organize Tests by Feature

Group related tests logically:

describe('Weather MCP Server', () => {
  describe('Tools', () => {
    describe('Current Weather', () => {
      it('should get weather for valid location', async () => {})
      it('should handle invalid location', async () => {})
      it('should handle missing location', async () => {})
    })

    describe('Forecast', () => {
      it('should get 7-day forecast', async () => {})
      it('should get 14-day forecast', async () => {})
      it('should validate days parameter', async () => {})
    })
  })

  describe('Resources', () => {
    it('should list resources', async () => {})
    it('should read config', async () => {})
  })
})

5. Mock External Dependencies

Test your MCP server in isolation:

describe('Best Practice: Mocking', () => {
  it('should use mock API server', async () => {
    const mockServer = createMockServer(3000)
    mockServer.get('/weather', (req, res) => {
      res.json({ temperature: 72, conditions: 'sunny' })
    })
    await mockServer.start()

    const client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
      env: { API_URL: 'http://localhost:3000' },
    })

    const result = await client.callTool('get-weather', {})
    const weather = JSON.parse(result.content[0].text)
    expect(weather.temperature).toBe(72)

    await client.disconnect()
    await mockServer.stop()
  })
})

Migrating from Other Testing Solutions

If you are currently using another MCP testing approach, here is how to migrate:

From Manual Inspector Testing

Before:

  1. Launch npx @modelcontextprotocol/inspector
  2. Manually connect to server
  3. Click through UI to test tools
  4. Copy/paste results into bug reports

After:

describe('Migration from Inspector', () => {
  it('automates what you did manually', async () => {
    const client = await createTestClient({
      command: 'node',
      args: ['./dist/server.js'],
    })

    // Automated version of clicking "List Tools"
    const tools = await client.listTools()
    expect(tools.length).toBeGreaterThan(0)

    // Automated version of calling a tool
    const result = await client.callTool('echo', { message: 'test' })
    expect(result.content[0].text).toBe('test')

    await client.disconnect()
  })
})

From mcp-test-client

Before:

import { MCPTestClient } from 'mcp-test-client'

const client = new MCPTestClient({
  serverCommand: 'node',
  serverArgs: ['./dist/server.js'],
})
await client.init()

await client.assertToolCall('echo', { message: 'test' }, result => {
  expect(result.content[0].text).toBe('test')
})

await client.cleanup()

After:

import { createTestClient } from '@crashbytes/mcp-test-kit'

const client = await createTestClient({
  command: 'node',
  args: ['./dist/server.js'],
})

const result = await client.callTool('echo', { message: 'test' })
expect(result.content[0].text).toBe('test')

await client.disconnect()

From mcp-dev-kit

Before (required setup files):

// vitest.setup.ts
import { installMCPMatchers } from 'mcp-dev-kit/matchers'
installMCPMatchers()

// vitest.config.ts
export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./vitest.setup.ts'],
  },
})

// test file
import { MCPTestClient } from 'mcp-dev-kit/client'

After (zero config):

import { createTestClient } from '@crashbytes/mcp-test-kit'

// Just write tests, no setup needed

Why This Matters for Production

Let me be blunt: you cannot ship production MCP servers without automated tests.

Here is what happens when you skip testing:

  1. Silent Regressions: You refactor a tool's error handling. The tool now crashes instead of returning an error message. Clients start failing. Users complain.

  2. Breaking Changes: You update a resource URI pattern. Old clients still use the old pattern. They get 404s. You don't notice until production alerts fire.

  3. Performance Degradation: You add a new dependency. It introduces a 5-second delay on every tool call. You don't discover this until users report the application feels slow.

  4. Security Vulnerabilities: You update input validation logic. The new code allows SQL injection. You don't catch it until the database is compromised.

Automated testing prevents all of this. The kit makes it so easy that there is no excuse not to test.

The Future of MCP Testing

This is version 1.0 of @crashbytes/mcp-test-kit. The roadmap includes:

  • Browser Testing: Test MCP servers running in browser environments
  • Streamable HTTP Support: Test servers using HTTP transports in addition to stdio
  • Load Testing: Built-in utilities for performance benchmarking
  • Contract Testing: Verify MCP protocol compliance automatically
  • Visual Testing: Generate reports showing tool coverage and test results
  • Fuzzing: Automated discovery of edge cases and crashes

But I built this tool to solve my problems. If it solves yours too, that is the point. If you find bugs or need features, open an issue on GitHub at github.com/CrashBytes/mcp-test-kit.

Conclusion: Testing Is Not Optional

The Model Context Protocol is becoming critical infrastructure. Claude Desktop, Cursor, and countless other AI applications depend on MCP servers being reliable. When your server crashes or returns incorrect data, it breaks the entire chain.

You would not ship a REST API without tests. You would not deploy a database without validation. You should not ship an MCP server without a comprehensive test suite.

@crashbytes/mcp-test-kit makes testing trivial. Install it. Write tests. Ship confidently.

The MCP ecosystem needed this. Now it exists.

Last updated: 12/13/2025