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,283 @@
/**
* 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);