Files
qiming/qimingclaw/crates/qiming-mcp-stdio-proxy/demo/test-reconnect.mjs

284 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* MCP Resilient Transport 重连测试
*
* 自动测试 Streamable HTTP 和 SSE 两种传输协议的 重连 + re-initialize 流程。
*
* 用法:
* node demo/test-reconnect.mjs # 跑全部streamable-http + sse
* node demo/test-reconnect.mjs streamable-http # 只跑 streamable-http
* node demo/test-reconnect.mjs sse # 只跑 sse
*
* 流程(每种协议):
* 1. 启动 demo MCP server
* 2. 启动 mcp-proxy (convert 模式) 连接 server
* 3. 发送 initialize + initialized + tools/list 验证连接
* 4. 等心跳 OK
* 5. SIGKILL 杀掉 server模拟崩溃
* 6. 等 proxy 重试几轮
* 7. 重启 server
* 8. 验证 proxy 自动 re-initialize + 心跳恢复 + tools/list 正常
*/
import { fork, spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ---- 配置 ----
const MODES = {
'streamable-http': {
serverScript: join(__dirname, 'streamable-http-server.mjs'),
port: 18080,
url: 'http://127.0.0.1:18080/mcp',
protocol: 'stream',
},
'sse': {
serverScript: join(__dirname, 'sse-server.mjs'),
port: 18081,
url: 'http://127.0.0.1:18081/sse',
protocol: 'sse',
},
};
// ---- 工具函数 ----
function startServer(config) {
const p = fork(config.serverScript, [String(config.port)], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
cwd: ROOT,
});
p.stdout.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
console.log(` [SERVER] ${line}`);
}
});
p.stderr.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
console.log(` [SERVER:err] ${line}`);
}
});
return p;
}
function startProxy(config) {
const args = [
join(ROOT, 'dist/index.js'),
'convert',
config.url,
'--protocol', config.protocol,
'--ping-interval', '3000',
'--ping-timeout', '2000',
];
return spawn(process.execPath, args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: ROOT,
env: { ...process.env, MCP_PROXY_LOG_FILE: '' },
});
}
function sendJsonrpc(proxy, message) {
proxy.stdin.write(JSON.stringify(message) + '\n');
}
function ts() {
return new Date().toISOString().slice(11, 23);
}
async function killProc(p) {
if (!p || p.killed) return;
p.kill('SIGTERM');
await sleep(300);
try { p.kill('SIGKILL'); } catch {}
}
// ---- 单轮测试 ----
async function runTest(modeName, config) {
console.log(`\n ${'─'.repeat(56)}`);
console.log(` 测试: ${modeName} (${config.url})`);
console.log(` ${'─'.repeat(56)}`);
let serverProcess = null;
let proxyProcess = null;
const proxyLogs = [];
try {
// 1. 启动 server
console.log(` [${ts()}] STEP 1 启动 MCP Server`);
serverProcess = startServer(config);
await sleep(1500);
// 2. 启动 proxy
console.log(` [${ts()}] STEP 2 启动 mcp-proxy (convert)`);
proxyProcess = startProxy(config);
proxyProcess.stderr.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
proxyLogs.push(line);
if (/Connected|Heartbeat|Re-initializ|re-initialized|Closed|Retrying|Connect failed|error/i.test(line)) {
console.log(` [${ts()}] PROXY ${line.replace(/.*\[qiming-mcp-proxy\]\s*/, '')}`);
}
}
});
proxyProcess.stdout.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.result?.tools) {
console.log(` [${ts()}] RESP tools/list -> ${msg.result.tools.length} tools: [${msg.result.tools.map(t => t.name).join(', ')}]`);
} else if (msg.result?.protocolVersion) {
console.log(` [${ts()}] RESP initialize OK -> protocol ${msg.result.protocolVersion}`);
} else if (msg.error) {
console.log(` [${ts()}] RESP ERROR: ${msg.error.message}`);
}
} catch {}
}
});
await sleep(2000);
// 3. initialize 握手
console.log(` [${ts()}] STEP 3 发送 initialize 握手`);
sendJsonrpc(proxyProcess, {
jsonrpc: '2.0', id: 'init-1', method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
await sleep(1000);
sendJsonrpc(proxyProcess, { jsonrpc: '2.0', method: 'notifications/initialized' });
await sleep(500);
// 4. tools/list
console.log(` [${ts()}] STEP 4 发送 tools/list`);
sendJsonrpc(proxyProcess, { jsonrpc: '2.0', id: 'list-1', method: 'tools/list', params: {} });
await sleep(1000);
// 5. 等心跳
console.log(` [${ts()}] STEP 5 等待首次心跳...`);
await sleep(5000);
// 6. 杀 server
console.log(` [${ts()}] STEP 6 SIGKILL 杀掉 server`);
serverProcess.kill('SIGKILL');
serverProcess = null;
// 7. 等重连重试
console.log(` [${ts()}] STEP 7 等待 proxy 重试...`);
await sleep(8000);
// 8. 重启 server
console.log(` [${ts()}] STEP 8 重启 server`);
serverProcess = startServer(config);
await sleep(1500);
// 9. 等 re-initialize + 心跳恢复
console.log(` [${ts()}] STEP 9 等待 re-initialize + 心跳恢复...`);
await sleep(12000);
// 10. 验证恢复
console.log(` [${ts()}] STEP 10 发送 tools/list 验证恢复`);
sendJsonrpc(proxyProcess, { jsonrpc: '2.0', id: 'list-2', method: 'tools/list', params: {} });
await sleep(2000);
// ---- 分析 ----
const checks = {
reconnect: proxyLogs.some(l => /Retrying|Closed/i.test(l)),
reInitSent: proxyLogs.some(l => l.includes('Invoking reconnect handler') || l.includes('Reconnect handler')),
reInitOk: proxyLogs.some(l => l.includes('Reconnect handler completed')) || proxyLogs.some(l => l.includes('Connected') && l.includes('StreamableHTTP')),
heartbeatOk: proxyLogs.filter(l => l.includes('Heartbeat OK') || l.includes('💖 Health check OK')).length >= 1,
};
return checks;
} finally {
await killProc(proxyProcess);
await killProc(serverProcess);
await sleep(500);
}
}
// ---- 主流程 ----
const arg = process.argv[2];
const modesToRun = arg ? [arg] : Object.keys(MODES);
for (const m of modesToRun) {
if (!MODES[m]) {
console.error(`\n 未知模式: ${m} (可选: ${Object.keys(MODES).join(', ')})`);
process.exit(1);
}
}
console.log(`\n${'='.repeat(60)}`);
console.log(` MCP Resilient Transport 重连测试`);
console.log(` 模式: ${modesToRun.join(', ')}`);
console.log(`${'='.repeat(60)}`);
const results = {};
for (const m of modesToRun) {
results[m] = await runTest(m, MODES[m]);
}
// ---- 汇总表格 ----
const labels = {
reconnect: '重连触发',
reInitSent: 'Re-init 发送',
reInitOk: 'Re-init 成功',
heartbeatOk: '心跳恢复',
};
const allPass = modesToRun.every(m => Object.values(results[m]).every(Boolean));
// 计算列宽: 第一列=检查项, 后续列=每个 mode
const col0W = 14; // "Re-init 发送" 占 12 中文宽度,留余量
const colW = modesToRun.map(m => Math.max(m.length, 7)); // 至少 "✅ PASS".length
const totalW = col0W + colW.reduce((a, b) => a + b + 3, 0) + 1; // +3 " | ", +1 边框
const pad = (s, w) => {
// 中文字符算 2 宽度
const vis = [...s].reduce((n, c) => n + (c.charCodeAt(0) > 0x7f ? 2 : 1), 0);
return s + ' '.repeat(Math.max(0, w - vis));
};
const hline = (ch = '─', joint = '┼') => {
const segs = [ch.repeat(col0W + 2), ...colW.map(w => ch.repeat(w + 2))];
return `${segs.join(joint)}`;
};
const topline = () => {
const segs = [' '.repeat(col0W + 2), ...colW.map(w => '─'.repeat(w + 2))];
return `${segs.join('┬')}`;
};
const botline = () => {
const segs = [' '.repeat(col0W + 2), ...colW.map(w => '─'.repeat(w + 2))];
return `${segs.join('┴')}`;
};
console.log(`\n 测试结果汇总\n`);
// 表头
console.log(` ${topline()}`);
const headerCols = modesToRun.map((m, i) => ` ${pad(m, colW[i])} `);
console.log(`${pad('检查项', col0W)}${headerCols.join('│')}`);
console.log(` ${hline()}`);
// 数据行
for (const [key, label] of Object.entries(labels)) {
const dataCols = modesToRun.map((m, i) => {
const v = results[m][key] ? '✅ PASS' : '❌ FAIL';
return ` ${pad(v, colW[i])} `;
});
console.log(`${pad(label, col0W)}${dataCols.join('│')}`);
}
console.log(` ${botline()}`);
console.log(`\n 总结: ${allPass ? '✅ 全部通过!' : '❌ 存在失败项'}\n`);
process.exit(allPass ? 0 : 1);