6.6 KiB
6.6 KiB
Testing Guide for TypeScript MCP Servers
Comprehensive testing strategies for MCP servers.
Testing Levels
- Unit Tests - Test tool logic in isolation
- Integration Tests - Test with MCP Inspector
- E2E Tests - Test with real MCP clients
1. Unit Testing with Vitest
Setup
npm install -D vitest @cloudflare/vitest-pool-workers
vitest.config.ts:
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' }
}
}
}
});
Test Example
src/tools/calculator.test.ts:
import { describe, it, expect } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
describe('Calculator Tool', () => {
it('should add two numbers', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
server.registerTool(
'add',
{
description: 'Adds numbers',
inputSchema: z.object({
a: z.number(),
b: z.number()
})
},
async ({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }]
})
);
const result = await server.callTool('add', { a: 5, b: 3 });
expect(result.content[0].text).toBe('8');
});
it('should handle errors', async () => {
const server = new McpServer({ name: 'test', version: '1.0.0' });
server.registerTool(
'divide',
{
description: 'Divides numbers',
inputSchema: z.object({ a: z.number(), b: z.number() })
},
async ({ a, b }) => {
if (b === 0) {
return {
content: [{ type: 'text', text: 'Division by zero' }],
isError: true
};
}
return { content: [{ type: 'text', text: String(a / b) }] };
}
);
const result = await server.callTool('divide', { a: 10, b: 0 });
expect(result.isError).toBe(true);
});
});
Run tests:
npx vitest
2. Integration Testing with MCP Inspector
Setup
# Terminal 1: Start dev server
wrangler dev
# Terminal 2: Launch inspector
npx @modelcontextprotocol/inspector
Inspector UI
- Connect to:
http://localhost:8787/mcp - View available tools/resources
- Test tool execution
- Inspect request/response
Automated Testing
import { test, expect } from '@playwright/test';
test('MCP server lists tools', async ({ page }) => {
await page.goto('http://localhost:8787');
const response = await page.request.post('http://localhost:8787/mcp', {
data: {
jsonrpc: '2.0',
method: 'tools/list',
id: 1
}
});
expect(response.ok()).toBeTruthy();
const data = await response.json();
expect(data.result.tools).toBeDefined();
expect(data.result.tools.length).toBeGreaterThan(0);
});
3. E2E Testing
With curl
# List tools
curl -X POST http://localhost:8787/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}'
# Call tool
curl -X POST http://localhost:8787/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "add",
"arguments": { "a": 5, "b": 3 }
},
"id": 2
}'
With TypeScript
import { test, expect } from 'vitest';
test('Full MCP flow', async () => {
const baseUrl = 'http://localhost:8787/mcp';
// List tools
const listResponse = await fetch(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
})
});
const listData = await listResponse.json();
expect(listData.result.tools).toBeDefined();
// Call tool
const callResponse = await fetch(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'add',
arguments: { a: 5, b: 3 }
},
id: 2
})
});
const callData = await callResponse.json();
expect(callData.result.content[0].text).toBe('8');
});
4. Testing Authentication
test('rejects without auth', async () => {
const response = await fetch('http://localhost:8787/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
})
});
expect(response.status).toBe(401);
});
test('accepts with valid API key', async () => {
const response = await fetch('http://localhost:8787/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-key-123'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
})
});
expect(response.status).toBe(200);
});
5. Load Testing
With Artillery
npm install -D artillery
artillery.yml:
config:
target: 'http://localhost:8787'
phases:
- duration: 60
arrivalRate: 10
scenarios:
- name: 'List tools'
flow:
- post:
url: '/mcp'
json:
jsonrpc: '2.0'
method: 'tools/list'
id: 1
npx artillery run artillery.yml
6. Mocking External APIs
import { vi } from 'vitest';
test('weather tool with mocked API', async () => {
// Mock fetch
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
main: { temp: 20 },
weather: [{ description: 'sunny' }]
})
});
const server = new McpServer({ name: 'test', version: '1.0.0' });
// Register weather tool...
const result = await server.callTool('get-weather', { city: 'London' });
expect(result.content[0].text).toContain('20');
});
CI/CD Testing
GitHub Actions:
name: Test MCP Server
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
- run: npm run build
# Integration test
- run: |
npm run dev &
sleep 5
curl -f http://localhost:8787/mcp \
-X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
Last Updated: 2025-10-28