Quick Takeaways
What you'll learn in this article
- 1
5 prompt templates with multiple variables
- 2
100+ distinct test cases for edge conditions
- 3
Pro: Official tool, great for exploration
- 4
Con: Completely manual, no automation, no CI/CD integration
- 5
Con: No regression detection across releases
Keep reading for detailed implementation, code examples, and real-world results
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:
- Zero-config simplicity: Install one package, write tests immediately
- Framework agnostic: Work with Vitest, Jest, Mocha, or any test runner
- Ergonomic API: Testing common patterns should be trivial
- Production-ready: Handle connection lifecycle, errors, timeouts automatically
- Type-safe: Full TypeScript support with inference
- 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:
- Launch npx @modelcontextprotocol/inspector
- Manually connect to server
- Click through UI to test tools
- 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:
-
Silent Regressions: You refactor a tool's error handling. The tool now crashes instead of returning an error message. Clients start failing. Users complain.
-
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.
-
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.
-
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.
Resources
- NPM Package: @crashbytes/mcp-test-kit
- GitHub Repository: github.com/CrashBytes/mcp-test-kit
- MCP Official Docs: modelcontextprotocol.io
- MCP TypeScript SDK: @modelcontextprotocol/sdk
Further Reading
- Building Production-Ready MCP Servers: Architecture Patterns and Best Practices
- The Model Context Protocol: Deep Dive into AI Tool Integration
- Automated Testing Strategies for AI Infrastructure
Subscribe for Updates
Follow CrashBytes for more tutorials on AI infrastructure, testing frameworks, and developer productivity tools. We publish in-depth technical content every week.
