484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
/**
|
||
* 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:
|
||
* 即使同时存在多个 session,stop() 也能正确完成并清理全部 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();
|
||
}
|
||
});
|
||
});
|