284 lines
8.7 KiB
JavaScript
284 lines
8.7 KiB
JavaScript
/**
|
||
* 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);
|