chore: initialize qiming workspace repository
This commit is contained in:
209
qimingclaw/crates/qiming-mcp-stdio-proxy/demo/TESTING.md
Normal file
209
qimingclaw/crates/qiming-mcp-stdio-proxy/demo/TESTING.md
Normal 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-proxy(convert 模式,心跳间隔 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: 启动 proxy(convert 模式)
|
||||
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*
|
||||
101
qimingclaw/crates/qiming-mcp-stdio-proxy/demo/sse-server.mjs
Normal file
101
qimingclaw/crates/qiming-mcp-stdio-proxy/demo/sse-server.mjs
Normal 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)`);
|
||||
});
|
||||
@@ -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)`);
|
||||
});
|
||||
283
qimingclaw/crates/qiming-mcp-stdio-proxy/demo/test-reconnect.mjs
Normal file
283
qimingclaw/crates/qiming-mcp-stdio-proxy/demo/test-reconnect.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user