chore: initialize qiming workspace repository
This commit is contained in:
483
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/bridge.test.ts
Normal file
483
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/bridge.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
145
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/detect.test.ts
Normal file
145
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/detect.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Unit tests: detect.ts — protocol auto-detection
|
||||
*
|
||||
* Spins up minimal HTTP servers to simulate Streamable HTTP and SSE endpoints
|
||||
* so detectProtocol can probe them.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import * as http from 'http';
|
||||
import { detectProtocol } from '../src/detect.js';
|
||||
import { MCP_SESSION_ID_HEADER } from '../src/constants.js';
|
||||
|
||||
/** Start an HTTP server that responds according to the handler, returns URL + close fn */
|
||||
async function startMockServer(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server = http.createServer(handler);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const addr = server.address() as { port: number };
|
||||
return {
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectProtocol', () => {
|
||||
const servers: Array<{ close: () => Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
await s.close();
|
||||
}
|
||||
servers.length = 0;
|
||||
});
|
||||
|
||||
it('detects streamable-http when server responds with JSON to POST', async () => {
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }));
|
||||
} else if (req.method === 'DELETE') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('stream');
|
||||
});
|
||||
|
||||
it('defaults to SSE when POST probe is rejected (e.g. 405)', async () => {
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
// Reject POST so streamable-http probe fails
|
||||
res.writeHead(405);
|
||||
res.end();
|
||||
} else if (req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
// Write initial SSE comment to keep connection alive briefly
|
||||
res.write(': keep-alive\n\n');
|
||||
// Don't end — SSE streams are long-lived; detectProtocol will abort
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('sse');
|
||||
});
|
||||
|
||||
it('defaults to sse when server rejects probe', async () => {
|
||||
const mock = await startMockServer((_req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('sse');
|
||||
});
|
||||
|
||||
it('defaults to sse when server is unreachable', async () => {
|
||||
// Use a port that's almost certainly not listening
|
||||
const result = await detectProtocol('http://127.0.0.1:1');
|
||||
expect(result).toBe('sse');
|
||||
});
|
||||
|
||||
it('passes custom headers to probes', async () => {
|
||||
let receivedAuth = '';
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
receivedAuth = req.headers['authorization'] as string;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }));
|
||||
} else if (req.method === 'DELETE') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
await detectProtocol(mock.url, { Authorization: 'Bearer test-token' });
|
||||
expect(receivedAuth).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('sends DELETE to clean up orphan session after streamable-http detection', async () => {
|
||||
let deleteReceived = false;
|
||||
let deleteSessionId = '';
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
[MCP_SESSION_ID_HEADER]: 'test-session-123',
|
||||
});
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }));
|
||||
} else if (req.method === 'DELETE') {
|
||||
deleteReceived = true;
|
||||
deleteSessionId = req.headers[MCP_SESSION_ID_HEADER] as string;
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('stream');
|
||||
// Poll for the fire-and-forget DELETE to arrive
|
||||
await vi.waitFor(() => {
|
||||
expect(deleteReceived).toBe(true);
|
||||
expect(deleteSessionId).toBe('test-session-123');
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Unit tests: filter.ts — tool filtering
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filterTools } from '../src/filter.js';
|
||||
import type { ToolFilter } from '../src/filter.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
function makeTool(name: string): Tool {
|
||||
return {
|
||||
name,
|
||||
description: `Tool: ${name}`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
}
|
||||
|
||||
const toolA = makeTool('tool-a');
|
||||
const toolB = makeTool('tool-b');
|
||||
const toolC = makeTool('tool-c');
|
||||
const allTools = [toolA, toolB, toolC];
|
||||
|
||||
describe('filterTools', () => {
|
||||
it('returns all tools when filter is empty', () => {
|
||||
const result = filterTools(allTools, {});
|
||||
expect(result).toEqual(allTools);
|
||||
});
|
||||
|
||||
it('returns all tools when allowTools is an empty set', () => {
|
||||
const result = filterTools(allTools, { allowTools: new Set() });
|
||||
expect(result).toEqual(allTools);
|
||||
});
|
||||
|
||||
it('returns all tools when denyTools is an empty set', () => {
|
||||
const result = filterTools(allTools, { denyTools: new Set() });
|
||||
expect(result).toEqual(allTools);
|
||||
});
|
||||
|
||||
it('filters by allowTools — only matching tools returned', () => {
|
||||
const filter: ToolFilter = { allowTools: new Set(['tool-a', 'tool-c']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(['tool-a', 'tool-c']);
|
||||
});
|
||||
|
||||
it('filters by denyTools — matching tools excluded', () => {
|
||||
const filter: ToolFilter = { denyTools: new Set(['tool-b']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(['tool-a', 'tool-c']);
|
||||
});
|
||||
|
||||
it('allowTools takes precedence over denyTools', () => {
|
||||
const filter: ToolFilter = {
|
||||
allowTools: new Set(['tool-a']),
|
||||
denyTools: new Set(['tool-a']),
|
||||
};
|
||||
const result = filterTools(allTools, filter);
|
||||
// allowTools wins — tool-a is allowed
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('tool-a');
|
||||
});
|
||||
|
||||
it('returns empty array when no tools match allowTools', () => {
|
||||
const filter: ToolFilter = { allowTools: new Set(['nonexistent']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when all tools match denyTools', () => {
|
||||
const filter: ToolFilter = { denyTools: new Set(['tool-a', 'tool-b', 'tool-c']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles empty tools array', () => {
|
||||
const filter: ToolFilter = { allowTools: new Set(['tool-a']) };
|
||||
const result = filterTools([], filter);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
66
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/fixtures/mock-mcp-server.mjs
vendored
Normal file
66
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/fixtures/mock-mcp-server.mjs
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Mock MCP Server — test fixture for qiming-mcp-stdio-proxy
|
||||
*
|
||||
* Usage:
|
||||
* node mock-mcp-server.mjs --tools '["tool-a","tool-b"]' --name 'my-server'
|
||||
*
|
||||
* Flags:
|
||||
* --tools <json> Array of tool names to register (default: ["mock-tool"])
|
||||
* --name <string> Server name (default: "mock-server")
|
||||
* --assert-no-env <VAR> Exit with error if env variable VAR is set (for env isolation tests)
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
// ---- Parse arguments ----
|
||||
|
||||
function getArg(flag) {
|
||||
const idx = process.argv.indexOf(flag);
|
||||
return idx >= 0 && idx + 1 < process.argv.length ? process.argv[idx + 1] : null;
|
||||
}
|
||||
|
||||
const toolNames = JSON.parse(getArg('--tools') || '["mock-tool"]');
|
||||
const serverName = getArg('--name') || 'mock-server';
|
||||
|
||||
// ---- Env assertion (for testing env isolation) ----
|
||||
|
||||
const assertNoEnv = getArg('--assert-no-env');
|
||||
if (assertNoEnv && process.env[assertNoEnv]) {
|
||||
process.stderr.write(
|
||||
`[${serverName}] ASSERTION FAILED: env var "${assertNoEnv}" should not be set (value: "${process.env[assertNoEnv]}")\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- Build tools ----
|
||||
|
||||
const tools = toolNames.map((name) => ({
|
||||
name,
|
||||
description: `Mock tool: ${name} (from ${serverName})`,
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
}));
|
||||
|
||||
// ---- Create server ----
|
||||
|
||||
const server = new Server(
|
||||
{ name: serverName, version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => ({
|
||||
content: [{ type: 'text', text: `${serverName}:${request.params.name}` }],
|
||||
}));
|
||||
|
||||
// ---- Start ----
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.stderr.write(`[${serverName}] Mock MCP server running with tools: ${toolNames.join(', ')}\n`);
|
||||
586
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/index.test.ts
Normal file
586
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/index.test.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* Integration tests: qiming-mcp-stdio-proxy
|
||||
*
|
||||
* Tests the proxy as a whole by spawning it as a child process and communicating
|
||||
* via MCP protocol (JSON-RPC over stdio). Uses a mock MCP server fixture for
|
||||
* child server simulation.
|
||||
*
|
||||
* Cross-platform: tests run on Windows, macOS, and Linux.
|
||||
* Requires `npm run build` before running (tests use compiled dist/index.js).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
||||
// ========== Paths ==========
|
||||
|
||||
const PROXY_SCRIPT = path.resolve('dist/index.js');
|
||||
const MOCK_SERVER = path.resolve('tests/fixtures/mock-mcp-server.mjs');
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/** Build a clean env Record<string, string> from process.env (filter undefined) */
|
||||
function cleanEnv(extra?: Record<string, string>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
return { ...env, ...extra };
|
||||
}
|
||||
|
||||
/** Spawn proxy process and collect exit code + stderr */
|
||||
function spawnProxy(
|
||||
args: string[],
|
||||
options?: { env?: Record<string, string>; timeoutMs?: number },
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('node', [PROXY_SCRIPT, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: options?.env,
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
resolve({ code, stderr });
|
||||
});
|
||||
|
||||
// Safety timeout
|
||||
const timeout = options?.timeoutMs ?? 10000;
|
||||
setTimeout(() => proc.kill(), timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/** Create config JSON for a single mock MCP server */
|
||||
function singleServerConfig(
|
||||
toolNames: string[] = ['mock-tool'],
|
||||
serverName = 'mock',
|
||||
extra?: { assertNoEnv?: string },
|
||||
): string {
|
||||
const args = [MOCK_SERVER, '--tools', JSON.stringify(toolNames), '--name', serverName];
|
||||
if (extra?.assertNoEnv) {
|
||||
args.push('--assert-no-env', extra.assertNoEnv);
|
||||
}
|
||||
return JSON.stringify({
|
||||
mcpServers: { [serverName]: { command: 'node', args } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Create config JSON for multiple mock MCP servers */
|
||||
function multiServerConfig(
|
||||
servers: { name: string; tools: string[]; assertNoEnv?: string }[],
|
||||
): string {
|
||||
const mcpServers: Record<string, { command: string; args: string[] }> = {};
|
||||
for (const s of servers) {
|
||||
const args = [MOCK_SERVER, '--tools', JSON.stringify(s.tools), '--name', s.name];
|
||||
if (s.assertNoEnv) args.push('--assert-no-env', s.assertNoEnv);
|
||||
mcpServers[s.name] = { command: 'node', args };
|
||||
}
|
||||
return JSON.stringify({ mcpServers });
|
||||
}
|
||||
|
||||
/** Connect an MCP client to the proxy (for E2E tests) */
|
||||
async function connectToProxy(
|
||||
configJson: string,
|
||||
extraEnv?: Record<string, string>,
|
||||
): Promise<Client> {
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [PROXY_SCRIPT, '--config', configJson],
|
||||
...(extraEnv ? { env: cleanEnv(extraEnv) } : {}),
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
}
|
||||
|
||||
// ========== Tests ==========
|
||||
|
||||
describe('qiming-mcp-stdio-proxy', () => {
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(PROXY_SCRIPT)) {
|
||||
throw new Error(
|
||||
`Build output not found at ${PROXY_SCRIPT}. Run "npm run build" first.`,
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(MOCK_SERVER)) {
|
||||
throw new Error(`Mock server fixture not found at ${MOCK_SERVER}.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Track clients for cleanup after each test
|
||||
const activeClients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const client of activeClients) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
activeClients.length = 0;
|
||||
});
|
||||
|
||||
// ---- CLI Error Handling (all platforms) ----
|
||||
|
||||
describe('CLI error handling', () => {
|
||||
it('exits with error when --config is missing', async () => {
|
||||
const { code, stderr } = await spawnProxy([]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Missing');
|
||||
expect(stderr).toContain('Usage:');
|
||||
});
|
||||
|
||||
it('exits with error for invalid JSON config', async () => {
|
||||
const { code, stderr } = await spawnProxy(['--config', '{not-json!!}']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Failed to parse --config JSON');
|
||||
});
|
||||
|
||||
it('exits with error when mcpServers key is missing', async () => {
|
||||
const { code, stderr } = await spawnProxy(['--config', '{"foo":"bar"}']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('must contain a "mcpServers" object');
|
||||
});
|
||||
|
||||
it('exits with error for empty mcpServers', async () => {
|
||||
const { code, stderr } = await spawnProxy(['--config', '{"mcpServers":{}}']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('No MCP servers configured');
|
||||
});
|
||||
|
||||
it('exits with error when all child servers fail to connect', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: {
|
||||
bad: { command: 'node', args: ['-e', 'process.exit(1)'], connectionTimeoutMs: 5000 },
|
||||
},
|
||||
});
|
||||
const { code, stderr } = await spawnProxy(['--config', config]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Failed to connect');
|
||||
expect(stderr).toContain('Failed to connect to any MCP server');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Tool Aggregation (all platforms) ----
|
||||
|
||||
describe('Tool aggregation — single server', () => {
|
||||
it('lists tools from a single child server', async () => {
|
||||
const client = await connectToProxy(
|
||||
singleServerConfig(['tool-alpha', 'tool-beta']),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['tool-alpha', 'tool-beta']);
|
||||
});
|
||||
|
||||
it('returns tool descriptions from child server', async () => {
|
||||
const client = await connectToProxy(singleServerConfig(['described-tool'], 'desc-srv'));
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools[0].description).toContain('described-tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool aggregation — multiple servers', () => {
|
||||
it('aggregates tools from multiple child servers', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'server-a', tools: ['tool-a1', 'tool-a2'] },
|
||||
{ name: 'server-b', tools: ['tool-b1'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['tool-a1', 'tool-a2', 'tool-b1']);
|
||||
});
|
||||
|
||||
it('handles tool name collision — last server wins, tool appears once', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'server-first', tools: ['shared-tool', 'unique-first'] },
|
||||
{ name: 'server-second', tools: ['shared-tool', 'unique-second'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
// shared-tool should appear only once (from server-second, which overwrites server-first)
|
||||
const sharedTools = tools.filter((t) => t.name === 'shared-tool');
|
||||
expect(sharedTools).toHaveLength(1);
|
||||
// Description should be from server-second (last writer)
|
||||
expect(sharedTools[0].description).toContain('server-second');
|
||||
// Total: shared-tool + unique-first + unique-second = 3
|
||||
expect(tools).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Tool Routing (all platforms) ----
|
||||
|
||||
describe('Tool routing', () => {
|
||||
it('routes tools/call to the correct child server', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'alpha', tools: ['tool-from-alpha'] },
|
||||
{ name: 'beta', tools: ['tool-from-beta'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
// Call tool-from-alpha → should be routed to "alpha" server
|
||||
const resultA = await client.callTool({ name: 'tool-from-alpha', arguments: {} });
|
||||
expect(resultA.content).toBeDefined();
|
||||
// Mock server responds with "serverName:toolName"
|
||||
const textA = (resultA.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(textA).toBe('alpha:tool-from-alpha');
|
||||
|
||||
// Call tool-from-beta → should be routed to "beta" server
|
||||
const resultB = await client.callTool({ name: 'tool-from-beta', arguments: {} });
|
||||
const textB = (resultB.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(textB).toBe('beta:tool-from-beta');
|
||||
});
|
||||
|
||||
it('returns error for unknown tool name', async () => {
|
||||
const client = await connectToProxy(singleServerConfig(['existing-tool']));
|
||||
activeClients.push(client);
|
||||
|
||||
const result = await client.callTool({ name: 'nonexistent-tool', arguments: {} });
|
||||
expect(result.isError).toBe(true);
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toContain('Unknown tool');
|
||||
expect(text).toContain('nonexistent-tool');
|
||||
});
|
||||
|
||||
it('routes collision tool to last-registered server', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'first', tools: ['colliding-tool'] },
|
||||
{ name: 'second', tools: ['colliding-tool'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const result = await client.callTool({ name: 'colliding-tool', arguments: {} });
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
// Should be routed to "second" (last writer wins)
|
||||
expect(text).toBe('second:colliding-tool');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Partial Startup (all platforms) ----
|
||||
|
||||
describe('Partial startup', () => {
|
||||
it('continues with remaining servers when one fails to connect', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: {
|
||||
'bad-server': {
|
||||
command: 'node',
|
||||
args: ['-e', 'process.exit(1)'],
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
'good-server': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["working-tool"]', '--name', 'good'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = await connectToProxy(config);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('working-tool');
|
||||
|
||||
// Verify the working tool is callable
|
||||
const result = await client.callTool({ name: 'working-tool', arguments: {} });
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toBe('good:working-tool');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Environment Handling (all platforms) ----
|
||||
|
||||
describe('Environment handling', () => {
|
||||
it('strips ELECTRON_RUN_AS_NODE from child server environment', async () => {
|
||||
// The mock server uses --assert-no-env to exit(1) if the var is set.
|
||||
// If the proxy correctly strips ELECTRON_RUN_AS_NODE, the mock server
|
||||
// starts successfully. If not stripped, mock server exits and proxy
|
||||
// fails to connect → client.connect() would throw.
|
||||
const config = singleServerConfig(['env-tool'], 'env-test', {
|
||||
assertNoEnv: 'ELECTRON_RUN_AS_NODE',
|
||||
});
|
||||
|
||||
const client = await connectToProxy(config, { ELECTRON_RUN_AS_NODE: '1' });
|
||||
activeClients.push(client);
|
||||
|
||||
// If we reach here, the mock server started → env was stripped
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('env-tool');
|
||||
});
|
||||
|
||||
it('merges per-server env variables into child environment', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["env-merge-tool"]', '--name', 'env-merge'],
|
||||
env: { CUSTOM_TEST_VAR: 'hello-from-config' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = await connectToProxy(config);
|
||||
activeClients.push(client);
|
||||
|
||||
// If env merge fails, child server won't start and this will throw
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Signal Handling (macOS/Linux only — Windows has no POSIX signals) ----
|
||||
|
||||
describe.skipIf(isWindows)('Signal handling (macOS/Linux)', () => {
|
||||
it('gracefully shuts down on SIGTERM', async () => {
|
||||
const config = singleServerConfig(['signal-tool']);
|
||||
|
||||
const proc = spawn('node', [PROXY_SCRIPT, '--config', config], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// Wait for proxy to be fully ready
|
||||
await new Promise<void>((resolve) => {
|
||||
const onData = (data: Buffer) => {
|
||||
if (data.toString().includes('Proxy server running')) {
|
||||
proc.stderr?.off('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
proc.stderr?.on('data', onData);
|
||||
// Timeout fallback
|
||||
setTimeout(resolve, 8000);
|
||||
});
|
||||
|
||||
// Send SIGTERM
|
||||
proc.kill('SIGTERM');
|
||||
|
||||
// Wait for exit
|
||||
const code = await new Promise<number | null>((resolve) => {
|
||||
proc.on('exit', (c) => resolve(c));
|
||||
setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(stderr).toContain('Received SIGTERM');
|
||||
expect(stderr).toContain('shutting down');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cross-Platform Smoke (all platforms) ----
|
||||
|
||||
describe('Cross-platform compatibility', () => {
|
||||
it('proxy starts and serves tools on current platform', async () => {
|
||||
// Basic smoke test: proxy + mock server work on current OS
|
||||
const client = await connectToProxy(
|
||||
singleServerConfig(['cross-platform-tool'], 'platform-test'),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'cross-platform-tool',
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toBe('platform-test:cross-platform-tool');
|
||||
});
|
||||
|
||||
it('handles config with special characters in tool names', async () => {
|
||||
const client = await connectToProxy(
|
||||
singleServerConfig(['tool-with-dashes', 'tool_with_underscores', 'tool.with.dots']),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual([
|
||||
'tool-with-dashes',
|
||||
'tool.with.dots',
|
||||
'tool_with_underscores',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Convert CLI error handling ----
|
||||
|
||||
describe('convert CLI error handling', () => {
|
||||
it('exits with error when no URL or --config provided', async () => {
|
||||
const { code, stderr } = await spawnProxy(['convert']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Either URL or --config is required');
|
||||
});
|
||||
|
||||
it('exits with error for invalid --protocol value', async () => {
|
||||
const { code, stderr } = await spawnProxy(['convert', 'http://example.com', '--protocol', 'invalid']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Invalid protocol');
|
||||
});
|
||||
|
||||
it('exits with error when both --allow-tools and --deny-tools used', async () => {
|
||||
const { code, stderr } = await spawnProxy([
|
||||
'convert', 'http://example.com',
|
||||
'--allow-tools', 'a',
|
||||
'--deny-tools', 'b',
|
||||
]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Cannot use both --allow-tools and --deny-tools');
|
||||
});
|
||||
|
||||
it('exits with error for unknown convert argument', async () => {
|
||||
const { code, stderr } = await spawnProxy(['convert', '--bad-flag']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Unknown argument');
|
||||
});
|
||||
|
||||
it('exits with error when --name not found in config', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: { svc: { url: 'http://example.com' } },
|
||||
});
|
||||
const { code, stderr } = await spawnProxy([
|
||||
'convert', '--config', config, '--name', 'nonexistent',
|
||||
]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('not found in config');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Proxy CLI error handling ----
|
||||
|
||||
describe('proxy CLI error handling', () => {
|
||||
it('exits with error when --port is missing', async () => {
|
||||
const config = singleServerConfig(['tool']);
|
||||
const { code, stderr } = await spawnProxy(['proxy', '--config', config]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('--port is required');
|
||||
});
|
||||
|
||||
it('exits with error when --config is missing', async () => {
|
||||
const { code, stderr } = await spawnProxy(['proxy', '--port', '9999']);
|
||||
expect(code).toBe(1);
|
||||
// CLI 支持 --config 或 --config-file,错误信息为 "--config or --config-file is required"
|
||||
expect(stderr).toContain('--config');
|
||||
expect(stderr).toContain('required');
|
||||
});
|
||||
|
||||
it('exits with error for invalid port', async () => {
|
||||
const config = singleServerConfig(['tool']);
|
||||
const { code, stderr } = await spawnProxy(['proxy', '--port', '70000', '--config', config]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Invalid port');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Proxy mode integration ----
|
||||
|
||||
describe('proxy mode', () => {
|
||||
it('starts HTTP server and serves tools via StreamableHTTP', async () => {
|
||||
const config = singleServerConfig(['proxy-tool'], 'proxy-mock');
|
||||
|
||||
// Start proxy in HTTP server mode (port 0 = random)
|
||||
const proc = spawn('node', [PROXY_SCRIPT, 'proxy', '--port', '0', '--config', config], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderrBuf = '';
|
||||
|
||||
// 1) 等待 HTTP 端口输出
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const tryMatch = () => {
|
||||
const match = stderrBuf.match(/HTTP server listening on 127\.0\.0\.1:(\d+)/);
|
||||
if (match) {
|
||||
resolve(parseInt(match[1], 10));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderrBuf += data.toString();
|
||||
tryMatch();
|
||||
});
|
||||
setTimeout(() => reject(new Error(`Timed out waiting for proxy server. stderr: ${stderrBuf}`)), 10000);
|
||||
});
|
||||
|
||||
// 2) 等待 bridge 就绪(子进程 spawn 与 listTools 完成后再接受 /mcp/<serverId> 请求)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (stderrBuf.includes('Bridge ready on port')) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onData = (data: Buffer) => {
|
||||
stderrBuf += data.toString();
|
||||
if (stderrBuf.includes('Bridge ready on port')) {
|
||||
proc.stderr?.off('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
proc.stderr?.on('data', onData);
|
||||
setTimeout(() => reject(new Error(`Timed out waiting for bridge ready. stderr: ${stderrBuf}`)), 15000);
|
||||
});
|
||||
|
||||
try {
|
||||
// Connect via StreamableHTTP to the bridge endpoint
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/proxy-mock`),
|
||||
);
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('proxy-tool');
|
||||
|
||||
const result = await client.callTool({ name: 'proxy-tool', arguments: {} });
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toBe('proxy-mock:proxy-tool');
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
} finally {
|
||||
proc.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
proc.on('exit', () => resolve());
|
||||
setTimeout(() => { proc.kill('SIGKILL'); resolve(); }, 3000);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Test: Remote MCP Server with Streamable HTTP (auth headers)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { writeFileSync, rmSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { buildRequestHeaders } from '../src/transport/headers.js';
|
||||
import type { StreamableServerEntry } from '../src/types.js';
|
||||
|
||||
// Test constants
|
||||
const TEST_CONFIG_DIR = join(tmpdir(), 'test-mcp-remote-headers');
|
||||
const TEST_URL = 'https://mcp.coze.cn/v1/plugins/7407724292865130515';
|
||||
|
||||
// 注意: 这是临时测试 token,不要固化到代码中
|
||||
const TEST_AUTH_TOKEN = process.env.TEST_AUTH_TOKEN || 'Bearer cztei_xxx';
|
||||
const TEST_HEADERS = {
|
||||
Authorization: TEST_AUTH_TOKEN,
|
||||
};
|
||||
|
||||
// Helper to create a test config file
|
||||
function createTestConfig(serverName: string, headers?: Record<string, string>): string {
|
||||
const configPath = join(TEST_CONFIG_DIR, `${serverName}.json`);
|
||||
const config = {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
headers: headers || TEST_HEADERS,
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(configPath, JSON.stringify(config), 'utf-8');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
// Helper to cleanup test files
|
||||
function cleanupTestFiles() {
|
||||
try {
|
||||
const files = readdirSync(TEST_CONFIG_DIR);
|
||||
for (const file of files) {
|
||||
rmSync(file);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildRequestHeaders', () => {
|
||||
beforeEach(() => {
|
||||
cleanupTestFiles();
|
||||
});
|
||||
|
||||
it('should return undefined when entry has no headers or authToken', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return headers with Bearer prefix when entry has authToken', () => {
|
||||
const rawToken = 'my-auth-token';
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
authToken: rawToken,
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({
|
||||
Authorization: `Bearer ${rawToken}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return headers directly when entry has headers', () => {
|
||||
const customHeaders = {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
Authorization: 'CustomAuth custom-token',
|
||||
};
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
headers: customHeaders,
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual(customHeaders);
|
||||
});
|
||||
|
||||
it('should let authToken override headers.Authorization when both are provided', () => {
|
||||
const customHeaders = {
|
||||
Authorization: 'CustomAuth custom-token',
|
||||
};
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
headers: customHeaders,
|
||||
authToken: 'override-token',
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
// authToken 会覆盖 headers 中的 Authorization
|
||||
expect(result).toEqual({
|
||||
Authorization: 'Bearer override-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when entry is a stdio entry (no url)', () => {
|
||||
const entry = {
|
||||
command: 'npx',
|
||||
args: ['-y', 'some-mcp-server'],
|
||||
} as unknown as StreamableServerEntry;
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProtocol with auth failure', () => {
|
||||
// 这个测试需要真实的网络请求, 仅用于调试目的
|
||||
// 注意: 不要在 CI 中启用, 因为 token 会过期
|
||||
it.skip('should handle 401 auth failure gracefully', async () => {
|
||||
const { detectProtocol } = await import('../src/detect.js');
|
||||
|
||||
const invalidToken = 'Bearer invalid-token-for-testing';
|
||||
const result = await detectProtocol(TEST_URL, {
|
||||
Authorization: invalidToken,
|
||||
});
|
||||
|
||||
// 即使鉴权失败, 也应该返回一个协议类型(默认 sse)
|
||||
expect(['sse', 'stream']).toContain(result);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Integration tests for ResilientTransportWrapper using real HTTP/SSE servers.
|
||||
*
|
||||
* These tests use the demo servers to simulate realistic network scenarios
|
||||
* including heartbeat concurrency, reconnection, and timing issues.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ResilientTransportWrapper } from '../src/resilient.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// ---- Mock HTTP Server for testing ----
|
||||
|
||||
class MockHttpMcpServer {
|
||||
private server: Server | null = null;
|
||||
private port: number;
|
||||
private requestDelay: number = 0;
|
||||
private shouldFail: boolean = false;
|
||||
private requestCount: number = 0;
|
||||
|
||||
constructor(port: number) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
setRequestDelay(ms: number) {
|
||||
this.requestDelay = ms;
|
||||
}
|
||||
|
||||
setShouldFail(fail: boolean) {
|
||||
this.shouldFail = fail;
|
||||
}
|
||||
|
||||
getRequestCount(): number {
|
||||
return this.requestCount;
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server = createServer(async (req, res) => {
|
||||
this.requestCount++;
|
||||
|
||||
// Simulate slow network
|
||||
if (this.requestDelay > 0) {
|
||||
await new Promise(r => setTimeout(r, this.requestDelay));
|
||||
}
|
||||
|
||||
if (this.shouldFail) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Server error' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read body
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString()) : {};
|
||||
|
||||
// Handle MCP requests
|
||||
if (body.method === 'initialize') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'mcp-session-id': randomUUID(),
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
serverInfo: { name: 'mock-server', version: '1.0' },
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.method === 'tools/list') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {
|
||||
tools: [
|
||||
{ name: 'echo', description: 'Echo tool', inputSchema: {} },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {},
|
||||
}));
|
||||
});
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('ResilientTransportWrapper - Integration Tests', () => {
|
||||
let mockServer: MockHttpMcpServer;
|
||||
const TEST_PORT = 19080;
|
||||
const TEST_URL = `http://127.0.0.1:${TEST_PORT}/mcp`;
|
||||
|
||||
beforeEach(() => {
|
||||
mockServer = new MockHttpMcpServer(TEST_PORT);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await mockServer.stop();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Heartbeat Concurrency Protection', () => {
|
||||
it('should prevent multiple concurrent health checks with slow network', async () => {
|
||||
// Start mock server with slow response
|
||||
mockServer.setRequestDelay(500); // 500ms delay per request
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCalls = 0;
|
||||
let concurrentCalls = 0;
|
||||
let maxConcurrentCalls = 0;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'concurrency-test',
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 10000, // Long timeout to not interfere
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
concurrentCalls++;
|
||||
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
|
||||
|
||||
// Simulate slow health check
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
concurrentCalls--;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first health check
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// At this point, first health check is in progress (started but not completed)
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Trigger more timer events while health check is in progress
|
||||
// These should be blocked by healthCheckInProgress guard
|
||||
await vi.advanceTimersByTimeAsync(1000); // Would trigger second check if not guarded
|
||||
await vi.advanceTimersByTimeAsync(1000); // Would trigger third check
|
||||
|
||||
// Complete the first health check
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Only 1 call should have started because of the guard
|
||||
expect(maxConcurrentCalls).toBe(1);
|
||||
|
||||
// After first check completes, next one can be scheduled
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should handle rapid heartbeat timer triggers without stacking', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
const healthCheckTimes: number[] = [];
|
||||
let startTime = Date.now();
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'stacking-test',
|
||||
pingIntervalMs: 500,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckTimes.push(Date.now() - startTime);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 10 "heartbeat cycles"
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(10); // Allow async to complete
|
||||
}
|
||||
|
||||
expect(healthCheckTimes.length).toBe(10);
|
||||
|
||||
// Verify each check is ~500ms apart (response-driven scheduling)
|
||||
for (let i = 1; i < healthCheckTimes.length; i++) {
|
||||
const diff = healthCheckTimes[i] - healthCheckTimes[i - 1];
|
||||
// Should be at least 500ms (pingIntervalMs), not stacked
|
||||
expect(diff).toBeGreaterThanOrEqual(500);
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response-Driven Scheduling', () => {
|
||||
it('should schedule next check only after current one completes', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let checkCompleted = false;
|
||||
let nextCheckScheduledBeforeComplete = false;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'response-driven-test',
|
||||
pingIntervalMs: 1000,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
// Check if next check was already scheduled (it shouldn't be)
|
||||
// This is a bit indirect, but we can verify by timing
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
checkCompleted = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first check
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// Check is in progress, not completed yet
|
||||
expect(checkCompleted).toBe(false);
|
||||
|
||||
// Complete the check
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(checkCompleted).toBe(true);
|
||||
|
||||
// Now next check should be scheduled 1000ms after completion
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
// Should not have triggered yet
|
||||
// (we'd need another counter to verify this properly)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
// Now it should trigger
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should not schedule next check when state changes during health check', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCount = 0;
|
||||
let shouldFailHealthCheck = false;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'state-change-test',
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCount++;
|
||||
if (shouldFailHealthCheck) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// First check passes
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(1);
|
||||
|
||||
// Make health checks fail
|
||||
shouldFailHealthCheck = true;
|
||||
|
||||
// Second check fails
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(2);
|
||||
|
||||
// Third check fails, triggers reconnect
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(3);
|
||||
|
||||
// After max failures, state should be reconnecting
|
||||
// No more health checks should run during reconnection
|
||||
const countBeforeReconnect = healthCheckCount;
|
||||
await vi.advanceTimersByTimeAsync(50); // During reconnection delay
|
||||
|
||||
// Should not have increased because state is reconnecting
|
||||
expect(healthCheckCount).toBe(countBeforeReconnect);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-World Scenarios', () => {
|
||||
it('should handle server restart with pending health checks', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCount = 0;
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'restart-test',
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 3,
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCount++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
// Simulate reconnection handling
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run a few successful heartbeats
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(healthCheckCount).toBe(2);
|
||||
|
||||
// Simulate server going down
|
||||
mockServer.setShouldFail(true);
|
||||
|
||||
// Wait for health checks to fail
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Bring server back up
|
||||
mockServer.setShouldFail(false);
|
||||
|
||||
// Wait for reconnection
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should maintain heartbeat rhythm despite slow health checks', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
const checkTimes: number[] = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'rhythm-test',
|
||||
pingIntervalMs: 1000,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
checkTimes.push(Date.now() - startTime);
|
||||
// Simulate variable health check duration
|
||||
await new Promise(r => setTimeout(r, Math.random() * 200));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 5 cycles
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(250); // Account for health check duration
|
||||
}
|
||||
|
||||
expect(checkTimes.length).toBe(5);
|
||||
|
||||
// Verify that checks don't stack up despite variable duration
|
||||
// Each check should start approximately 1000ms after the previous one completed
|
||||
for (let i = 1; i < checkTimes.length; i++) {
|
||||
const diff = checkTimes[i] - checkTimes[i - 1];
|
||||
// Should be around 1000ms + health check duration
|
||||
expect(diff).toBeGreaterThanOrEqual(1000);
|
||||
expect(diff).toBeLessThan(1500); // Not stacked
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
599
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/resilient.test.ts
Normal file
599
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/resilient.test.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ResilientTransportWrapper } from '../src/resilient.js';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
class MockTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
startCalls = 0;
|
||||
closeCalls = 0;
|
||||
sentMessages: JSONRPCMessage[] = [];
|
||||
shouldFailStart = false;
|
||||
shouldFailSend = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.startCalls++;
|
||||
if (this.shouldFailStart) {
|
||||
throw new Error('Mock start failure');
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls++;
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (this.shouldFailSend) {
|
||||
throw new Error('Mock send failure');
|
||||
}
|
||||
this.sentMessages.push(message);
|
||||
}
|
||||
|
||||
// Helper to simulate incoming messages
|
||||
simulateMessage(msg: JSONRPCMessage) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to simulate transport error
|
||||
simulateError(err: Error) {
|
||||
if (this.onerror) {
|
||||
this.onerror(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to simulate transport close
|
||||
simulateClose() {
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ResilientTransportWrapper', () => {
|
||||
let mockTransports: MockTransport[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mockTransports = [];
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createWrapper = (options = {}) => {
|
||||
return new ResilientTransportWrapper({
|
||||
name: 'test-wrapper',
|
||||
connectParams: async () => {
|
||||
const t = new MockTransport();
|
||||
mockTransports.push(t);
|
||||
return t;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
it('should connect successfully', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await wrapper.start();
|
||||
|
||||
expect(mockTransports.length).toBe(1);
|
||||
expect(mockTransports[0].startCalls).toBe(1);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should queue messages while reconnecting, then flush them', async () => {
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
await wrapper.start();
|
||||
|
||||
const firstTransport = mockTransports[0];
|
||||
|
||||
// Simulate drop
|
||||
firstTransport.simulateClose();
|
||||
|
||||
// Wrapper state should now be reconnecting
|
||||
|
||||
const testMsg: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: { a: 1 } };
|
||||
|
||||
// Send while reconnecting
|
||||
await wrapper.send(testMsg);
|
||||
|
||||
// Ensure it wasn't sent to the closed transport
|
||||
expect(firstTransport.sentMessages.length).toBe(0);
|
||||
|
||||
// Fast forward to trigger reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// A new transport should be created
|
||||
expect(mockTransports.length).toBe(2);
|
||||
const secondTransport = mockTransports[1];
|
||||
|
||||
// The queued message should have been flushed
|
||||
expect(secondTransport.sentMessages.length).toBe(1);
|
||||
expect(secondTransport.sentMessages[0]).toEqual(testMsg);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should reconnect if heartbeat fails repeatedly', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
return false; // Always fail health check
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first heartbeat failure
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(mockTransports.length).toBe(1); // Still same transport
|
||||
|
||||
// Trigger second heartbeat failure (maxConsecutiveFailures = 2)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Advance for reconnect delay
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should have reconnected (new transport created)
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should intercept inner transport errors and trigger reconnect', async () => {
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
await wrapper.start();
|
||||
|
||||
const firstTransport = mockTransports[0];
|
||||
firstTransport.simulateError(new Error('Test error'));
|
||||
|
||||
// Advance time for reconnect delay
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should have reconnected
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should pass transparent messages down to onmessage handler', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await wrapper.start();
|
||||
|
||||
let receivedMsg: JSONRPCMessage | null = null;
|
||||
wrapper.onmessage = (msg) => {
|
||||
receivedMsg = msg;
|
||||
};
|
||||
|
||||
const testMsg: JSONRPCMessage = { jsonrpc: '2.0', method: 'notify', params: { x: 1 } };
|
||||
mockTransports[0].simulateMessage(testMsg);
|
||||
|
||||
expect(receivedMsg).toEqual(testMsg);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should handle flushQueue errors by putting messages back', async () => {
|
||||
const wrapper = createWrapper({ maxQueueSize: 5 });
|
||||
await wrapper.start();
|
||||
|
||||
const firstTransport = mockTransports[0];
|
||||
firstTransport.simulateClose(); // puts it in reconnecting state
|
||||
|
||||
// Add messages to queue
|
||||
await wrapper.send({ jsonrpc: '2.0', method: 'test1' });
|
||||
await wrapper.send({ jsonrpc: '2.0', method: 'test2' });
|
||||
|
||||
// Advance time to reconnect
|
||||
let newTransportReady = false;
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
const spy = vi.spyOn(global, 'setTimeout').mockImplementationOnce((cb: any, ms?: number) => {
|
||||
// simulate the factory creating the second one and failing its send
|
||||
const id = originalSetTimeout(() => {
|
||||
cb();
|
||||
if (mockTransports.length > 1) {
|
||||
mockTransports[1].shouldFailSend = true;
|
||||
newTransportReady = true;
|
||||
}
|
||||
}, ms);
|
||||
return id as any;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
// Test the queue size management limit
|
||||
firstTransport.simulateClose();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await wrapper.send({ jsonrpc: '2.0', method: `spam${i}` });
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should throw when sending on a closed transport', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await wrapper.start();
|
||||
await wrapper.close();
|
||||
|
||||
await expect(wrapper.send({ jsonrpc: '2.0', method: 'test' })).rejects.toThrow('Transport is closed');
|
||||
});
|
||||
|
||||
it('should throw when initial connect fails', async () => {
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'fail-connect',
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
throw new Error('Initial network error');
|
||||
}
|
||||
});
|
||||
|
||||
// start() should throw on initial connection failure
|
||||
// Caller is responsible for retry logic
|
||||
await expect(wrapper.start()).rejects.toThrow('Initial network error');
|
||||
|
||||
// Clean up
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should call healthCheckFn for heartbeat', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 3,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Advance timer to trigger first heartbeat
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
// Allow async healthCheckFn to complete
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// healthCheckFn should have been called once
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Advance for another heartbeat
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should trigger reconnect when healthCheckFn fails repeatedly', async () => {
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
return false; // Always fail
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first heartbeat failure
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(mockTransports.length).toBe(1); // Still same transport
|
||||
|
||||
// Trigger second heartbeat failure (maxConsecutiveFailures = 2)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Advance for reconnect delay
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should have reconnected (new transport created)
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should trigger onreconnect event on reconnect', async () => {
|
||||
let onreconnectCalls = 0;
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
onreconnectCalls++;
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
|
||||
// Simulate disconnect
|
||||
mockTransports[0].simulateClose();
|
||||
|
||||
// Advance time for reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// onreconnect should have been called
|
||||
expect(onreconnectCalls).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should handle onreconnect failure with backoff', async () => {
|
||||
let reconnectAttempts = 0;
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
reconnectAttempts++;
|
||||
throw new Error('Reconnect handler failed');
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
|
||||
// Simulate disconnect
|
||||
mockTransports[0].simulateClose();
|
||||
|
||||
// Advance time for first reconnect attempt (100ms delay)
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(reconnectAttempts).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
// onreconnect failed, should retry with backoff (400ms = 100 * 2^2)
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
expect(reconnectAttempts).toBe(2);
|
||||
expect(mockTransports.length).toBe(3);
|
||||
|
||||
// Next retry with backoff (800ms = 100 * 2^3)
|
||||
await vi.advanceTimersByTimeAsync(850);
|
||||
expect(reconnectAttempts).toBe(3);
|
||||
expect(mockTransports.length).toBe(4);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should succeed when onreconnect handler succeeds', async () => {
|
||||
let reconnectAttempts = 0;
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
reconnectAttempts++;
|
||||
// Success - no throw
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
|
||||
// Simulate disconnect
|
||||
mockTransports[0].simulateClose();
|
||||
|
||||
// Advance time for reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// onreconnect should have succeeded
|
||||
expect(reconnectAttempts).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
// No more reconnects should happen
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(reconnectAttempts).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should use transport liveness check when no healthCheckFn provided', async () => {
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
// No healthCheckFn provided
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Trigger heartbeat - should pass because transport is alive
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// Should still be on same transport (health check passed via liveness)
|
||||
expect(mockTransports.length).toBe(1);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should prevent concurrent health check executions', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
let healthCheckResolve: () => void;
|
||||
let healthCheckPromise: Promise<boolean>;
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 5000, // Long timeout to test concurrency
|
||||
maxConsecutiveFailures: 3,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
// Return a promise that doesn't resolve immediately
|
||||
healthCheckPromise = new Promise((resolve) => {
|
||||
healthCheckResolve = () => resolve(true);
|
||||
});
|
||||
return healthCheckPromise;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first heartbeat - this will start but not complete
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// healthCheckFn should have been called once and is now pending
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Trigger more timers while health check is in progress
|
||||
// These should be skipped due to healthCheckInProgress guard
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Still only 1 call because the first one hasn't resolved yet
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Now resolve the health check
|
||||
healthCheckResolve!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now the next heartbeat can run
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should use response-driven scheduling (setTimeout not setInterval)', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
const healthCheckTimes: number[] = [];
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
healthCheckTimes.push(Date.now());
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 5 heartbeat cycles
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10); // Allow async to complete
|
||||
}
|
||||
|
||||
expect(healthCheckCalls).toBe(5);
|
||||
|
||||
// Verify timing - each check should be ~1000ms apart
|
||||
// (not stacked up like setInterval would do if checks were slow)
|
||||
for (let i = 1; i < healthCheckTimes.length; i++) {
|
||||
const diff = healthCheckTimes[i] - healthCheckTimes[i - 1];
|
||||
expect(diff).toBeGreaterThanOrEqual(1000);
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should not schedule next health check when state changes to reconnecting', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
return false; // Always fail
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first failure
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Trigger second failure - should trigger reconnect
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
// After hitting maxConsecutiveFailures, no more health checks should be scheduled
|
||||
// because state is now 'reconnecting'
|
||||
const callsBeforeReconnect = healthCheckCalls;
|
||||
|
||||
// Advance time - no new health checks should run during reconnect
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(healthCheckCalls).toBe(callsBeforeReconnect);
|
||||
|
||||
// Complete the reconnect
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// After reconnect, heartbeat should restart
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBeGreaterThan(callsBeforeReconnect);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should schedule next check in finally block only when connected', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
let shouldFailHealthCheck = true;
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
if (shouldFailHealthCheck) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger failures to cause reconnect
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now let health checks pass
|
||||
shouldFailHealthCheck = false;
|
||||
|
||||
// Complete reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should be reconnected now
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
// Heartbeat should resume with new transport
|
||||
const callsAfterReconnect = healthCheckCalls;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// New health check should have been scheduled after reconnect
|
||||
expect(healthCheckCalls).toBeGreaterThan(callsAfterReconnect);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
167
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/shared.test.ts
Normal file
167
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/shared.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Unit tests: shared.ts — discoverTools, createToolProxyServer, setupGracefulShutdown
|
||||
*
|
||||
* Uses mock MCP servers to test the shared helpers in isolation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { discoverTools, setupGracefulShutdown } from '../src/shared/index.js';
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
function makeTool(name: string): Tool {
|
||||
return {
|
||||
name,
|
||||
description: `Tool: ${name}`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a mock MCP server + connected client via in-memory transport */
|
||||
async function createMockServerClient(
|
||||
tools: Tool[],
|
||||
): Promise<{ server: Server; client: Client; close: () => Promise<void> }> {
|
||||
const server = new Server(
|
||||
{ name: 'mock', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name } = request.params;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `result:${name}` }],
|
||||
};
|
||||
});
|
||||
|
||||
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
return {
|
||||
server,
|
||||
client,
|
||||
close: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========== discoverTools ==========
|
||||
|
||||
describe('discoverTools', () => {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const fn of cleanups) {
|
||||
await fn();
|
||||
}
|
||||
cleanups.length = 0;
|
||||
});
|
||||
|
||||
it('discovers tools from a server with no tools', async () => {
|
||||
const { client, close } = await createMockServerClient([]);
|
||||
cleanups.push(close);
|
||||
|
||||
const tools = await discoverTools(client);
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('discovers all tools from a server', async () => {
|
||||
const mockTools = [makeTool('alpha'), makeTool('beta'), makeTool('gamma')];
|
||||
const { client, close } = await createMockServerClient(mockTools);
|
||||
cleanups.push(close);
|
||||
|
||||
const tools = await discoverTools(client);
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map((t) => t.name)).toEqual(['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('returns tool objects with correct properties', async () => {
|
||||
const mockTools = [makeTool('test-tool')];
|
||||
const { client, close } = await createMockServerClient(mockTools);
|
||||
cleanups.push(close);
|
||||
|
||||
const tools = await discoverTools(client);
|
||||
expect(tools[0]).toEqual({
|
||||
name: 'test-tool',
|
||||
description: 'Tool: test-tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== setupGracefulShutdown ==========
|
||||
|
||||
describe('setupGracefulShutdown', () => {
|
||||
let listeners: Map<string, ((...args: unknown[]) => void)[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
listeners = new Map();
|
||||
vi.spyOn(process, 'on').mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(handler);
|
||||
return process;
|
||||
});
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers SIGINT and SIGTERM handlers', () => {
|
||||
setupGracefulShutdown(async () => {});
|
||||
expect(listeners.has('SIGINT')).toBe(true);
|
||||
expect(listeners.has('SIGTERM')).toBe(true);
|
||||
});
|
||||
|
||||
it('calls cleanup function on signal', async () => {
|
||||
const cleanupFn = vi.fn().mockResolvedValue(undefined);
|
||||
setupGracefulShutdown(cleanupFn);
|
||||
|
||||
// Trigger SIGTERM handler
|
||||
const handler = listeners.get('SIGTERM')![0];
|
||||
await handler();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledOnce();
|
||||
expect(process.exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('only runs cleanup once even if signaled twice', async () => {
|
||||
const cleanupFn = vi.fn().mockResolvedValue(undefined);
|
||||
setupGracefulShutdown(cleanupFn);
|
||||
|
||||
const handler = listeners.get('SIGINT')![0];
|
||||
await handler();
|
||||
await handler();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('still exits when cleanup throws', async () => {
|
||||
const cleanupFn = vi.fn().mockRejectedValue(new Error('cleanup failed'));
|
||||
setupGracefulShutdown(cleanupFn);
|
||||
|
||||
const handler = listeners.get('SIGTERM')![0];
|
||||
await handler();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledOnce();
|
||||
expect(process.exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests: transport.ts — buildRequestHeaders
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRequestHeaders } from '../src/transport/index.js';
|
||||
import type { StreamableServerEntry, SseServerEntry } from '../src/types.js';
|
||||
|
||||
describe('buildRequestHeaders', () => {
|
||||
it('returns undefined when no headers or authToken', () => {
|
||||
const entry: StreamableServerEntry = { url: 'http://example.com' };
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes custom headers', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
headers: { 'X-Custom': 'value', 'X-Other': 'other' },
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({ 'X-Custom': 'value', 'X-Other': 'other' });
|
||||
});
|
||||
|
||||
it('adds Bearer authorization from authToken', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
authToken: 'my-secret-token',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({ Authorization: 'Bearer my-secret-token' });
|
||||
});
|
||||
|
||||
it('merges headers and authToken', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
authToken: 'token123',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({
|
||||
'X-Custom': 'value',
|
||||
Authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('authToken overrides Authorization in custom headers', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
headers: { Authorization: 'Basic abc' },
|
||||
authToken: 'bearer-wins',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result!.Authorization).toBe('Bearer bearer-wins');
|
||||
});
|
||||
|
||||
it('works with SseServerEntry', () => {
|
||||
const entry: SseServerEntry = {
|
||||
url: 'http://example.com/sse',
|
||||
transport: 'sse',
|
||||
headers: { 'X-SSE': 'yes' },
|
||||
authToken: 'sse-token',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({
|
||||
'X-SSE': 'yes',
|
||||
Authorization: 'Bearer sse-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
116
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/usage.test.ts
Normal file
116
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/usage.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Usage Examples & Integration Tests
|
||||
*
|
||||
* This test suite demonstrates how a downstream client (like an Electron app
|
||||
* or Agent OS manager) configuration translates to qiming-mcp-stdio-proxy execution.
|
||||
* It is adapted from real usage patterns in agent-electron-client.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
const PROXY_SCRIPT = path.resolve('dist/index.js');
|
||||
|
||||
/**
|
||||
* Helper to test CLI arg parsing and proxy behavior
|
||||
* without actually completing a full connection (we just check its stderr rejection to see if it understood args).
|
||||
*/
|
||||
function spawnProxy(
|
||||
args: string[],
|
||||
options?: { env?: Record<string, string>; timeoutMs?: number },
|
||||
): Promise<{ code: number | null; stderr: string; stdout: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('node', [PROXY_SCRIPT, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...options?.env }, // inherit to keep vitest happy
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
let stdout = '';
|
||||
|
||||
proc.stderr?.on('data', (d) => (stderr += d.toString()));
|
||||
proc.stdout?.on('data', (d) => (stdout += d.toString()));
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
resolve({ code, stderr, stdout });
|
||||
});
|
||||
|
||||
setTimeout(() => proc.kill(), options?.timeoutMs ?? 15000);
|
||||
});
|
||||
}
|
||||
|
||||
describe('Proxy Usage Examples (from agent-electron-client)', () => {
|
||||
it('Example 1: Downstream client creates temporary stdio proxy configuration', async () => {
|
||||
// In agent-electron-client, mcpProxyManager builds a config for the proxy
|
||||
// to aggregate multiple temporary MCP servers.
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'test-mcp': {
|
||||
command: 'node',
|
||||
args: ['-e', 'console.log("dummy-server")'],
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const configString = JSON.stringify(config);
|
||||
|
||||
// The proxy is then invoked with this config via standard stdio
|
||||
const { code, stderr } = await spawnProxy(['--config', configString]);
|
||||
|
||||
// We expect the proxy to start and immediately fail because our dummy server isn't a REAL MCP server
|
||||
// (it just prints 'dummy-server' and exits), but we can verify the proxy accepted the config
|
||||
// and attempted to spawn "test-mcp".
|
||||
expect(code).toBe(1); // Exits 1 because child server dies immediately
|
||||
expect(stderr).toContain('test-mcp');
|
||||
expect(stderr).toContain('Failed to connect to any MCP server');
|
||||
});
|
||||
|
||||
it('Example 2: Downstream client routes to a persistent bridge', async () => {
|
||||
// When the PersistentMcpBridge is running in agent-electron-client,
|
||||
// it maps standard subprocess configs to HTTP URLs.
|
||||
const proxyConfig = {
|
||||
mcpServers: {
|
||||
'chrome-devtools': {
|
||||
url: 'http://127.0.0.1:12345/mcp/chrome-devtools',
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const configString = JSON.stringify(proxyConfig);
|
||||
|
||||
// The downstream client spawns the proxy expecting it to convert that HTTP URL into stdio
|
||||
const { code, stderr } = await spawnProxy(['--config', configString]);
|
||||
|
||||
// It should fail to connect since port 12345 has no real server.
|
||||
// Protocol detection defaults to SSE, then SSE connection fails with ECONNREFUSED.
|
||||
expect(stderr).toContain('chrome-devtools');
|
||||
expect(stderr).toContain('Failed to connect');
|
||||
}, 20000);
|
||||
|
||||
it('Example 3: Downstream client mixed temporary and bridged configs', async () => {
|
||||
// agent-electron-client frequently mixes "temporary" (stdio) tools
|
||||
// with "persistent" (bridged) tools in the same proxy call.
|
||||
const mixedConfig = {
|
||||
mcpServers: {
|
||||
'local-tool': {
|
||||
command: 'node',
|
||||
args: ['-e', 'process.exit(1)'],
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
'remote-bridge': {
|
||||
url: 'http://127.0.0.1:12346/mcp/remote-bridge',
|
||||
connectionTimeoutMs: 5000,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { code, stderr } = await spawnProxy(['--config', JSON.stringify(mixedConfig)]);
|
||||
|
||||
// Proxy should try to aggregate both
|
||||
expect(stderr).toContain('local-tool');
|
||||
expect(stderr).toContain('Failed to connect');
|
||||
}, 20000);
|
||||
});
|
||||
Reference in New Issue
Block a user