chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View 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
* 即使同时存在多个 sessionstop() 也能正确完成并清理全部 session。
*/
it('stop() closes multiple active HTTP sessions in parallel', async () => {
bridge = new PersistentMcpBridge();
await bridge.start({
'mock': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["tool-x","tool-y"]', '--name', 'multi-session'],
},
});
const url = bridge.getBridgeUrl('mock')!;
// 建立 3 个独立 HTTP session
const connections: Array<{ client: Client; transport: StreamableHTTPClientTransport }> = [];
for (let i = 0; i < 3; i++) {
const transport = new StreamableHTTPClientTransport(new URL(url));
const client = new Client({ name: `multi-session-client-${i}`, version: '1.0.0' });
await client.connect(transport);
connections.push({ client, transport });
}
// 3 个 session 建立期间bridge 应保持健康
expect(bridge.isServerHealthy('mock')).toBe(true);
// 调用 stop(),验证多 session 并行关闭后 bridge 完全停止
await bridge.stop();
expect(bridge.isRunning()).toBe(false);
expect(bridge.isServerHealthy('mock')).toBe(false);
expect(bridge.getBridgeUrl('mock')).toBeNull();
// 清理客户端stop 后 transport 可能已关闭,忽略错误)
for (const { client, transport } of connections) {
try { await client.close(); } catch { /* ignore */ }
try { await transport.close(); } catch { /* ignore */ }
}
});
// ---- Logger ----
it('logs lifecycle events via injected logger', async () => {
const logger = createTestLogger();
bridge = new PersistentMcpBridge(logger);
await bridge.start({
'mock': { command: 'node', args: [MOCK_SERVER] },
});
await bridge.stop();
const hasStarting = logger.messages.some((m) => m.includes('Starting with'));
const hasReady = logger.messages.some((m) => m.includes('Bridge ready'));
const hasStopped = logger.messages.some((m) => m.includes('Stopped'));
expect(hasStarting).toBe(true);
expect(hasReady).toBe(true);
expect(hasStopped).toBe(true);
});
// ---- Explicit port ----
it('start() with explicit port listens on that port', async () => {
bridge = new PersistentMcpBridge();
await bridge.start(
{
'mock': {
command: 'node',
args: [MOCK_SERVER, '--tools', '["port-tool"]', '--name', 'port-test'],
},
},
{ port: 18199 },
);
expect(bridge.isRunning()).toBe(true);
const url = bridge.getBridgeUrl('mock');
expect(url).toBe('http://127.0.0.1:18199/mcp/mock');
// Verify it's actually listening on that port by connecting
const transport = new StreamableHTTPClientTransport(new URL(url!));
const client = new Client({ name: 'port-test-client', version: '1.0.0' });
await client.connect(transport);
try {
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('port-tool');
} finally {
await client.close();
await transport.close();
}
});
});

View 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 });
});
});

View File

@@ -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);
});
});

View 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`);

View 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);
});
}
});
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});
});

View 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();
});
});

View 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);
});
});

View File

@@ -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',
});
});
});

View 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);
});