MCP

MCP Server 测试策略:从单元测试到端到端验证

完整的 MCP Server 测试指南,涵盖单元测试、集成测试和端到端测试的最佳实践,确保你的 MCP 服务稳定可靠。

MCP Server 是 AI Agent 的手和眼——如果它不可靠,整个系统都会出问题。这篇文章将介绍如何为 MCP Server 构建完整的测试体系。

测试金字塔

MCP Server 的测试遵循经典的测试金字塔模型:

        ╱╲
       ╱E2E╲        少量端到端测试
      ╱──────╲
     ╱集成测试 ╲     适量集成测试
    ╱────────────╲
   ╱  单元测试    ╲   大量单元测试
  ╱────────────────╲

单元测试

单元测试验证单个工具函数的逻辑正确性,不依赖外部服务。

测试框架选择

TypeScript 项目推荐使用 Vitest:

npm install -D vitest

工具函数测试

首先将工具的业务逻辑与 MCP 协议分离:

// tools/calculator.ts
export function calculate(expression: string): number {
  // 安全的表达式解析
  const sanitized = expression.replace(/[^0-9+\-*/().]/g, '');
  if (!sanitized) throw new Error('无效的表达式');
  return Function(`"use strict"; return (${sanitized})`)();
}

// tools/calculator.test.ts
import { describe, it, expect } from 'vitest';
import { calculate } from './calculator';

describe('calculate', () => {
  it('应该正确计算加法', () => {
    expect(calculate('2 + 3')).toBe(5);
  });

  it('应该处理复杂表达式', () => {
    expect(calculate('(10 + 5) * 2')).toBe(30);
  });

  it('应该拒绝无效输入', () => {
    expect(() => calculate('rm -rf /')).toThrow('无效的表达式');
  });

  it('应该拒绝空表达式', () => {
    expect(() => calculate('')).toThrow();
  });
});

输入验证测试

// validators.ts
import { z } from 'zod';

export const QuerySchema = z.object({
  sql: z.string()
    .max(1000)
    .refine(s => s.trim().toUpperCase().startsWith('SELECT'))
    .refine(s => !/(DROP|DELETE|UPDATE)/i.test(s)),
});

// validators.test.ts
describe('QuerySchema', () => {
  it('应该接受有效的 SELECT 查询', () => {
    const result = QuerySchema.safeParse({ sql: 'SELECT * FROM users' });
    expect(result.success).toBe(true);
  });

  it('应该拒绝非 SELECT 查询', () => {
    const result = QuerySchema.safeParse({ sql: 'DROP TABLE users' });
    expect(result.success).toBe(false);
  });

  it('应该拒绝超长查询', () => {
    const result = QuerySchema.safeParse({ sql: 'SELECT ' + 'x'.repeat(1000) });
    expect(result.success).toBe(false);
  });
});

集成测试

集成测试验证 MCP 协议层面的交互,包括 Server 的初始化、工具发现和调用。

使用内存传输进行测试

MCP SDK 提供了内存传输,可以在不启动子进程的情况下测试 Server:

import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { createServer } from '../src/server.js';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';

describe('MCP Server 集成测试', () => {
  let client: Client;
  let server: ReturnType<typeof createServer>;

  beforeAll(async () => {
    const [clientTransport, serverTransport] = InMemoryTransport.createPair();
    
    server = createServer();
    await server.connect(serverTransport);

    client = new Client({ name: 'test', version: '1.0.0' });
    await client.connect(clientTransport);
  });

  afterAll(async () => {
    await client.close();
    await server.close();
  });

  it('应该正确列出工具', async () => {
    const { tools } = await client.listTools();
    expect(tools.length).toBeGreaterThan(0);
    expect(tools.map(t => t.name)).toContain('calculate');
  });

  it('应该正确执行工具调用', async () => {
    const result = await client.callTool({
      name: 'calculate',
      arguments: { expression: '2 + 3' },
    });
    expect(result.content[0].text).toBe('5');
  });

  it('应该正确处理错误', async () => {
    const result = await client.callTool({
      name: 'calculate',
      arguments: { expression: 'invalid' },
    });
    expect(result.isError).toBe(true);
  });

  it('应该拒绝未知工具', async () => {
    await expect(
      client.callTool({ name: 'nonexistent', arguments: {} })
    ).rejects.toThrow();
  });
});

资源和提示测试

describe('Resources', () => {
  it('应该列出资源', async () => {
    const { resources } = await client.listResources();
    expect(resources.length).toBeGreaterThan(0);
  });

  it('应该读取资源内容', async () => {
    const { contents } = await client.readResource('config://app');
    expect(contents[0].text).toBeTruthy();
  });
});

describe('Prompts', () => {
  it('应该列出提示模板', async () => {
    const { prompts } = await client.listPrompts();
    expect(prompts.length).toBeGreaterThan(0);
  });

  it('应该获取提示内容', async () => {
    const result = await client.getPrompt('analyze', { topic: 'AI' });
    expect(result.messages.length).toBeGreaterThan(0);
  });
});

端到端测试

端到端测试验证完整的使用场景,从 LLM 到工具执行。

模拟 LLM 调用

describe('端到端场景', () => {
  it('场景:文件搜索和读取', async () => {
    // 1. 搜索文件
    const searchResult = await client.callTool({
      name: 'search_files',
      arguments: { pattern: '*.md', directory: '/project' },
    });
    expect(searchResult.isError).toBeFalsy();

    const files = JSON.parse(searchResult.content[0].text);
    expect(files.length).toBeGreaterThan(0);

    // 2. 读取第一个文件
    const readResult = await client.callTool({
      name: 'read_file',
      arguments: { filePath: files[0] },
    });
    expect(readResult.isError).toBeFalsy();
    expect(readResult.content[0].text).toBeTruthy();
  });
});

性能测试

describe('性能测试', () => {
  it('工具调用延迟应在 100ms 内', async () => {
    const start = Date.now();
    await client.callTool({
      name: 'calculate',
      arguments: { expression: '1 + 1' },
    });
    const duration = Date.now() - start;
    expect(duration).toBeLessThan(100);
  });

  it('应支持并发调用', async () => {
    const calls = Array.from({ length: 10 }, (_, i) =>
      client.callTool({
        name: 'calculate',
        arguments: { expression: `${i} * 2` },
      })
    );

    const results = await Promise.all(calls);
    expect(results.every(r => !r.isError)).toBe(true);
  });
});

Mock 和 Stub

Mock 外部服务

import { vi } from 'vitest';

// Mock 数据库
vi.mock('../src/db', () => ({
  query: vi.fn().mockResolvedValue([{ id: 1, name: 'test' }]),
}));

// Mock 文件系统
vi.mock('fs/promises', () => ({
  readFile: vi.fn().mockResolvedValue('file content'),
  writeFile: vi.fn().mockResolvedValue(undefined),
}));

测试错误场景

describe('错误处理', () => {
  it('应该处理数据库连接失败', async () => {
    vi.mocked(db.query).mockRejectedValue(new Error('Connection refused'));
    
    const result = await client.callTool({
      name: 'query',
      arguments: { sql: 'SELECT 1' },
    });
    
    expect(result.isError).toBe(true);
    expect(result.content[0].text).toContain('连接');
  });
});

持续集成

在 CI 中运行 MCP 测试:

# .github/workflows/test.yml
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 test:integration

常见问题(FAQ)

需要测试 LLM 的行为吗?

不需要。测试应该聚焦于 MCP Server 的行为——工具是否正确执行、错误是否正确处理。LLM 的决策逻辑不在 Server 的测试范围内。

如何测试需要认证的工具?

使用环境变量或 mock 提供认证凭据。不要在测试代码中硬编码真实的密钥。

测试覆盖率目标是多少?

工具函数和验证逻辑应该达到 90% 以上的覆盖率。协议层的交互测试覆盖主要场景即可。

总结

MCP Server 的测试分为三个层次:单元测试验证业务逻辑,集成测试验证协议交互,端到端测试验证完整场景。关键是将业务逻辑与协议层分离,使得每一层都可以独立测试。