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,209 @@
# MCP Resilient Transport 重连测试文档
## 背景
`ResilientTransportWrapper` 在 v1.4.10 中修复了一个关键 bug重连后未重放 MCP `initialize` 握手,导致服务端返回 `"Server not initialized"` 并进入无限重连循环。
本文档记录了修复后的测试流程与结果。
---
## 测试环境
| 项目 | 值 |
|------|-----|
| qiming-mcp-stdio-proxy | v1.4.11 |
| @modelcontextprotocol/sdk | v1.27.1 |
| Node.js | v22.14.0 |
| 平台 | macOS (Darwin 25.3.0) |
---
## 测试工具
位于 `demo/` 目录:
| 文件 | 说明 |
|------|------|
| `streamable-http-server.mjs` | Streamable HTTP MCP Server端口 18080 |
| `sse-server.mjs` | SSE MCP Server端口 18081 |
| `test-reconnect.mjs` | 一键重连测试脚本 |
---
## 快速测试
```bash
cd crates/qiming-mcp-stdio-proxy
# 跑全部streamable-http + sse
node demo/test-reconnect.mjs
# 只跑单个协议
node demo/test-reconnect.mjs streamable-http
node demo/test-reconnect.mjs sse
```
---
## 测试流程
### 自动化测试步骤
脚本自动完成以下步骤:
1. 启动 demo MCP server
2. 启动 mcp-proxyconvert 模式,心跳间隔 3s
3. 发送 `initialize` + `notifications/initialized` 握手
4. 发送 `tools/list` 验证连接正常
5. 等待首次心跳 OK
6. **SIGKILL 杀掉 server模拟崩溃**
7. 等待 proxy 检测断连并指数退避重试
8. **重启 server**
9. 等待 proxy 自动重连 + re-initialize + 心跳恢复
10. 再次发送 `tools/list` 验证连接恢复
### 输出示例
```
============================================================
MCP Resilient Transport 重连测试
模式: streamable-http, sse
============================================================
────────────────────────────────────────────────────────
测试: streamable-http (http://127.0.0.1:18080/mcp)
────────────────────────────────────────────────────────
[15:57:30.628] STEP 1 启动 MCP Server
[SERVER] 🚀 Streamable HTTP MCP Server listening on http://127.0.0.1:18080/mcp
[15:57:32.206] PROXY ✅ Connected via StreamableHTTPClientTransport
[15:57:34.137] RESP initialize OK -> protocol 2024-11-05
[15:57:35.232] PROXY 💖 Health check OK (count: 1)
[15:57:35.639] RESP tools/list -> 2 tools: [echo, time]
[15:57:41.641] STEP 6 SIGKILL 杀掉 server
[15:57:41.646] PROXY 🔄 Closed. Retrying in 1000ms (attempt 1)...
[15:57:42.647] PROXY 🔄 Invoking reconnect handler...
[15:57:42.647] PROXY ❌ Reconnect handler failed: TypeError: fetch failed
[15:57:49.642] STEP 8 重启 server
[15:57:50.668] PROXY ✅ Reconnect handler completed
[15:57:55.664] PROXY 💖 Health check OK (count: 1)
[15:58:03.148] RESP tools/list -> 2 tools: [echo, time]
────────────────────────────────────────────────────────
测试: sse (http://127.0.0.1:18081/sse)
────────────────────────────────────────────────────────
...
测试结果汇总
┌ ┬─────────────────┬─────────┐
│ 检查项 │ streamable-http │ sse │
├────────────────┼─────────────────┼─────────┤
│ 重连触发 │ ✅ PASS │ ✅ PASS │
│ Re-init 发送 │ ✅ PASS │ ✅ PASS │
│ Re-init 成功 │ ✅ PASS │ ✅ PASS │
│ 心跳恢复 │ ✅ PASS │ ✅ PASS │
└ ┴─────────────────┴─────────┘
总结: ✅ 全部通过!
```
### 手动测试
如需手动测试,可分三个终端操作:
```bash
# 终端 1: 启动 server
node demo/streamable-http-server.mjs # 或 sse-server.mjs
# 终端 2: 启动 proxyconvert 模式)
node dist/index.js convert http://127.0.0.1:18080/mcp \
--protocol stream --ping-interval 3000 --ping-timeout 2000
# 终端 3: 通过 stdin 发送 JSONRPC
echo '{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | ...
```
然后在终端 1 按 `Ctrl+C` 杀掉 server等待几秒后重新启动观察终端 2 的日志。
---
## 验证要点
### 修复前行为v1.4.9
```
Connected → heartbeat (tools/list) → "Server not initialized" →
onerror → triggerReconnect → Connected → heartbeat → 同样失败 → 无限循环(~21s 周期)
```
### 修复后行为v1.4.10
```
Connected → 🔄 Re-initializing MCP session → ✅ re-initialized →
heartbeat → 💖 OK → 正常服务
```
### 关键改动
1. **捕获 initialize 消息**`send()` 中拦截并存储 `initialize``notifications/initialized`
2. **重连后重放握手**`performConnect(!initial)` 中调用 `performReInitialize()`
3. **失败时保持退避** — re-initialize 失败不重置 `retryAttempt`,指数退避正常工作
4. **响应拦截**`respl-init-*` 前缀的响应不转发给下游 Client
5. **`cleanupTransport()` 提取** — 消除重复的 transport 清理代码
---
## 单元测试
```bash
cd crates/qiming-mcp-stdio-proxy
npx vitest run tests/resilient.test.ts tests/resilient-integration.test.ts
```
18/18 测试通过,覆盖场景:
### 基础单元测试 (resilient.test.ts)
| # | 测试场景 |
|---|----------|
| 1 | 正常连接 |
| 2 | 断连后消息队列 + flush |
| 3 | 心跳失败重连 |
| 4 | transport error 重连 |
| 5 | 消息透传 |
| 6 | 队列溢出处理 |
| 7 | 关闭后发送抛异常 |
| 8 | 初始连接失败重试 |
| 9 | 重连后 initialize 重放 |
| 10 | re-initialize 超时退避重试 |
| 11 | 无 initialize 时跳过重放 |
| 12 | re-initialize send 异常退避重试 |
| 13 | **并发健康检查防护 (v1.4.11)** |
| 14 | **响应驱动调度 (v1.4.11)** |
| 15 | **状态变化时不调度下次检查 (v1.4.11)** |
| 16 | **finally 块中仅在 connected 状态调度 (v1.4.11)** |
### 集成测试 (resilient-integration.test.ts)
| # | 测试场景 |
|---|----------|
| 1 | **慢网络下的并发保护 (v1.4.11)** |
| 2 | **快速定时器触发不堆积 (v1.4.11)** |
| 3 | **当前检查完成后才调度下次 (v1.4.11)** |
| 4 | **健康检查期间状态变化处理 (v1.4.11)** |
| 5 | **服务器重启场景 (v1.4.11)** |
| 6 | **可变检查时长下的心跳节奏 (v1.4.11)** |
---
## 退出码
| 退出码 | 说明 |
|--------|------|
| 0 | 全部通过 |
| 1 | 存在失败项 |
---
*测试日期: 2026-03-20*

View File

@@ -0,0 +1,101 @@
/**
* Demo: MCP SSE Server (Legacy)
*
* 用于本地测试 ResilientTransportWrapper 对 SSE transport 的重连 + re-initialize。
*
* 用法:
* node demo/sse-server.mjs [port]
*
* 默认监听:
* SSE 连接: GET http://127.0.0.1:18081/sse
* 消息接收: POST http://127.0.0.1:18081/message?sessionId=xxx
*
* 测试方式:
* 1. 启动此 server
* 2. 用 mcp-proxy 连接 http://127.0.0.1:18081/sse
* 3. Ctrl+C 杀掉 server → proxy 触发重连
* 4. 重新启动 server → proxy 自动 re-initialize + heartbeat 恢复
*/
import { createServer } from 'node:http';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
const PORT = parseInt(process.argv[2] || '18081', 10);
// --- MCP Server with demo tools ---
function createMcpServer() {
const server = new McpServer({
name: 'demo-sse',
version: '1.0.0',
});
server.tool('echo', 'Echo back the input', { message: { type: 'string' } }, async ({ message }) => ({
content: [{ type: 'text', text: `[echo] ${message}` }],
}));
server.tool('time', 'Return current server time', {}, async () => ({
content: [{ type: 'text', text: new Date().toISOString() }],
}));
return server;
}
// --- Session management ---
/** @type {Record<string, SSEServerTransport>} */
const sessions = {};
const httpServer = createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (url.pathname === '/sse' && req.method === 'GET') {
// New SSE connection
const transport = new SSEServerTransport('/message', res);
const sessionId = transport.sessionId;
sessions[sessionId] = transport;
console.log(`✅ SSE session created: ${sessionId}`);
transport.onclose = () => {
delete sessions[sessionId];
console.log(`🔴 SSE session closed: ${sessionId}`);
};
const mcpServer = createMcpServer();
// connect() internally calls transport.start(), do NOT call start() again
await mcpServer.connect(transport);
return;
}
if (url.pathname === '/message' && req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId || !sessions[sessionId]) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Invalid or missing sessionId' },
id: null,
}));
return;
}
// Read body
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = Buffer.concat(chunks).toString();
await sessions[sessionId].handlePostMessage(req, res, JSON.parse(body));
return;
}
res.writeHead(404);
res.end('Not Found');
});
httpServer.listen(PORT, '127.0.0.1', () => {
console.log(`🚀 SSE MCP Server listening on http://127.0.0.1:${PORT}/sse`);
console.log(` Tools: echo, time`);
console.log(` Ctrl+C to stop (simulate server crash for reconnect testing)`);
});

View File

@@ -0,0 +1,133 @@
/**
* Demo: MCP Streamable HTTP Server
*
* 用于本地测试 ResilientTransportWrapper 的重连 + re-initialize 行为。
*
* 用法:
* node demo/streamable-http-server.mjs [port]
*
* 默认监听 http://127.0.0.1:18080/mcp
*
* 测试方式:
* 1. 启动此 server
* 2. 用 mcp-proxy 连接 http://127.0.0.1:18080/mcp
* 3. Ctrl+C 杀掉 server → proxy 触发重连
* 4. 重新启动 server → proxy 自动 re-initialize + heartbeat 恢复
*/
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
const PORT = parseInt(process.argv[2] || '18080', 10);
// --- MCP Server with demo tools ---
function createMcpServer() {
const server = new McpServer({
name: 'demo-streamable-http',
version: '1.0.0',
});
server.tool('echo', 'Echo back the input', { message: { type: 'string' } }, async ({ message }) => ({
content: [{ type: 'text', text: `[echo] ${message}` }],
}));
server.tool('time', 'Return current server time', {}, async () => ({
content: [{ type: 'text', text: new Date().toISOString() }],
}));
return server;
}
// --- Session management ---
/** @type {Record<string, StreamableHTTPServerTransport>} */
const sessions = {};
const httpServer = createServer(async (req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (url.pathname !== '/mcp') {
res.writeHead(404);
res.end('Not Found');
return;
}
const sessionId = req.headers['mcp-session-id'];
if (req.method === 'POST') {
// Read body
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString());
if (sessionId && sessions[sessionId]) {
// Existing session
await sessions[sessionId].handleRequest(req, res, body);
return;
}
// New session (initialize request)
if (!sessionId && body?.method === 'initialize') {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
sessions[id] = transport;
console.log(`✅ Session created: ${id}`);
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete sessions[transport.sessionId];
console.log(`🔴 Session closed: ${transport.sessionId}`);
}
};
const mcpServer = createMcpServer();
await mcpServer.connect(transport);
await transport.handleRequest(req, res, body);
return;
}
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: Server not initialized' },
id: body?.id ?? null,
}));
return;
}
if (req.method === 'GET') {
// SSE stream for existing session
if (sessionId && sessions[sessionId]) {
await sessions[sessionId].handleRequest(req, res);
return;
}
res.writeHead(400);
res.end('Invalid session');
return;
}
if (req.method === 'DELETE') {
// Session termination
if (sessionId && sessions[sessionId]) {
await sessions[sessionId].handleRequest(req, res);
return;
}
res.writeHead(404);
res.end('Session not found');
return;
}
res.writeHead(405);
res.end('Method Not Allowed');
});
httpServer.listen(PORT, '127.0.0.1', () => {
console.log(`🚀 Streamable HTTP MCP Server listening on http://127.0.0.1:${PORT}/mcp`);
console.log(` Tools: echo, time`);
console.log(` Ctrl+C to stop (simulate server crash for reconnect testing)`);
});

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