Files
qiming/qimingclaw/crates/qiming-mcp-stdio-proxy/tests/bridge.test.ts

484 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Smoke tests: PersistentMcpBridge
*
* Verifies the bridge lifecycle (start/stop), HTTP routing, tool proxying,
* and auto-restart behavior using the same mock MCP server fixture.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as path from 'path';
import * as net from 'net';
import { PersistentMcpBridge } from '../src/bridge.js';
import type { BridgeLogger } from '../src/bridge.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
/**
* 动态占用一个随机可用端口,返回端口号和释放函数。
* 用于模拟端口冲突,测试 start() 的容错清理逻辑。
*/
function occupyRandomPort(): Promise<{ port: number; release: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as net.AddressInfo;
resolve({
port: addr.port,
release: () => new Promise<void>((res) => server.close(() => res())),
});
});
server.on('error', reject);
});
}
const MOCK_SERVER = path.resolve('tests/fixtures/mock-mcp-server.mjs');
/** Silent logger that captures messages for assertions */
function createTestLogger(): BridgeLogger & { messages: string[] } {
const messages: string[] = [];
return {
messages,
info: (...args: unknown[]) => messages.push(`INFO: ${args.map(String).join(' ')}`),
warn: (...args: unknown[]) => messages.push(`WARN: ${args.map(String).join(' ')}`),
error: (...args: unknown[]) => messages.push(`ERROR: ${args.map(String).join(' ')}`),
};
}
describe('PersistentMcpBridge', () => {
let bridge: PersistentMcpBridge;
afterEach(async () => {
if (bridge) {
await bridge.stop();
}
});
// ---- Construction & initial state ----
it('isRunning() returns false before start()', () => {
bridge = new PersistentMcpBridge();
expect(bridge.isRunning()).toBe(false);
});
it('getBridgeUrl() returns null before start()', () => {
bridge = new PersistentMcpBridge();
expect(bridge.getBridgeUrl('anything')).toBeNull();
});
it('isServerHealthy() returns false for unknown server', () => {
bridge = new PersistentMcpBridge();
expect(bridge.isServerHealthy('nonexistent')).toBe(false);
});
it('accepts a custom logger', () => {
const logger = createTestLogger();
bridge = new PersistentMcpBridge(logger);
expect(bridge.isRunning()).toBe(false);
});
it('stop() is safe to call before start()', async () => {
bridge = new PersistentMcpBridge();
await expect(bridge.stop()).resolves.toBeUndefined();
});
// ---- Lifecycle with real mock server ----
it('start() spawns server and exposes HTTP bridge', async () => {
const logger = createTestLogger();
bridge = new PersistentMcpBridge(logger);
await bridge.start({
'mock': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["bridge-tool-a","bridge-tool-b"]', '--name', 'bridge-mock'],
},
});
expect(bridge.isRunning()).toBe(true);
expect(bridge.isServerHealthy('mock')).toBe(true);
const url = bridge.getBridgeUrl('mock');
expect(url).not.toBeNull();
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp\/mock$/);
});
it('getBridgeUrl() returns null for unknown server after start', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'mock': {
command: 'node',
args: [MOCK_SERVER],
},
});
expect(bridge.getBridgeUrl('nonexistent')).toBeNull();
});
it('stop() cleans up and sets isRunning to false', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'mock': {
command: 'node',
args: [MOCK_SERVER],
},
});
expect(bridge.isRunning()).toBe(true);
await bridge.stop();
expect(bridge.isRunning()).toBe(false);
expect(bridge.isServerHealthy('mock')).toBe(false);
expect(bridge.getBridgeUrl('mock')).toBeNull();
});
// ---- HTTP bridge integration ----
it('proxies tools via HTTP bridge (list + call)', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'mock': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["alpha"]', '--name', 'proxy-test'],
},
});
const url = bridge.getBridgeUrl('mock')!;
expect(url).toBeTruthy();
// Connect as an MCP client via StreamableHTTP
const transport = new StreamableHTTPClientTransport(new URL(url));
const client = new Client({ name: 'test-client', version: '1.0.0' });
await client.connect(transport);
try {
// List tools
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('alpha');
// Call tool
const result = await client.callTool({ name: 'alpha', arguments: {} });
expect(result.content).toEqual([
{ type: 'text', text: 'proxy-test:alpha' },
]);
} finally {
await client.close();
await transport.close();
}
});
it('returns 404 for invalid HTTP paths', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'mock': { command: 'node', args: [MOCK_SERVER] },
});
const url = bridge.getBridgeUrl('mock')!;
// Extract base URL (without /mcp/mock)
const baseUrl = url.replace('/mcp/mock', '');
const res = await fetch(`${baseUrl}/invalid`);
expect(res.status).toBe(404);
});
it('returns 503 for unknown server ID', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'mock': { command: 'node', args: [MOCK_SERVER] },
});
const url = bridge.getBridgeUrl('mock')!;
const baseUrl = url.replace('/mcp/mock', '');
const res = await fetch(`${baseUrl}/mcp/nonexistent`, { method: 'POST' });
expect(res.status).toBe(503);
});
// ---- Multiple servers (parallel start/stop) ----
/**
* 验证 3 个 server 并行启动start() 返回后全部健康,且各自 URL 正确路由。
* 如果内部退化为串行,此测试仍应通过(正确性不变),但性能会下降。
*/
it('starts 3 servers in parallel and all become healthy', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'svc-alpha': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["alpha-tool"]', '--name', 'alpha'],
},
'svc-beta': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["beta-tool"]', '--name', 'beta'],
},
'svc-gamma': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["gamma-tool"]', '--name', 'gamma'],
},
});
// 全部健康
expect(bridge.isServerHealthy('svc-alpha')).toBe(true);
expect(bridge.isServerHealthy('svc-beta')).toBe(true);
expect(bridge.isServerHealthy('svc-gamma')).toBe(true);
// URL 路由各自独立
expect(bridge.getBridgeUrl('svc-alpha')).toMatch(/\/mcp\/svc-alpha$/);
expect(bridge.getBridgeUrl('svc-beta')).toMatch(/\/mcp\/svc-beta$/);
expect(bridge.getBridgeUrl('svc-gamma')).toMatch(/\/mcp\/svc-gamma$/);
// 每个 server 只暴露自己的工具
for (const [serverId, toolName, namePrefix] of [
['svc-alpha', 'alpha-tool', 'alpha'],
['svc-beta', 'beta-tool', 'beta'],
['svc-gamma', 'gamma-tool', 'gamma'],
] as const) {
const url = bridge.getBridgeUrl(serverId)!;
const transport = new StreamableHTTPClientTransport(new URL(url));
const client = new Client({ name: 'parallel-test', version: '1.0.0' });
await client.connect(transport);
try {
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe(toolName);
const result = await client.callTool({ name: toolName, arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: `${namePrefix}:${toolName}` }]);
} finally {
await client.close();
await transport.close();
}
}
});
it('stops all servers and bridge becomes not running', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'stop-a': { command: 'node', args: [MOCK_SERVER, '--name', 'stop-a'] },
'stop-b': { command: 'node', args: [MOCK_SERVER, '--name', 'stop-b'] },
'stop-c': { command: 'node', args: [MOCK_SERVER, '--name', 'stop-c'] },
});
expect(bridge.isRunning()).toBe(true);
await bridge.stop();
// 并行停止后整体不再运行
expect(bridge.isRunning()).toBe(false);
// 每个 server 均已清理
expect(bridge.isServerHealthy('stop-a')).toBe(false);
expect(bridge.isServerHealthy('stop-b')).toBe(false);
expect(bridge.isServerHealthy('stop-c')).toBe(false);
expect(bridge.getBridgeUrl('stop-a')).toBeNull();
});
it('handles multiple concurrent servers', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'server-a': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["tool-a"]', '--name', 'a'],
},
'server-b': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["tool-b"]', '--name', 'b'],
},
});
expect(bridge.isServerHealthy('server-a')).toBe(true);
expect(bridge.isServerHealthy('server-b')).toBe(true);
const urlA = bridge.getBridgeUrl('server-a')!;
const urlB = bridge.getBridgeUrl('server-b')!;
expect(urlA).toContain('/mcp/server-a');
expect(urlB).toContain('/mcp/server-b');
// Verify each proxies its own tools
for (const [url, expectedTool, expectedPrefix] of [
[urlA, 'tool-a', 'a:tool-a'],
[urlB, 'tool-b', 'b:tool-b'],
] as const) {
const transport = new StreamableHTTPClientTransport(new URL(url));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
try {
const { tools } = await client.listTools();
expect(tools[0].name).toBe(expectedTool);
const result = await client.callTool({ name: expectedTool, arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: expectedPrefix }]);
} finally {
await client.close();
await transport.close();
}
}
});
// ---- Error handling ----
it('handles server spawn failure gracefully', async () => {
const logger = createTestLogger();
bridge = new PersistentMcpBridge(logger);
await bridge.start({
'bad': {
command: 'nonexistent-binary-that-does-not-exist',
args: [],
},
});
// Bridge should still be running (HTTP server started)
expect(bridge.isRunning()).toBe(true);
// But the server should not be healthy
expect(bridge.isServerHealthy('bad')).toBe(false);
expect(bridge.getBridgeUrl('bad')).toBeNull();
// Logger should capture the failure
const hasError = logger.messages.some((m) => m.includes('Failed to start server'));
expect(hasError).toBe(true);
});
/**
* 验证 start() 中 HTTP server 启动失败(端口冲突)时:
* 1. start() 应抛出异常
* 2. bridge 处于未运行状态isRunning = false
* 3. 所有 spawnAndConnect 完成后子进程被正确关闭(无孤儿进程)
* 4. bridge 实例回到可重用状态,可以重新正常启动
*/
it('start() throws and cleans up spawned processes when HTTP server fails to bind', async () => {
const { port, release } = await occupyRandomPort();
try {
const logger = createTestLogger();
bridge = new PersistentMcpBridge(logger);
// 使用被占用的端口 → startHttpServer 应抛 EADDRINUSE
await expect(
bridge.start(
{ 'mock': { command: 'node', args: [MOCK_SERVER, '--name', 'cleanup-test'] } },
{ port },
),
).rejects.toThrow();
// 失败后 bridge 必须处于未运行状态
expect(bridge.isRunning()).toBe(false);
expect(bridge.getBridgeUrl('mock')).toBeNull();
// 失败日志记录(来自 catch 块)
expect(logger.messages.some((m) => m.includes('启动失败'))).toBe(true);
} finally {
await release();
}
// 释放端口后,同一个 bridge 实例应能正常重新启动
await bridge.start({
'mock-retry': { command: 'node', args: [MOCK_SERVER, '--name', 'retry'] },
});
expect(bridge.isRunning()).toBe(true);
expect(bridge.isServerHealthy('mock-retry')).toBe(true);
});
/**
* 验证 stop() 并行关闭多个活跃 HTTP session
* 即使同时存在多个 sessionstop() 也能正确完成并清理全部 session。
*/
it('stop() closes multiple active HTTP sessions in parallel', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'mock': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["tool-x","tool-y"]', '--name', 'multi-session'],
},
});
const url = bridge.getBridgeUrl('mock')!;
// 建立 3 个独立 HTTP session
const connections: Array<{ client: Client; transport: StreamableHTTPClientTransport }> = [];
for (let i = 0; i < 3; i++) {
const transport = new StreamableHTTPClientTransport(new URL(url));
const client = new Client({ name: `multi-session-client-${i}`, version: '1.0.0' });
await client.connect(transport);
connections.push({ client, transport });
}
// 3 个 session 建立期间bridge 应保持健康
expect(bridge.isServerHealthy('mock')).toBe(true);
// 调用 stop(),验证多 session 并行关闭后 bridge 完全停止
await bridge.stop();
expect(bridge.isRunning()).toBe(false);
expect(bridge.isServerHealthy('mock')).toBe(false);
expect(bridge.getBridgeUrl('mock')).toBeNull();
// 清理客户端stop 后 transport 可能已关闭,忽略错误)
for (const { client, transport } of connections) {
try { await client.close(); } catch { /* ignore */ }
try { await transport.close(); } catch { /* ignore */ }
}
});
// ---- Logger ----
it('logs lifecycle events via injected logger', async () => {
const logger = createTestLogger();
bridge = new PersistentMcpBridge(logger);
await bridge.start({
'mock': { command: 'node', args: [MOCK_SERVER] },
});
await bridge.stop();
const hasStarting = logger.messages.some((m) => m.includes('Starting with'));
const hasReady = logger.messages.some((m) => m.includes('Bridge ready'));
const hasStopped = logger.messages.some((m) => m.includes('Stopped'));
expect(hasStarting).toBe(true);
expect(hasReady).toBe(true);
expect(hasStopped).toBe(true);
});
// ---- Explicit port ----
it('start() with explicit port listens on that port', async () => {
bridge = new PersistentMcpBridge();
await bridge.start(
{
'mock': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["port-tool"]', '--name', 'port-test'],
},
},
{ port: 18199 },
);
expect(bridge.isRunning()).toBe(true);
const url = bridge.getBridgeUrl('mock');
expect(url).toBe('http://127.0.0.1:18199/mcp/mock');
// Verify it's actually listening on that port by connecting
const transport = new StreamableHTTPClientTransport(new URL(url!));
const client = new Client({ name: 'port-test-client', version: '1.0.0' });
await client.connect(transport);
try {
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('port-tool');
} finally {
await client.close();
await transport.close();
}
});
});