chore: initialize qiming workspace repository
This commit is contained in:
153
qimingclaw/crates/qiming-mcp-stdio-proxy/AGENTS.md
Normal file
153
qimingclaw/crates/qiming-mcp-stdio-proxy/AGENTS.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# qiming-mcp-stdio-proxy (Agent 开发文档)
|
||||
|
||||
## 概述
|
||||
|
||||
`qiming-mcp-stdio-proxy` 是一个基于 TypeScript 编写的 MCP (Model Context Protocol) 代理程序。它主要用于解决在复杂环境(例如 Electron 应用或统一的 Agent OS 平台)中集成多个 MCP Server 时遇到的生命周期管理、启动时序以及僵尸进程等关键问题。
|
||||
|
||||
## 核心架构与运行模式
|
||||
|
||||
代理程序提供三种主要的运行模式,均可通过 CLI 命令行启动:
|
||||
|
||||
1. **stdio (默认聚合模式)**
|
||||
- **用途**:将多个上游的 MCP Server (可以是标准的基于子进程的 `stdio` 服务器,也可以是基于 HTTP 的 `bridge` 服务器) 汇聚成**单个**对下游暴露的 `stdio` MCP 接口。
|
||||
- **用法**:`qiming-mcp-stdio-proxy --config '{"mcpServers":{...}}'`
|
||||
- **数据流**:Agent (stdin/stdout) ↔ 代理 ↔ [上游 1 (stdio), 上游 2 (HTTP Bridge), ...]
|
||||
|
||||
2. **convert (协议转换模式)**
|
||||
- **用途**:连接到单个远程的 MCP 服务(通过 SSE 或 Streamable HTTP),并将其在本地作为标准的 `stdio` MCP 服务器暴露。
|
||||
- **用法**:`qiming-mcp-stdio-proxy convert http://remote-mcp-server/sse`
|
||||
|
||||
3. **proxy (持久化桥接模式, PersistentMcpBridge)**
|
||||
- **用途**:作为 Streamable HTTP Server 启动 `PersistentMcpBridge`。该模式会预先启动多个基于子进程的 MCP Server,并通过本地 HTTP 将它们暴露出来供下游连接。
|
||||
- **用法**:`qiming-mcp-stdio-proxy proxy --port 18099 --config '{"mcpServers":{...}}'`
|
||||
|
||||
## 解决的核心痛点:就绪状态与生命周期
|
||||
|
||||
根据 `fix-mcp-readiness-and-cleanup.md` 中的记录,如果 Agent 引擎直接使用原生的 `stdio` 子进程来启动 MCP Server,会遇到两个重大问题:
|
||||
|
||||
1. **首次对话的时序竞争(Timing Race)**:标准 `stdio` MCP 进程的启动、初始化及连接需要时间。如果在启动 MCP 进程后立刻发送 Prompt 给 Agent 引擎,工具列表可能还未加载完毕,导致 Agent 认为没有合适的工具可用。
|
||||
2. **退出时的僵尸进程问题**:在跨平台环境(如 Electron 应用的 Teardown 阶段)中,快速、可靠地清理所有子进程非常困难。
|
||||
|
||||
### `PersistentMcpBridge` 解决方案
|
||||
|
||||
`proxy` 模式和 `PersistentMcpBridge` 类的引入,将 MCP Server 的生命周期与即时的 Agent 对话会话(Session)解耦,从而彻底解决了上述问题:
|
||||
|
||||
1. **预热与阻塞启动**:Bridge 会预先 Spawn 所有配置的 MCP Server 子进程,并**阻塞等待**它们通过 `listTools()` 就绪检查。
|
||||
2. **HTTP 协议转换**:一旦就绪,它们将通过 Streamable HTTP 暴露 (例如 `http://127.0.0.1:<PORT>/mcp/<serverId>`)。
|
||||
3. **秒级连接**:当下游的 Agent 引擎开启新的对话会话时,只需通过 HTTP URL 进行连接。由于 Bridge 早已运行并且缓存了工具列表,此连接过程是**瞬间完成**的,完全消除了时序竞争。
|
||||
4. **集中式的退出清理**:Bridge 采用优雅终止(Graceful Termination)逻辑来统一编排并管理所有子进程的关闭,有效避免遗留孤儿进程。同时它还能追踪 HTTP 会话,自动清理失效连接。
|
||||
|
||||
## 关键文件导读
|
||||
|
||||
- `src/index.ts`: CLI 入口点、参数解析及模式路由逻辑。
|
||||
- `src/bridge.ts`: `PersistentMcpBridge` 的具体实现代码。
|
||||
- `src/customStdio.ts`: 自定义的 StdioClientTransport 实现,具备更健壮的子进程管理能力及降级处理(这对 Windows 环境下的进程终止尤为重要)。
|
||||
- `src/modes/`: 包含 `stdio`, `convert` 和 `proxy` 三种模式的具体实现逻辑。
|
||||
- `src/logger.ts`: 统一日志模块,同时输出到 stderr 和可选的日志文件。
|
||||
- `src/resilient.ts`: `ResilientTransportWrapper`,为 URL-based MCP Server 提供心跳监测、指数退避重连和请求队列。
|
||||
|
||||
## 日志系统 (logger.ts)
|
||||
|
||||
所有日志通过 `logger.ts` 统一输出,stdout 保留给 MCP JSON-RPC 通信。
|
||||
|
||||
### 输出通道
|
||||
|
||||
| 通道 | 说明 |
|
||||
|------|------|
|
||||
| **stderr** | 始终输出,Agent 引擎可捕获 |
|
||||
| **日志文件** | 通过环境变量 `MCP_PROXY_LOG_FILE` 启用,append 模式写入 |
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `MCP_PROXY_LOG_FILE` | 日志文件路径,设置后日志同时写入该文件 | `~/.qimingbot/logs/mcp-proxy.log` |
|
||||
|
||||
### 日志格式
|
||||
|
||||
```
|
||||
[2026-03-09 19:29:37.650] [info] [qiming-mcp-proxy] Proxy server running on stdio
|
||||
```
|
||||
|
||||
与 electron-log 格式一致:`[时间戳] [级别] [标签] 消息`
|
||||
|
||||
### Electron 集成
|
||||
|
||||
Electron 宿主无法直接捕获 proxy 的 stderr(proxy 由 ACP 引擎 spawn,是 ACP 的子进程)。
|
||||
集成方式:Electron 在 proxy 环境变量中设置 `MCP_PROXY_LOG_FILE`,然后通过 `fs.watchFile` tail 读取日志文件,逐行转发到 `electron-log`,最终出现在 `~/.qimingbot/logs/main.log`。
|
||||
|
||||
## 弹性传输层 (ResilientTransportWrapper)
|
||||
|
||||
`src/resilient.ts` 为 URL-based MCP Server(SSE / Streamable HTTP)提供连接弹性保障。
|
||||
|
||||
### 核心参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `pingIntervalMs` | 30000 | 心跳检测间隔 (ms),响应驱动调度 |
|
||||
| `maxConsecutiveFailures` | 3 | 连续失败次数阈值,达到后触发重连 |
|
||||
| `pingTimeoutMs` | 5000 | 单次心跳超时 (ms) |
|
||||
| `reconnectDelayMs` | 1000 | 退避基础延迟 (ms) |
|
||||
| `maxReconnectDelayMs` | 60000 | 退避延迟上限 (ms) |
|
||||
| `maxQueueSize` | 100 | 重连期间请求队列最大容量 |
|
||||
|
||||
### 心跳机制
|
||||
|
||||
- **响应驱动调度**: 使用 `setTimeout` 替代 `setInterval`,上一次心跳检查完成后才开始计时下一次,避免网络慢时请求堆积。
|
||||
- **并发保护**: `healthCheckInProgress` 标志位防止多个心跳检查同时执行。
|
||||
- **轻量级检查**: SSE/HTTP 连接依赖 transport 层的 `onclose`/`onerror` 回调监控连接健康状态,不再使用 `client.listTools()` 避免创建额外的 HTTP 连接。
|
||||
- **心跳间隔**: 默认 30 秒(从 20 秒增加),减少不必要的检查频率。
|
||||
|
||||
### 重连触发机制
|
||||
|
||||
**两种触发路径:**
|
||||
|
||||
| 触发源 | 延迟 | 说明 |
|
||||
|--------|------|------|
|
||||
| Transport `onclose`/`onerror` | **立即** | SSE 断开、网络错误等,立即触发重连 |
|
||||
| 心跳健康检查失败 | 连续 3 次 | 仅在使用 `healthCheckFn` 时生效(stdio 传输) |
|
||||
|
||||
**SSE/HTTP 传输**:由于不使用 `healthCheckFn`,心跳检查只是轻量级存活性验证(检查 `activeTransport !== null`),实际重连由 transport 层的 `onclose`/`onerror` 回调**立即触发**。
|
||||
|
||||
### 重连策略
|
||||
|
||||
- **指数退避**: `1s → 2s → 4s → 8s → 16s → 32s → 60s`(capped),与 Rust mcp-proxy 的 `CappedExponentialBackoff` 一致
|
||||
- **不限重试**: 初次连接和 heartbeat 触发的重连均不限次数
|
||||
- **成功重置**: 连接成功后 `retryAttempt` 重置为 0,退避延迟回归 1s
|
||||
|
||||
### 重连流程
|
||||
|
||||
```
|
||||
初次连接 / Heartbeat 失败 ×3
|
||||
↓
|
||||
关闭当前 transport(清理 handler、停止 heartbeat)
|
||||
↓
|
||||
指数退避等待(1s → 2s → ... → 60s)
|
||||
↓
|
||||
performConnect() 创建新 transport
|
||||
├── 成功 → state='connected',重置 retryAttempt=0,恢复 heartbeat
|
||||
└── 失败 → 继续退避重试(不限次数)
|
||||
```
|
||||
|
||||
### 状态机
|
||||
|
||||
```
|
||||
idle → connecting → connected ←→ reconnecting
|
||||
↓
|
||||
closed(永久停止)
|
||||
```
|
||||
|
||||
## 如何修改与扩展
|
||||
|
||||
- 若要添加新的 CLI 选项,请更新 `src/index.ts` 以及位于 `src/modes/` 下对应的模式文件。
|
||||
- 若需修改 Bridge 的 HTTP 路由策略或 Session 处理逻辑,请参考 `src/bridge.ts` 中的 `handleHttpRequest` 方法。
|
||||
|
||||
## 测试与质量保障
|
||||
|
||||
项目使用 Vitest 作为主要的测试框架。测试文件位于 `tests/` 目录中,主要覆盖组件的功能完整性、集成逻辑及内部封装(如 `ResilientTransportWrapper` 心跳检测和连接重试)。
|
||||
|
||||
运行相关指令获取覆盖率等指标:
|
||||
|
||||
- `npm run test`:以 Watch 模式启动测试监听。
|
||||
- `npm run test:run`:单次运行所有测试用例。
|
||||
- `npm run test:coverage`:运行所有测试用例并生成 v8 代码行级和分支覆盖率报告,输出结果将存在于根目录下的 `coverage/` 文件夹中。
|
||||
73
qimingclaw/crates/qiming-mcp-stdio-proxy/CHANGELOG.md
Normal file
73
qimingclaw/crates/qiming-mcp-stdio-proxy/CHANGELOG.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 更新日志 (Changelog)
|
||||
|
||||
本项目的所有显著更改都将记录在此文件中。
|
||||
|
||||
## [1.4.11] - 2026-03-20
|
||||
|
||||
### 修复 (Fixed)
|
||||
|
||||
- **心跳并发问题修复**: 解决了 `ResilientTransportWrapper` 中多个心跳检查同时执行的并发问题,该问题会导致创建多个 SSE/HTTP 连接。
|
||||
- 添加 `healthCheckInProgress` 标志位防止并发执行
|
||||
- `finally` 块中显式检查 `state === 'connected'` 后才调度下一次检查
|
||||
- 修复 `heartbeatTimer` 类型定义(`setInterval` → `setTimeout`)
|
||||
|
||||
### 改进 (Changed)
|
||||
|
||||
- **响应驱动心跳机制**: 将固定间隔 `setInterval` 改为响应驱动的 `setTimeout` 调度。上一次心跳检查完成后才开始计时下一次,避免网络慢时请求堆积。
|
||||
- **移除 listTools() 健康检查**: SSE/HTTP 连接不再使用 `client.listTools()` 进行心跳检查(该方法会创建新的 HTTP 连接),改为依赖 transport 层的 `onclose`/`onerror` 回调监控连接健康状态。
|
||||
- **心跳间隔调整**: 默认心跳间隔从 20s 增加到 30s,减少不必要的检查频率。
|
||||
|
||||
## [1.4.10] - 2026-03-10
|
||||
|
||||
### 修复 (Fixed)
|
||||
|
||||
- **重连后自动重放 MCP initialize 握手**: 修复了 `ResilientTransportWrapper` 重连后不重放 MCP `initialize` 握手导致服务端返回 `"Server not initialized"` 并进入无限重连循环(~21s 周期)的关键 bug。
|
||||
- 在 `send()` 中捕获 `initialize` 和 `notifications/initialized` 消息
|
||||
- 重连后自动调用 `performReInitialize()` 重放握手
|
||||
- re-initialize 失败时保持指数退避,不重置 `retryAttempt`
|
||||
- 使用 `respl-init-*` 前缀拦截内部握手响应,不转发给下游 Client
|
||||
|
||||
### 改进 (Changed)
|
||||
|
||||
- **`cleanupTransport()` 提取**: 将 transport 清理逻辑(detach handlers + close)提取为独立方法,消除 `performConnect` 和 `triggerReconnect` 中的重复代码。
|
||||
- **`retryAttempt` 重置时机修正**: 从 transport 连接成功后立即重置,改为 re-initialize 成功后才重置,确保指数退避在握手失败时正常工作。
|
||||
- **`pendingReInit` 泄漏修复**: `performReInitialize()` 中 `send()` 抛异常时正确清理 `pendingReInit`,避免悬空的超时定时器。
|
||||
|
||||
### 测试 (Tests)
|
||||
|
||||
- 新增 4 个单元测试覆盖 re-initialize 相关场景(重放、超时、send 异常、无 initialize 边界)
|
||||
- 修复 1 个已有的错误测试用例
|
||||
- 新增 `demo/` 目录:Streamable HTTP / SSE demo server + 一键重连测试脚本
|
||||
- 新增 `demo/TESTING.md` 测试文档
|
||||
|
||||
## [1.4.9] - 2026-03-09
|
||||
|
||||
### 新增 (Added)
|
||||
|
||||
- **日志文件输出 (File Logging)**: 新增 `MCP_PROXY_LOG_FILE` 环境变量支持。设置后,`logger.ts` 会将所有日志同时写入指定文件(append 模式),便于 Electron 宿主通过 tail 机制将 proxy stderr 日志转发到 `main.log`。
|
||||
- **日志时间戳 (Timestamps)**: 日志格式改为 `[YYYY-MM-DD HH:mm:ss.SSS] [level] [qiming-mcp-proxy] message`,与 electron-log 输出格式保持一致,方便日志对照排查。
|
||||
|
||||
### 改进 (Changed)
|
||||
|
||||
- **指数退避重连 (Exponential Backoff Reconnect)**: `ResilientTransportWrapper` 重连策略从固定延迟(3s)改为指数退避(1s → 2s → 4s → ... → 60s cap),与 Rust mcp-proxy 的 `CappedExponentialBackoff` 保持一致。
|
||||
- **初次连接失败自动重试**: `performConnect()` 初次连接失败不再抛出异常,而是进入指数退避重试循环,不限次数。此前初次连接失败会直接 `throw`,导致上游无法恢复。
|
||||
- **重连不限次数**: `triggerReconnect()` 和 `performConnect()` 均不限重试次数。通过 `retryAttempt` 计数器驱动退避延迟,连接成功后重置为 0。
|
||||
- **修复重连死循环 Bug**: `performConnect()` 失败后不再调用 `triggerReconnect()`(该方法有 `state === 'reconnecting'` 守卫导致重入失败),改为直接调度下一次 `performConnect()`。
|
||||
- **新增 `maxReconnectDelayMs` 选项**: 退避延迟上限,默认 60000ms (60s)。
|
||||
- **`start()` 幂等性增强**: 在 `reconnecting` 状态下调用 `start()` 也直接返回,避免重复连接。
|
||||
|
||||
## [1.4.7] - 2026-03-09
|
||||
|
||||
### 新增 (Added)
|
||||
|
||||
- **ResilientTransportWrapper (弹性传输包装层)**: 实现了一个全面的弹性传输层,专门针对 `sse` 和 `streamable-http` 协议在网络不稳定或服务端崩溃时的连接恢复能力。
|
||||
- **心跳监测 (Heartbeat monitoring)**: 将基于 JSON-RPC 的 `ping` 请求原生集成到传输层循环中,支持自定义检测间隔 (`pingIntervalMs`) 和超时时间 (`pingTimeoutMs`)。
|
||||
- **自动重连与队列 (Connection Retry Logic)**: 为中断的传输流添加了原生自动重连能力,并包含消息队列缓冲机制,以防止在临时断网期间丢失下游的 RPC 请求。
|
||||
- **Vitest 单元测试覆盖 (Vitest Testing)**: 新增针对 `ResilientTransportWrapper` 的专用单元测试,与现有的集成测试系统整合在一起。引入 `@vitest/coverage-v8`,实现了关键代码路径及分支近乎 100% 的覆盖率保障。
|
||||
- **文档与规范 (Documentation)**: 全面更新了 `AGENTS.md` 和 `README.md`,加入了测试、覆盖率统计和弹性传输层的官方说明与支持。
|
||||
|
||||
### 修复 (Fixed)
|
||||
|
||||
- 修复了因为下游的 `stdio` 服务器子进程初始化失败 (例如找不到二进制文件而触发 `ENOENT` 报错) 从而导致 Promise 未捕获异常并无限挂起进程的核心缺陷。
|
||||
- 修复了 `flushQueue` 时因不保证 Promise 顺序而产生的有效载荷竞态和乱序重发的问题。
|
||||
- 彻底消除了下游的远端 MCP 服务因 HTTP 边界失效导致无声无息断连,进而产生的僵尸请求和进程挂起问题。
|
||||
1
qimingclaw/crates/qiming-mcp-stdio-proxy/CLAUDE.md
Normal file
1
qimingclaw/crates/qiming-mcp-stdio-proxy/CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
||||
AGENTS.md
|
||||
559
qimingclaw/crates/qiming-mcp-stdio-proxy/MIGRATION.md
Normal file
559
qimingclaw/crates/qiming-mcp-stdio-proxy/MIGRATION.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# 接入替换文档:qiming-mcp-stdio-proxy → agent-electron-client
|
||||
|
||||
将 `crates/agent-electron-client` 中旧的 `mcp-stdio-proxy`(bin: `mcp-proxy`,Rust 二进制)替换为新的 `qiming-mcp-stdio-proxy`(纯 TypeScript/Node.js)。
|
||||
|
||||
---
|
||||
|
||||
## 一、旧 mcp-proxy 完整能力梳理
|
||||
|
||||
通过 `mcp-proxy --help` 确认,旧 Rust 二进制(v0.1.54)支持以下子命令:
|
||||
|
||||
| 子命令 | 方向 | 用途 |
|
||||
|--------|------|------|
|
||||
| `proxy` | **stdio → Streamable HTTP/SSE** | 将本地 stdio MCP server 暴露为 HTTP 服务 |
|
||||
| `convert` | **Streamable HTTP/SSE → stdio** | 将远程 HTTP MCP 服务转换为本地 stdio |
|
||||
| `detect` | — | 自动检测远程服务协议类型 |
|
||||
| `check` | — | 检查远程服务可用性 |
|
||||
| `health` | — | 健康检查(MCP 握手级别) |
|
||||
|
||||
### `proxy` 子命令详情
|
||||
|
||||
```bash
|
||||
mcp-proxy proxy --port <PORT> --host <HOST> --config '<mcpServers JSON>'
|
||||
[--protocol sse|stream] # 默认 stream (Streamable HTTP)
|
||||
[--log-dir <DIR>]
|
||||
[--diagnostic]
|
||||
```
|
||||
|
||||
- 启动 HTTP 服务器,聚合 `--config` 中定义的 stdio MCP servers
|
||||
- 对外暴露 Streamable HTTP(默认)或 SSE 端点
|
||||
- 支持 `--allow-tools` / `--deny-tools` 工具过滤
|
||||
|
||||
### `convert` 子命令详情
|
||||
|
||||
```bash
|
||||
mcp-proxy convert <URL>
|
||||
[--config '<mcpServers JSON>'] # 也支持 config 模式
|
||||
[--auth "Bearer token"]
|
||||
[--protocol sse|stream]
|
||||
[--ping-interval <SEC>]
|
||||
```
|
||||
|
||||
- 将远程 HTTP/SSE MCP 服务转为本地 stdio 接口
|
||||
- Agent 引擎(claude-code / qimingcode)通过 stdio 消费
|
||||
|
||||
---
|
||||
|
||||
## 二、Tauri 客户端实际架构(参考基准)
|
||||
|
||||
Tauri 客户端使用的是 **stdio → Streamable HTTP → stdio** 双跳架构:
|
||||
|
||||
```
|
||||
┌─ Tauri Main Process ─────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 1. 启动 proxy 进程: │
|
||||
│ mcp-proxy proxy --port 18099 --host 127.0.0.1 │
|
||||
│ --config '{"mcpServers":{...}}' │
|
||||
│ --protocol stream │
|
||||
│ │
|
||||
│ 2. TCP 健康检查: │
|
||||
│ wait_for_mcp_proxy_ready(18099, "127.0.0.1", timeout=15s) │
|
||||
│ │
|
||||
│ 3. 注入 Agent MCP 配置: │
|
||||
│ { "mcp-proxy": { │
|
||||
│ "command": "mcp-proxy", │
|
||||
│ "args": ["convert", "http://127.0.0.1:18099"] │
|
||||
│ }} │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
|
||||
数据流:
|
||||
|
||||
Child MCP Servers (stdio × N)
|
||||
↑ StdioClientTransport
|
||||
│
|
||||
mcp-proxy proxy (Rust, 聚合 + Streamable HTTP 服务器)
|
||||
│ http://127.0.0.1:18099
|
||||
↓
|
||||
mcp-proxy convert http://127.0.0.1:18099 (Rust, HTTP → stdio)
|
||||
│ stdio
|
||||
↓
|
||||
Agent Engine (claude-code / qimingcode)
|
||||
```
|
||||
|
||||
**Tauri 这样做的原因**:
|
||||
- Rust 原生 `Command::new()` + `CREATE_NO_WINDOW` 避免 Windows 弹窗
|
||||
- 进程组管理(Unix: setsid, Windows: JobObject)
|
||||
- TCP 健康检查确认服务就绪
|
||||
|
||||
**存在的问题**:
|
||||
- 需要端口管理(默认 18099,冲突时需要 kill 旧进程)
|
||||
- 两层 mcp-proxy 进程(proxy + convert)
|
||||
- Electron 客户端复用此架构时,Rust 二进制 + `.cmd` wrapper 导致 Windows 弹窗
|
||||
|
||||
---
|
||||
|
||||
## 三、Electron 客户端当前架构(有问题)
|
||||
|
||||
Electron 当前**试图复制** Tauri 架构,但有几个问题:
|
||||
|
||||
```
|
||||
Electron Main Process
|
||||
│ spawnJsFile() → run-mcp-proxy.js → Rust binary
|
||||
│ mcp-proxy proxy --port 8080 --host 127.0.0.1 --config '...'
|
||||
▼
|
||||
mcp-proxy proxy (Rust binary, HTTP server)
|
||||
│ http://127.0.0.1:8080
|
||||
▼
|
||||
Agent Engine
|
||||
│ mcpServers: { "mcp-proxy": { command: "mcp-proxy", args: ["convert", "http://..."] } }
|
||||
▼
|
||||
mcp-proxy convert (Rust binary, HTTP → stdio)
|
||||
```
|
||||
|
||||
**问题清单**:
|
||||
1. Windows 上 Rust 二进制 `.cmd` wrapper 弹出控制台窗口
|
||||
2. `spawnJsFile()` spawn 的是 `run-mcp-proxy.js`(Node.js wrapper),它下载并执行 Rust binary
|
||||
3. 端口管理复杂(分配、占用检测、kill 占用进程)
|
||||
4. 单进程限制(Electron 代码中强制过滤为单服务)
|
||||
5. 两层进程开销
|
||||
|
||||
---
|
||||
|
||||
## 四、新架构设计
|
||||
|
||||
### 方案:纯 stdio 直通(消除 HTTP 中间层)
|
||||
|
||||
```
|
||||
Agent Engine (claude-code / qimingcode)
|
||||
│ spawns as MCP server (stdio)
|
||||
▼
|
||||
qiming-mcp-stdio-proxy --config '{"mcpServers":{...}}'
|
||||
│ StdioServerTransport (上游) + StdioClientTransport × N (下游)
|
||||
▼
|
||||
Child MCP Servers (stdio × N)
|
||||
```
|
||||
|
||||
**核心变化**:
|
||||
- **消除 HTTP 中间层**:不再需要 `proxy` → HTTP → `convert` 的双跳
|
||||
- **Agent 直接 spawn proxy**:proxy 是 Agent 引擎的一个 stdio MCP server
|
||||
- **Electron 不管理 proxy 进程**:生命周期随 Agent 引擎
|
||||
- **纯 Node.js**:无 Rust 二进制,无 `.cmd` wrapper,Windows 无弹窗
|
||||
|
||||
### 架构对比
|
||||
|
||||
| | Tauri(旧) | Electron 旧 | Electron 新 |
|
||||
|---|---|---|---|
|
||||
| **聚合层** | `mcp-proxy proxy` (Rust, HTTP) | 同左 | `qiming-mcp-stdio-proxy` (Node.js, stdio) |
|
||||
| **桥接层** | `mcp-proxy convert` (Rust, HTTP→stdio) | 同左 | **无**(直接 stdio) |
|
||||
| **进程数** | 2 (proxy + convert) | 2 | 1 |
|
||||
| **通信协议** | stdio → HTTP → stdio | 同左 | stdio → stdio |
|
||||
| **端口** | 需要 (18099) | 需要 (8080) | **无** |
|
||||
| **Windows 弹窗** | 无(Rust CREATE_NO_WINDOW) | **有**(.cmd wrapper) | **无**(Node.js) |
|
||||
| **多服务** | 支持 | 强制单服务 | 支持 N 个 |
|
||||
| **生命周期** | Tauri 管理 | Electron 管理 | Agent 引擎管理 |
|
||||
|
||||
### 为什么可以省掉 HTTP 层
|
||||
|
||||
旧架构需要 HTTP 中间层是因为:
|
||||
- Tauri 的 Rust 进程管理需要一个长驻 HTTP 服务作为控制面
|
||||
- Agent 引擎通过 `mcp-proxy convert <url>` 以 stdio 方式接入
|
||||
|
||||
新架构中:
|
||||
- `qiming-mcp-stdio-proxy` 本身就是一个 stdio MCP server
|
||||
- Agent 引擎直接 spawn 它,无需中转
|
||||
- 配置通过 `--config` 参数传入,无需运行时控制面
|
||||
|
||||
---
|
||||
|
||||
## 五、配置格式兼容性
|
||||
|
||||
新旧 proxy 使用**完全相同**的 `mcpServers` JSON 配置格式:
|
||||
|
||||
```typescript
|
||||
interface McpServersConfig {
|
||||
mcpServers: Record<string, {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
- SQLite 中持久化的 `mcp_proxy_config` 键值可直接复用
|
||||
- `resolveServersConfig()` 的 uvx 解析和 env 注入逻辑不变
|
||||
|
||||
---
|
||||
|
||||
## 六、需要修改的文件清单
|
||||
|
||||
| # | 文件 | 改动量 | 说明 |
|
||||
|---|------|--------|------|
|
||||
| 1 | `src/main/services/packages/mcp.ts` | **大改** | 核心:移除 HTTP 进程管理,改为配置提供者 |
|
||||
| 2 | `src/main/services/system/dependencies.ts` | 小改 | 更新包名、bin 名、最低版本 |
|
||||
| 3 | `src/main/ipc/mcpHandlers.ts` | 中改 | 简化 start/stop/restart 为 no-op |
|
||||
| 4 | `src/main/ipc/processHandlers.ts` | 中改 | restartAll/stopAll 移除 MCP 进程管理 |
|
||||
| 5 | `src/main/main.ts` | 小改 | cleanup 中移除 mcpProxyManager.cleanup() |
|
||||
| 6 | `src/renderer/App.tsx` | 中改 | 移除 mcpProxy 进程启动/轮询 |
|
||||
| 7 | `src/renderer/components/ClientPage.tsx` | 中改 | 移除 mcpProxy 启停逻辑 |
|
||||
| 8 | `src/renderer/components/MCPSettings.tsx` | 小改 | 移除端口配置 UI |
|
||||
| 9 | `src/main/services/engines/unifiedAgent.ts` | 无改 | syncMcpConfigToProxyAndReload 已简化 |
|
||||
| 10 | `src/main/ipc/agentHandlers.ts` | 无改 | getAgentMcpConfig() 返回值已变 |
|
||||
|
||||
---
|
||||
|
||||
## 七、逐文件改动详解
|
||||
|
||||
### 7.1 `src/main/services/packages/mcp.ts` — 核心重构
|
||||
|
||||
旧文件约 692 行,重构后预计约 300 行。
|
||||
|
||||
#### 7.1.1 删除的代码(约 400 行)
|
||||
|
||||
| 删除项 | 原因 |
|
||||
|--------|------|
|
||||
| `import { spawn, ChildProcess, exec }` | 不再 spawn 进程 |
|
||||
| `import * as net` | 不再管理端口 |
|
||||
| `import { DEFAULT_MCP_PROXY_PORT, DEFAULT_MCP_PROXY_HOST }` | 不再使用端口/主机 |
|
||||
| `isPortInUse()` 函数 | 不再管理端口 |
|
||||
| `killProcessOnPort()` 函数 | 不再管理端口 |
|
||||
| `McpProxyManager.process` / `.port` / `.host` / `.startPromise` 字段 | 无进程实例 |
|
||||
| `McpProxyManager.start()` 方法体(进程 spawn 逻辑) | 整体重写为验证 |
|
||||
| `McpProxyManager.stop()` / `.restart()` 方法体 | 简化为 no-op |
|
||||
| `McpProxyManager.isProcessRunning()` | 无进程 |
|
||||
| `McpProxyManager.getPort()` / `.setPort()` | 不再使用端口 |
|
||||
| `McpProxyManager.cleanup()` 方法体 | 简化为 no-op |
|
||||
| `McpProxyStatus.pid` / `.port` / `.host` 字段 | 不再使用 |
|
||||
| `McpProxyStartConfig` 接口 | 不再使用 |
|
||||
| `extractRealServersFromMcpServers()` 中解析 `mcp-proxy convert` 桥接项 | 无桥接模式 |
|
||||
|
||||
#### 7.1.2 保留的代码(约 200 行)
|
||||
|
||||
| 保留项 | 说明 |
|
||||
|--------|------|
|
||||
| `getUvBinDir()` / `resolveUvCommand()` | 仍需为 child server 解析 uvx |
|
||||
| `resolveServersConfig()` | 仍需注入 env + 解析 uvx |
|
||||
| `DEFAULT_MCP_PROXY_CONFIG` | 默认配置不变 |
|
||||
| `McpServerEntry` / `McpServersConfig` 接口 | 配置格式不变 |
|
||||
| `McpProxyManager.config` + get/set/add/remove | 配置管理不变 |
|
||||
| `syncMcpConfigToProxyAndReload()` | 保留但简化(不 restart) |
|
||||
|
||||
#### 7.1.3 `McpProxyManager` 类重写
|
||||
|
||||
```typescript
|
||||
class McpProxyManager {
|
||||
private config: McpServersConfig = JSON.parse(JSON.stringify(DEFAULT_MCP_PROXY_CONFIG));
|
||||
|
||||
/**
|
||||
* 获取 qiming-mcp-stdio-proxy 脚本路径
|
||||
*/
|
||||
private getProxyScriptPath(): string | null {
|
||||
const dirs = getAppPaths();
|
||||
const packageDir = path.join(dirs.nodeModules, 'qiming-mcp-stdio-proxy');
|
||||
if (!fs.existsSync(packageDir)) return null;
|
||||
return resolveNpmPackageEntry(packageDir, 'qiming-mcp-stdio-proxy');
|
||||
}
|
||||
|
||||
/**
|
||||
* start() → 仅验证 binary 可用性(不再启动进程)
|
||||
*/
|
||||
async start(): Promise<{ success: boolean; error?: string }> {
|
||||
if (!isInstalledLocally('qiming-mcp-stdio-proxy')) {
|
||||
return { success: false, error: 'qiming-mcp-stdio-proxy 未安装' };
|
||||
}
|
||||
const scriptPath = this.getProxyScriptPath();
|
||||
if (!scriptPath) {
|
||||
return { success: false, error: 'qiming-mcp-stdio-proxy 入口文件未找到' };
|
||||
}
|
||||
log.info('[McpProxy] qiming-mcp-stdio-proxy 就绪:', scriptPath);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async stop(): Promise<{ success: boolean }> { return { success: true }; }
|
||||
async restart(): Promise<{ success: boolean; error?: string }> { return this.start(); }
|
||||
|
||||
getStatus(): McpProxyStatus {
|
||||
const serverNames = Object.keys(this.config.mcpServers || {});
|
||||
return {
|
||||
running: !!this.getProxyScriptPath(),
|
||||
serverCount: serverNames.length,
|
||||
serverNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* getAgentMcpConfig() — 核心变更
|
||||
*
|
||||
* 旧: 返回 { "mcp-proxy": { command: "mcp-proxy", args: ["convert", "http://127.0.0.1:8080"] } }
|
||||
* 新: 返回 { "mcp-proxy": { command: "node", args: ["proxy.js", "--config", "..."] } }
|
||||
*
|
||||
* Agent 引擎直接 spawn qiming-mcp-stdio-proxy 作为 stdio MCP server,
|
||||
* 不再经过 HTTP 中间层。
|
||||
*/
|
||||
getAgentMcpConfig(): Record<string, {
|
||||
command: string; args: string[]; env?: Record<string, string>;
|
||||
}> | null {
|
||||
const servers = this.config.mcpServers;
|
||||
if (!servers || Object.keys(servers).length === 0) return null;
|
||||
|
||||
const scriptPath = this.getProxyScriptPath();
|
||||
if (!scriptPath) {
|
||||
// fallback: 直接返回解析后的各 server stdio 配置(无聚合)
|
||||
return resolveServersConfig(servers);
|
||||
}
|
||||
|
||||
// 解析配置:uvx → 应用内路径,注入 env
|
||||
const resolvedConfig: McpServersConfig = {
|
||||
mcpServers: resolveServersConfig(servers),
|
||||
};
|
||||
const configJson = JSON.stringify(resolvedConfig);
|
||||
|
||||
// Windows: 用 Electron 的 Node.js 执行(避免 .cmd 弹窗)
|
||||
// macOS/Linux: 用系统 node
|
||||
if (isWindows()) {
|
||||
return {
|
||||
'mcp-proxy': {
|
||||
command: process.execPath,
|
||||
args: [scriptPath, '--config', configJson],
|
||||
env: { ELECTRON_RUN_AS_NODE: '1' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'mcp-proxy': {
|
||||
command: 'node',
|
||||
args: [scriptPath, '--config', configJson],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
cleanup(): void { /* no-op */ }
|
||||
|
||||
// getConfig / setConfig / addServer / removeServer — 保持不变
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.1.4 `McpProxyStatus` 接口简化
|
||||
|
||||
```typescript
|
||||
// 旧
|
||||
export interface McpProxyStatus {
|
||||
running: boolean;
|
||||
pid?: number; // ← 删除
|
||||
port?: number; // ← 删除
|
||||
host?: string; // ← 删除
|
||||
serverCount?: number;
|
||||
serverNames?: string[];
|
||||
}
|
||||
|
||||
// 新
|
||||
export interface McpProxyStatus {
|
||||
running: boolean; // 语义变化:"binary 可用" 而非 "进程在运行"
|
||||
serverCount?: number;
|
||||
serverNames?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.1.5 `syncMcpConfigToProxyAndReload()` 简化
|
||||
|
||||
```typescript
|
||||
export async function syncMcpConfigToProxyAndReload(
|
||||
mcpServers: Record<string, { command: string; args?: string[]; env?: Record<string, string> }>,
|
||||
): Promise<void> {
|
||||
if (!mcpServers || Object.keys(mcpServers).length === 0) return;
|
||||
|
||||
// 提取真实服务(过滤旧桥接项 command==='mcp-proxy')
|
||||
const realOnly: Record<string, { command: string; args: string[]; env?: Record<string, string> }> = {};
|
||||
for (const [name, entry] of Object.entries(mcpServers)) {
|
||||
if (!entry || entry.command === 'mcp-proxy') continue;
|
||||
realOnly[name] = { command: entry.command, args: Array.isArray(entry.args) ? entry.args : [], env: entry.env };
|
||||
}
|
||||
if (Object.keys(realOnly).length === 0) return;
|
||||
|
||||
log.info('[McpProxy] 同步 MCP 配置:', Object.keys(realOnly).join(', '));
|
||||
mcpProxyManager.setConfig({ mcpServers: realOnly });
|
||||
|
||||
// 持久化到 SQLite
|
||||
try {
|
||||
const { getDb } = await import('../../db');
|
||||
const db = getDb();
|
||||
db?.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('mcp_proxy_config', JSON.stringify({ mcpServers: realOnly }));
|
||||
} catch (e) {
|
||||
log.warn('[McpProxy] 持久化 MCP 配置失败:', e);
|
||||
}
|
||||
|
||||
// 不再需要 restart — 无后台进程
|
||||
// Agent 下次 init 时 getAgentMcpConfig() 会使用新配置
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.2 `src/main/services/system/dependencies.ts`
|
||||
|
||||
**改动 1**:`SETUP_REQUIRED_DEPENDENCIES` 中更新 MCP 条目
|
||||
|
||||
```typescript
|
||||
// 旧
|
||||
{
|
||||
name: "mcp-stdio-proxy",
|
||||
displayName: "MCP 服务",
|
||||
type: "npm-local",
|
||||
description: "MCP 协议转换工具(应用内安装)",
|
||||
required: true,
|
||||
minVersion: "0.1.48",
|
||||
binName: "mcp-proxy",
|
||||
},
|
||||
|
||||
// 新
|
||||
{
|
||||
name: "qiming-mcp-stdio-proxy",
|
||||
displayName: "MCP 服务",
|
||||
type: "npm-local",
|
||||
description: "MCP 协议聚合代理(应用内安装)",
|
||||
required: true,
|
||||
minVersion: "1.0.0",
|
||||
binName: "qiming-mcp-stdio-proxy",
|
||||
},
|
||||
```
|
||||
|
||||
**改动 2**:`checkAllDependencies()` 中更新 case
|
||||
|
||||
```diff
|
||||
- case 'mcp-stdio-proxy':
|
||||
+ case 'qiming-mcp-stdio-proxy':
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.3 `src/main/ipc/mcpHandlers.ts`
|
||||
|
||||
- `mcp:start` → 移除 options 参数(port/host/configJson),调用 `mcpProxyManager.start()`(仅验证)
|
||||
- `mcp:stop` → 调用 `mcpProxyManager.stop()`(no-op)
|
||||
- `mcp:restart` → 调用 `mcpProxyManager.restart()`(仅验证)
|
||||
- `mcp:getPort` / `mcp:setPort` → 保留为 no-op 向后兼容
|
||||
- `mcp:getConfig` / `mcp:setConfig` → 不变
|
||||
|
||||
---
|
||||
|
||||
### 7.4 `src/main/ipc/processHandlers.ts`
|
||||
|
||||
`services:restartAll`:
|
||||
- 移除 `mcpProxyManager.stop()` 调用
|
||||
- `mcpProxyManager.start()` 保留但语义变为"验证可用性"
|
||||
|
||||
`services:stopAll`:
|
||||
- MCP Proxy 部分改为 `results.mcpProxy = { success: true }` (no-op)
|
||||
|
||||
---
|
||||
|
||||
### 7.5 `src/main/main.ts`
|
||||
|
||||
`cleanupAllProcesses()` 中移除或注释 `mcpProxyManager.cleanup()`(已 no-op)。
|
||||
|
||||
---
|
||||
|
||||
### 7.6 `src/renderer/App.tsx` + `ClientPage.tsx`
|
||||
|
||||
`mcpProxy` 在 serviceKeys/startOrder 中**保留**,因为 `mcp.start()` 已变为轻量验证。UI 展示的 `running` 状态语义从"进程运行中"变为"binary 可用"。
|
||||
|
||||
可选移除 `pid` 字段的展示。
|
||||
|
||||
---
|
||||
|
||||
### 7.7 `src/renderer/components/MCPSettings.tsx`
|
||||
|
||||
- 移除端口配置 UI(不再使用端口)
|
||||
- 启动/停止按钮可保留(已为 no-op)或改为"检测可用性"
|
||||
- MCP server 列表编辑功能保留不变
|
||||
|
||||
---
|
||||
|
||||
## 八、关键行为变更对照表
|
||||
|
||||
| 行为 | 旧(mcp-proxy Rust) | 新(qiming-mcp-stdio-proxy TS) |
|
||||
|------|----------------------|-------------------------------|
|
||||
| **binary 类型** | Rust 编译二进制 | Node.js 脚本 |
|
||||
| **通信链路** | stdio → HTTP (Streamable) → stdio | stdio → stdio |
|
||||
| **进程数** | 2 (proxy + convert) | 1 |
|
||||
| **端口** | 需要(默认 8080/18099) | **无** |
|
||||
| **进程管理** | Electron spawn + 监控 + cleanup | Agent 引擎自动管理 |
|
||||
| **多服务聚合** | 支持(但 Electron 强制单服务) | 支持 N 个 |
|
||||
| **Agent 注入** | `mcp-proxy convert http://...` | `node proxy.js --config '...'` |
|
||||
| **Windows 弹窗** | 有(.cmd wrapper) | **无**(Node.js + ELECTRON_RUN_AS_NODE) |
|
||||
| **配置热更新** | restart proxy HTTP 进程 | 更新配置,Agent 下次 init 使用 |
|
||||
| **健康检查** | TCP 连接测试 | 不需要(无 HTTP 服务) |
|
||||
| **远程 MCP 服务** | 支持(convert URL → stdio) | 不支持(仅聚合本地 stdio servers) |
|
||||
|
||||
### 注意:远程 MCP 服务能力
|
||||
|
||||
旧 `mcp-proxy` 的 `convert` 子命令支持将远程 SSE/Streamable HTTP MCP 服务转为 stdio。新的 `qiming-mcp-stdio-proxy` 仅支持聚合本地 stdio servers。
|
||||
|
||||
如果需要访问远程 MCP 服务,有两个选项:
|
||||
1. **保留旧 `mcp-proxy` 作为 convert 工具**:远程服务配置为 `{ command: "mcp-proxy", args: ["convert", "https://..."] }`,作为 `qiming-mcp-stdio-proxy` 的一个 child server
|
||||
2. **后续为 qiming-mcp-stdio-proxy 添加 HTTP client transport**
|
||||
|
||||
---
|
||||
|
||||
## 九、实施步骤
|
||||
|
||||
### Phase 1: 发布 npm 包
|
||||
|
||||
```bash
|
||||
cd crates/qiming-mcp-stdio-proxy
|
||||
npm run build
|
||||
npm publish
|
||||
```
|
||||
|
||||
### Phase 2: 修改 Electron 客户端
|
||||
|
||||
按以下顺序修改(每步可独立提交):
|
||||
|
||||
1. `dependencies.ts` — 更新包名/bin 名/版本
|
||||
2. `mcp.ts` — 核心重构
|
||||
3. `mcpHandlers.ts` — 简化 IPC
|
||||
4. `processHandlers.ts` — 移除 MCP 进程管理
|
||||
5. `main.ts` — 移除 cleanup
|
||||
6. `App.tsx` + `ClientPage.tsx` — UI 适配
|
||||
7. `MCPSettings.tsx` — 移除端口配置
|
||||
|
||||
### Phase 3: 测试验证
|
||||
|
||||
- [ ] 初始化向导自动安装 `qiming-mcp-stdio-proxy`
|
||||
- [ ] `getAgentMcpConfig()` 返回 `{ command: "node", args: ["proxy.js", "--config", "..."] }`
|
||||
- [ ] Agent 通过 proxy 聚合多个 MCP server 的 tools
|
||||
- [ ] Windows 上无控制台弹窗
|
||||
- [ ] macOS/Linux 上功能正常
|
||||
- [ ] MCP 设置页可编辑 server 列表
|
||||
- [ ] 配置修改后 Agent 重新 init 生效
|
||||
- [ ] 远程 MCP 服务可通过 `mcp-proxy convert` 作为 child server 接入
|
||||
|
||||
---
|
||||
|
||||
## 十、回滚方案
|
||||
|
||||
若需回滚到旧 `mcp-proxy`:
|
||||
|
||||
1. `dependencies.ts` 改回 `name: "mcp-stdio-proxy"`, `binName: "mcp-proxy"`
|
||||
2. `mcp.ts` 恢复进程管理逻辑
|
||||
3. 重新安装 `npm install mcp-stdio-proxy`
|
||||
|
||||
配置格式完全兼容,SQLite 数据无需迁移。
|
||||
|
||||
---
|
||||
|
||||
## 十一、共享类型更新
|
||||
|
||||
如果 `@shared/types/electron.ts` 导出了 `McpProxyStatus`,需同步移除 `pid` / `port` / `host` 字段:
|
||||
|
||||
```typescript
|
||||
export interface McpProxyStatus {
|
||||
running: boolean;
|
||||
serverCount?: number;
|
||||
serverNames?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
同时删除 `McpProxyStartConfig` 接口。
|
||||
93
qimingclaw/crates/qiming-mcp-stdio-proxy/README.md
Normal file
93
qimingclaw/crates/qiming-mcp-stdio-proxy/README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# qiming-mcp-stdio-proxy
|
||||
|
||||
一个纯 TypeScript 编写的 MCP (Model Context Protocol) 代理工具,为 MCP Server 提供高级聚合、协议转换以及生命周期管理功能。
|
||||
|
||||
它的设计初衷,是为了解决将 MCP Server 集成到大型应用或 Agent OS 平台时遇到的“启动时序竞争”及“应用退出后产生僵尸进程”等痛点问题。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- **Node.js** >= 22.0.0
|
||||
|
||||
## 运行模式
|
||||
|
||||
该代理工具具备三种截然不同的工作模式:
|
||||
|
||||
### 1. Stdio 聚合模式 (默认)
|
||||
|
||||
将多个基于不同协议上游 MCP Server 聚合成单个面向下游的 `stdio` 节点。
|
||||
|
||||
```bash
|
||||
qiming-mcp-stdio-proxy --config '{"mcpServers":{...}}'
|
||||
```
|
||||
|
||||
配置中可以混合配置 `stdio` (子进程) 和 `bridge` (HTTP 连接) 类型的上游服务器。代理会统一将它们聚合并向客户端暴露唯一的一个 `stdio` MCP 交互接口。
|
||||
|
||||
### 2. 协议转换模式 (`convert`)
|
||||
|
||||
将单个远程的 SSE 或 Streamable HTTP MCP Server 代理转化为本地的 `stdio` MCP Server。适用于仅支持 `stdio` 接入的客户端应用。
|
||||
|
||||
```bash
|
||||
qiming-mcp-stdio-proxy convert http://example.com/mcp/sse --protocol sse
|
||||
```
|
||||
|
||||
### 3. 持久化 HTTP 桥接模式 (`proxy`)
|
||||
|
||||
作为 Streamable HTTP Server 启动 `PersistentMcpBridge`。该模式会预先构建并管理标准的 `stdio` 子进程,进而将它们通过高速、可秒连的 HTTP 接口暴露给下游使用。
|
||||
|
||||
```bash
|
||||
qiming-mcp-stdio-proxy proxy --port 18099 --config '{"mcpServers":{...}}'
|
||||
```
|
||||
|
||||
## 配置文件格式
|
||||
|
||||
在使用默认聚合模式或 `proxy` 模式时,使用如下结构的 JSON 配置:
|
||||
|
||||
| 节点类型 | 配置结构 | 描述 |
|
||||
| ---------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| **stdio** | `{ "command": string, "args"?: string[], "env"?: Record<string, string> }` | 创建子进程;通过 stdio 进行 MCP 通信。 |
|
||||
| **bridge** | `{ "url": string }` | 通过 HTTP 建立 MCP 连接 (例如 `http://127.0.0.1:PORT/mcp/<serverId>`)。 |
|
||||
|
||||
**配置示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/path/to/allowed"
|
||||
]
|
||||
},
|
||||
"chrome-devtools": {
|
||||
"url": "http://127.0.0.1:57278/mcp/chrome-devtools"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 架构简图
|
||||
|
||||
```
|
||||
Agent / ACP 引擎客户端 (stdin/stdout)
|
||||
↕
|
||||
qiming-mcp-stdio-proxy (Stdio 模式)
|
||||
├→ [stdio 上游] → Spawn 启动子进程 (StdioClientTransport)
|
||||
└→ [bridge 上游] → StreamableHTTPClientTransport → 连接 PersistentMcpBridge HTTP
|
||||
```
|
||||
|
||||
通过引入 `proxy` 模式开启 `PersistentMcpBridge`,上层的应用可以剥离 MCP Server 的原本子进程生命周期,从而避免启动初期的时序竞争条件,同时能够更加可靠、整洁地在退出时销毁无用进程。
|
||||
|
||||
## 开发指令
|
||||
|
||||
| 指令 | 描述 |
|
||||
| ----------------------- | ------------------------------------------------------------ |
|
||||
| `npm run build` | 编译 TypeScript 输出到 `dist/` 目录。 |
|
||||
| `npm run test` | 运行测试 (Vitest)。 |
|
||||
| `npm run test:run` | 单次运行所有的单元与集成测试 (无监控交互)。 |
|
||||
| `npm run test:coverage` | 运行测试并生成详细的覆盖率统计报告 (存于 `coverage/` 目录)。 |
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
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);
|
||||
@@ -0,0 +1,57 @@
|
||||
# 心跳与重试机制设计 (qiming-mcp-stdio-proxy)
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
目前 `qiming-mcp-stdio-proxy` 缺乏针对其传输层(特别是 `sse` 和 `streamable` 远程连接)的健壮的心跳(Keep-Alive)和透明重试机制。如果后端的 MCP 服务器崩溃或网络断开,依赖代理的下游 Agent 会遇到 `stdio` 管道断裂或请求挂起的问题。
|
||||
|
||||
我们需要一个具有弹性的代理层:
|
||||
|
||||
1. 主动监控上游连接的健康状况(使用 Ping 等请求)。
|
||||
2. 如果连接断开,透明地重连并重新映射传输层。
|
||||
3. 对下游的 Agent(仅通过标准 stdio 通信)屏蔽这些临时故障,有效创建一个“永远在线”的代理抽象。
|
||||
|
||||
## 2. 核心功能与要求
|
||||
|
||||
### 2.1 心跳 (Keep-Alive)
|
||||
|
||||
- **间隔**: 默认 20 秒 (`--ping-interval`)。
|
||||
- **超时**: 默认 5 秒 (`--ping-timeout`)。
|
||||
- **方式**: 发送 MCP 的 JSON-RPC `ping` 请求进行健康检查。
|
||||
- **失败阈值**: 连续 3 次健康检查失败。
|
||||
|
||||
### 2.2 重连与重试逻辑
|
||||
|
||||
- 当连接被认定为不健康(连续 3 次失败或传输层触发 `onclose`/`onerror`)时:
|
||||
1. 状态变更为 `RECONNECTING` (重连中)。在重连期间到达的 MCP 请求会进入内部队列进行缓冲,代理本身**绝不能崩溃**。
|
||||
2. 代理启动对上游服务的重连(重新 spawn 标准子进程,或重新建立 SSE/Streamable HTTP 连接)。
|
||||
3. 重连成功后,代理将把内部的 `Transport` 重新映射到新的连接,并自动刷出之前排队的请求。
|
||||
4. 下游 Agent 与代理之间的 `stdio` 连接在整个过程中保持完好无损。
|
||||
|
||||
## 3. 架构设计
|
||||
|
||||
### 3.1 弹性代理传输层 (ResilientTransportWrapper)
|
||||
|
||||
我们使用 `ResilientTransportWrapper` 将底层的连接进行封装。这个 Wrapper 向上层环境暴露标准稳定的 `Transport` 接口,但内部动态管理和更替实际的 SSE/Streamable/Stdio 传输实例。
|
||||
|
||||
```typescript
|
||||
class ResilientTransportWrapper implements Transport {
|
||||
// 实现 MCP Transport 接口
|
||||
// 内部管理实际的连接 (activeTransport)
|
||||
// 包含断开重连逻辑、心跳检测机制和消息排队发送队列
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 内部实现细节
|
||||
|
||||
1. **连接建立**: 初始化时通过外部传入的 `connectParams` 工厂函数创建底层的 Transport 连接。
|
||||
2. **心跳定时器**: 每隔固定时间(例如 20 秒),向 `activeTransport` 发送原生的 JSON-RPC `ping` 请求。如果在发送期间抛错或传输直接断开,则累加连续失败计数。
|
||||
3. **断开拦截**: 拦截 `activeTransport` 的 `onclose` 和 `onerror` 事件,并触发内部重连,防止其向上冒泡直接中止下游的 stdio 代理进程。
|
||||
4. **请求排队**: 当正处于重连状态时,由于暂时没有可用的底层发送通道,通过外层 `send(message)` 收到的任何请求都会进入一个有容量限制(默认 100)的队列中。新的底层连接建立成功后,会迅速重放(flush)发送队列的所有请求。
|
||||
5. **多模式支持**: 这个逻辑被集中继承到了 `modes/convert.ts` (独立服务转换模式) 及 `bridge.ts` (`PersistentMcpBridge` 持久化管理桥接) 内部,以实现对不同形式请求的通用重连保护。
|
||||
|
||||
## 4. 命令行及参数配置
|
||||
|
||||
新增了控制心跳频次的参数选项支持,可以通过启动命令或在配置文件内声明:
|
||||
|
||||
- `--ping-interval <毫秒>`: 发送心跳 Ping 的时间间隔(如果不指定,默认为 20000 即 20 秒)。
|
||||
- `--ping-timeout <毫秒>`: 心跳请求超时判断(如果不指定,默认为 5000 即 5 秒)。
|
||||
3816
qimingclaw/crates/qiming-mcp-stdio-proxy/package-lock.json
generated
Normal file
3816
qimingclaw/crates/qiming-mcp-stdio-proxy/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
qimingclaw/crates/qiming-mcp-stdio-proxy/package.json
Normal file
37
qimingclaw/crates/qiming-mcp-stdio-proxy/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "qiming-mcp-stdio-proxy",
|
||||
"version": "1.4.14",
|
||||
"description": "TypeScript MCP proxy — aggregates multiple MCP servers (stdio + streamable-http + SSE) with convert & proxy modes",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"qiming-mcp-stdio-proxy": "dist/index.js"
|
||||
},
|
||||
"main": "./dist/lib.js",
|
||||
"types": "./dist/lib.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "npm run build",
|
||||
"build": "tsc && node scripts/build.mjs",
|
||||
"build:tsc": "tsc",
|
||||
"test": "npm run build:tsc && vitest",
|
||||
"test:run": "npm run build:tsc && vitest run",
|
||||
"test:coverage": "npm run build:tsc && vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"esbuild": "^0.27.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
35
qimingclaw/crates/qiming-mcp-stdio-proxy/scripts/build.mjs
Normal file
35
qimingclaw/crates/qiming-mcp-stdio-proxy/scripts/build.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* esbuild 构建脚本 — 将 qiming-mcp-stdio-proxy 打成单文件 bundle
|
||||
*
|
||||
* 产物: dist/index.js (含 shebang, 可直接执行)
|
||||
*
|
||||
* 所有运行时依赖 (@modelcontextprotocol/sdk 及其 90+ 传递依赖)
|
||||
* 全部内联到 bundle 中,打包后的 Electron 客户端不再需要
|
||||
* 为 proxy 单独安装 node_modules。
|
||||
*/
|
||||
|
||||
import * as esbuild from 'esbuild';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
const pkg = JSON.parse(readFileSync('./package.json', 'utf8'));
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
format: 'esm',
|
||||
outfile: 'dist/index.js',
|
||||
banner: { js: '#!/usr/bin/env node' },
|
||||
define: {
|
||||
'process.env.__MCP_PROXY_PKG_NAME__': JSON.stringify(pkg.name),
|
||||
'process.env.__MCP_PROXY_PKG_VERSION__': JSON.stringify(pkg.version),
|
||||
},
|
||||
// Node.js builtins are external (child_process, http, stream, etc.)
|
||||
// esbuild --platform=node handles this automatically
|
||||
sourcemap: false,
|
||||
minify: false, // Keep readable for debugging
|
||||
legalComments: 'none',
|
||||
});
|
||||
|
||||
console.log(`[build] ✅ dist/index.js built (${pkg.name}@${pkg.version})`);
|
||||
595
qimingclaw/crates/qiming-mcp-stdio-proxy/src/bridge.ts
Normal file
595
qimingclaw/crates/qiming-mcp-stdio-proxy/src/bridge.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* PersistentMcpBridge — long-lived MCP server bridge
|
||||
*
|
||||
* Manages persistent MCP servers (e.g. chrome-devtools-mcp) that outlive
|
||||
* individual ACP sessions. Spawns child processes via CustomStdioClientTransport
|
||||
* (with Windows fixes) and exposes them over HTTP for downstream consumers.
|
||||
*
|
||||
* Architecture:
|
||||
* persistent child process (stdio)
|
||||
* ↕ CustomStdioClientTransport
|
||||
* MCP Client (cached tools, proxies callTool)
|
||||
* ↕
|
||||
* HTTP Server (single port, path routing /mcp/<serverId>)
|
||||
* ↕ StreamableHTTPServerTransport (per HTTP session)
|
||||
* MCP Server (tool handlers → Client.callTool)
|
||||
*/
|
||||
|
||||
import * as http from 'http';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { CustomStdioClientTransport } from './customStdio.js';
|
||||
import { ResilientTransportWrapper } from './resilient.js';
|
||||
import type { StdioServerEntry } from './types.js';
|
||||
import { MCP_SESSION_ID_HEADER } from './constants.js';
|
||||
|
||||
const LOG_TAG = '[McpProxy] [PersistentMcpBridge]';
|
||||
const BASE_RESTART_COOLDOWN_MS = 5_000;
|
||||
const MAX_RESTART_ATTEMPTS = 5;
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
const SESSION_CLEANUP_INTERVAL_MS = 60_000; // 1 minute
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface BridgeLogger {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface PersistentServerEntry {
|
||||
config: StdioServerEntry;
|
||||
client: Client | null;
|
||||
transport: ResilientTransportWrapper | null;
|
||||
tools: Tool[];
|
||||
healthy: boolean;
|
||||
restarting: boolean;
|
||||
restartTimer: ReturnType<typeof setTimeout> | null;
|
||||
restartCount: number;
|
||||
}
|
||||
|
||||
/** session ID → { server, transport } */
|
||||
interface HttpSession {
|
||||
server: Server;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
}
|
||||
|
||||
// ========== PersistentMcpBridge ==========
|
||||
|
||||
export class PersistentMcpBridge {
|
||||
private log: BridgeLogger;
|
||||
private httpServer: http.Server | null = null;
|
||||
private port = 0;
|
||||
private servers = new Map<string, PersistentServerEntry>();
|
||||
/** "serverId:sessionId" → HttpSession */
|
||||
private httpSessions = new Map<string, HttpSession>();
|
||||
private running = false;
|
||||
private sessionCleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(logger?: BridgeLogger) {
|
||||
this.log = logger ?? console;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start bridge: spawn child processes for each persistent server and create HTTP server
|
||||
*
|
||||
* @param servers - Map of server ID to stdio config
|
||||
* @param options - Optional settings (e.g. port to listen on)
|
||||
*/
|
||||
async start(servers: Record<string, StdioServerEntry>, options?: { port?: number }): Promise<void> {
|
||||
if (this.running) {
|
||||
this.log.warn(`${LOG_TAG} Already running, stopping first`);
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
const serverCount = Object.keys(servers).length;
|
||||
this.log.info(`${LOG_TAG} Starting with ${serverCount} persistent servers (parallel)`);
|
||||
|
||||
// 1. 并行启动 HTTP server 和所有 MCP server 子进程。
|
||||
// 两者完全独立:HTTP server 在请求时才查询 this.servers,
|
||||
// startServer 会在异步 spawnAndConnect 前先同步写入 this.servers,
|
||||
// 所以 HTTP server 收到第一个请求时所有 entry 都已注册。
|
||||
//
|
||||
// 提前保存 serverPromises 引用:若 startHttpServer 抛出,在 catch 中先
|
||||
// await Promise.allSettled(serverPromises) 等所有 spawnAndConnect 跑完
|
||||
// (保证 entry.client / entry.transport 已赋值),再调用 stopServer,
|
||||
// 才能可靠关闭子进程,防止孤儿进程残留。
|
||||
const serverPromises = Object.entries(servers).map(([id, config]) => this.startServer(id, config));
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.startHttpServer(options?.port),
|
||||
...serverPromises,
|
||||
]);
|
||||
} catch (e) {
|
||||
this.log.error(`${LOG_TAG} Start failed, cleaning spawned child processes:`, e);
|
||||
// 等待所有 spawnAndConnect 完成,确保 entry.client/transport 已赋值后再清理
|
||||
await Promise.allSettled(serverPromises);
|
||||
await Promise.all(Array.from(this.servers.keys()).map((id) => this.stopServer(id)));
|
||||
this.servers.clear();
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
// 2. Start periodic session cleanup
|
||||
this.sessionCleanupTimer = setInterval(() => this.cleanupStaleSessions(), SESSION_CLEANUP_INTERVAL_MS);
|
||||
|
||||
this.log.info(`${LOG_TAG} Bridge ready on port ${this.port} (${serverCount} servers)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop bridge: close HTTP, kill all child processes
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
this.log.info(`${LOG_TAG} Stopping...`);
|
||||
this.running = false;
|
||||
|
||||
// Stop session cleanup timer
|
||||
if (this.sessionCleanupTimer) {
|
||||
clearInterval(this.sessionCleanupTimer);
|
||||
this.sessionCleanupTimer = null;
|
||||
}
|
||||
|
||||
// 并行关闭所有 HTTP sessions(各 session transport 独立,无顺序依赖)。
|
||||
// 内层 async 函数自行 catch 错误后不再 throw,Promise.all 足够,不需要 allSettled。
|
||||
await Promise.all(
|
||||
Array.from(this.httpSessions.entries()).map(async ([key, session]) => {
|
||||
try {
|
||||
await session.transport.close();
|
||||
} catch (e) {
|
||||
this.log.warn(`${LOG_TAG} Error closing HTTP session ${key}:`, e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.httpSessions.clear();
|
||||
|
||||
// Close HTTP server
|
||||
if (this.httpServer) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.httpServer!.close(() => resolve());
|
||||
});
|
||||
this.httpServer = null;
|
||||
this.port = 0;
|
||||
}
|
||||
|
||||
// 并行关闭所有 MCP server 子进程(各自独立,无顺序依赖)
|
||||
await Promise.all(
|
||||
Array.from(this.servers.keys()).map((id) => this.stopServer(id)),
|
||||
);
|
||||
this.servers.clear();
|
||||
|
||||
this.log.info(`${LOG_TAG} Stopped`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bridge URL for a server (for bridge clients to connect)
|
||||
*/
|
||||
getBridgeUrl(serverId: string): string | null {
|
||||
if (!this.running || !this.port) return null;
|
||||
const entry = this.servers.get(serverId);
|
||||
if (!entry || !entry.healthy) return null;
|
||||
return `http://127.0.0.1:${this.port}/mcp/${serverId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bridge is running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.running && this.port > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for a specific server
|
||||
*/
|
||||
isServerHealthy(serverId: string): boolean {
|
||||
return this.servers.get(serverId)?.healthy ?? false;
|
||||
}
|
||||
|
||||
// ==================== Internal: Server Lifecycle ====================
|
||||
|
||||
private async startServer(id: string, config: StdioServerEntry): Promise<void> {
|
||||
const entry: PersistentServerEntry = {
|
||||
config,
|
||||
client: null,
|
||||
transport: null,
|
||||
tools: [],
|
||||
healthy: false,
|
||||
restarting: false,
|
||||
restartTimer: null,
|
||||
restartCount: 0,
|
||||
};
|
||||
this.servers.set(id, entry);
|
||||
|
||||
await this.spawnAndConnect(id, entry);
|
||||
}
|
||||
|
||||
private async spawnAndConnect(id: string, entry: PersistentServerEntry): Promise<void> {
|
||||
try {
|
||||
this.log.info(`${LOG_TAG} Spawning server "${id}": ${entry.config.command} ${(entry.config.args || []).join(' ')}`);
|
||||
|
||||
// Create MCP Client + ResilientTransportWrapper
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: id,
|
||||
logger: this.log,
|
||||
connectParams: async () => {
|
||||
const t = new CustomStdioClientTransport({
|
||||
command: entry.config.command,
|
||||
args: entry.config.args || [],
|
||||
env: entry.config.env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
if (t.stderr) {
|
||||
t.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text) this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
|
||||
});
|
||||
}
|
||||
return t;
|
||||
},
|
||||
pingIntervalMs: 0, // No heartbeat for stdio — child process close/error events handle detection
|
||||
});
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'qiming-persistent-bridge', version: '1.0.0' },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
|
||||
// Handle wrapper intentional close → mark unhealthy
|
||||
wrapper.onclose = () => {
|
||||
this.log.warn(`${LOG_TAG} Server "${id}" wrapper closed`);
|
||||
entry.healthy = false;
|
||||
};
|
||||
|
||||
wrapper.onerror = (err: Error) => {
|
||||
this.log.error(`${LOG_TAG} Server "${id}" transport error:`, err.message);
|
||||
};
|
||||
|
||||
// Handle reconnect → re-establish MCP session
|
||||
wrapper.onreconnect = async () => {
|
||||
this.log.info(`${LOG_TAG} Server "${id}" reconnecting MCP session...`);
|
||||
|
||||
// Track reconnect attempts for this server
|
||||
entry.restartCount++;
|
||||
if (entry.restartCount > MAX_RESTART_ATTEMPTS) {
|
||||
this.log.warn(`${LOG_TAG} Server "${id}" failed ${entry.restartCount - 1} reconnect attempts, giving up (max ${MAX_RESTART_ATTEMPTS})`);
|
||||
// Close the wrapper to stop further reconnect attempts
|
||||
await wrapper.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Close old client if exists
|
||||
if (entry.client) {
|
||||
try { await entry.client.close(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Create new client and connect
|
||||
const newClient = new Client(
|
||||
{ name: 'qiming-persistent-bridge', version: '1.0.0' },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
await newClient.connect(wrapper);
|
||||
|
||||
// Update entry with new client and refresh tools
|
||||
entry.client = newClient;
|
||||
const result = await newClient.listTools();
|
||||
entry.tools = result.tools;
|
||||
entry.healthy = true;
|
||||
entry.restartCount = 0; // reset on success
|
||||
|
||||
this.log.info(`${LOG_TAG} Server "${id}" reconnected with ${entry.tools.length} tools`);
|
||||
} catch (err) {
|
||||
this.log.error(`${LOG_TAG} Server "${id}" reconnect failed (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS}):`, err);
|
||||
throw err; // Re-throw to let ResilientTransportWrapper trigger another reconnect
|
||||
}
|
||||
};
|
||||
|
||||
// Connect client to transport (this starts the subprocess)
|
||||
await wrapper.start();
|
||||
await client.connect(wrapper);
|
||||
|
||||
entry.client = client;
|
||||
entry.transport = wrapper;
|
||||
|
||||
// List tools (cached)
|
||||
const result = await client.listTools();
|
||||
entry.tools = result.tools;
|
||||
entry.healthy = true;
|
||||
entry.restartCount = 0; // reset on success
|
||||
|
||||
this.log.info(`${LOG_TAG} Server "${id}" ready with ${entry.tools.length} tools: ${entry.tools.map((t) => t.name).join(', ')}`);
|
||||
} catch (e) {
|
||||
this.log.error(`${LOG_TAG} Failed to start server "${id}":`, e);
|
||||
entry.healthy = false;
|
||||
if (this.running && !entry.restarting) {
|
||||
this.scheduleRestart(id, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRestart(id: string, entry: PersistentServerEntry): void {
|
||||
if (entry.restartTimer) return;
|
||||
|
||||
entry.restartCount++;
|
||||
if (entry.restartCount > MAX_RESTART_ATTEMPTS) {
|
||||
this.log.warn(`${LOG_TAG} Server "${id}" failed ${entry.restartCount - 1} times, giving up (max ${MAX_RESTART_ATTEMPTS})`);
|
||||
entry.restarting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
entry.restarting = true;
|
||||
// Exponential backoff: 5s, 10s, 20s, 40s, 80s
|
||||
const delay = BASE_RESTART_COOLDOWN_MS * Math.pow(2, entry.restartCount - 1);
|
||||
this.log.info(`${LOG_TAG} Scheduling restart for "${id}" in ${delay}ms (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS})`);
|
||||
|
||||
entry.restartTimer = setTimeout(async () => {
|
||||
entry.restartTimer = null;
|
||||
entry.restarting = false;
|
||||
if (!this.running) return;
|
||||
|
||||
// Clean up old resources
|
||||
try {
|
||||
if (entry.client) await entry.client.close();
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
if (entry.transport) await entry.transport.close();
|
||||
} catch { /* ignore */ }
|
||||
entry.client = null;
|
||||
entry.transport = null;
|
||||
|
||||
this.log.info(`${LOG_TAG} Restarting server "${id}"... (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS})`);
|
||||
await this.spawnAndConnect(id, entry);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private async stopServer(id: string): Promise<void> {
|
||||
const entry = this.servers.get(id);
|
||||
if (!entry) return;
|
||||
|
||||
if (entry.restartTimer) {
|
||||
clearTimeout(entry.restartTimer);
|
||||
entry.restartTimer = null;
|
||||
}
|
||||
entry.restarting = false;
|
||||
entry.healthy = false;
|
||||
|
||||
try {
|
||||
if (entry.client) await entry.client.close();
|
||||
} catch (e) {
|
||||
this.log.warn(`${LOG_TAG} Error closing client for "${id}":`, e);
|
||||
}
|
||||
|
||||
// transport.close() handles graceful shutdown: stdin.end → SIGTERM → SIGKILL
|
||||
try {
|
||||
if (entry.transport) await entry.transport.close();
|
||||
} catch (e) {
|
||||
this.log.warn(`${LOG_TAG} Error closing transport for "${id}":`, e);
|
||||
}
|
||||
|
||||
entry.client = null;
|
||||
entry.transport = null;
|
||||
}
|
||||
|
||||
// ==================== Internal: HTTP Server ====================
|
||||
|
||||
private async startHttpServer(listenPort?: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
this.handleHttpRequest(req, res).catch((e) => {
|
||||
this.log.error(`${LOG_TAG} HTTP request error:`, e);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Internal server error' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen on specified port or random port (0)
|
||||
server.listen(listenPort ?? 0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === 'object') {
|
||||
this.port = addr.port;
|
||||
}
|
||||
this.httpServer = server;
|
||||
this.log.info(`${LOG_TAG} HTTP server listening on 127.0.0.1:${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
this.log.error(`${LOG_TAG} HTTP server error:`, err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
||||
const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`);
|
||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||
|
||||
// Route: /mcp/<serverId>
|
||||
if (pathParts.length !== 2 || pathParts[0] !== 'mcp') {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Not found. Use /mcp/<serverId>' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const serverId = pathParts[1];
|
||||
const serverEntry = this.servers.get(serverId);
|
||||
if (!serverEntry || !serverEntry.healthy || !serverEntry.client) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Server "${serverId}" not available` }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle DELETE → terminate session
|
||||
if (req.method === 'DELETE') {
|
||||
const sessionId = req.headers[MCP_SESSION_ID_HEADER] as string | undefined;
|
||||
if (sessionId) {
|
||||
const key = `${serverId}:${sessionId}`;
|
||||
const session = this.httpSessions.get(key);
|
||||
if (session) {
|
||||
try {
|
||||
await session.transport.close();
|
||||
} catch { /* ignore */ }
|
||||
this.httpSessions.delete(key);
|
||||
}
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// POST or GET → route to session
|
||||
if (req.method === 'POST' || req.method === 'GET') {
|
||||
const sessionId = req.headers[MCP_SESSION_ID_HEADER] as string | undefined;
|
||||
|
||||
// Try to find existing session
|
||||
if (sessionId) {
|
||||
const key = `${serverId}:${sessionId}`;
|
||||
const session = this.httpSessions.get(key);
|
||||
if (session) {
|
||||
if (req.method === 'POST') {
|
||||
const body = await this.readBody(req);
|
||||
await session.transport.handleRequest(req, res, body);
|
||||
} else {
|
||||
await session.transport.handleRequest(req, res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// New session: only for POST (initialize request)
|
||||
if (req.method === 'POST') {
|
||||
const body = await this.readBody(req);
|
||||
const session = this.createHttpSession(serverId, serverEntry);
|
||||
await session.transport.handleRequest(req, res, body);
|
||||
|
||||
// Register session immediately after first handleRequest (sessionId is now set)
|
||||
const sid = session.transport.sessionId;
|
||||
if (sid) {
|
||||
const key = `${serverId}:${sid}`;
|
||||
this.httpSessions.set(key, session);
|
||||
this.log.info(`${LOG_TAG} New HTTP session: ${key}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// GET without session → 400
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: `Missing ${MCP_SESSION_ID_HEADER} header for GET` }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(405);
|
||||
res.end();
|
||||
}
|
||||
|
||||
private createHttpSession(serverId: string, serverEntry: PersistentServerEntry): HttpSession {
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => `${serverId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
});
|
||||
|
||||
const mcpServer = new Server(
|
||||
{ name: `qiming-bridge-${serverId}`, version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Register tool handlers that proxy to the persistent Client
|
||||
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: serverEntry.tools };
|
||||
});
|
||||
|
||||
mcpServer.setRequestHandler(CallToolRequestSchema, async (request: { params: { name: string; arguments?: Record<string, unknown> } }) => {
|
||||
if (!serverEntry.client || !serverEntry.healthy) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Server "${serverId}" is not available` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await serverEntry.client.callTool({
|
||||
name: request.params.name,
|
||||
arguments: request.params.arguments,
|
||||
});
|
||||
return result as { content: Array<{ type: string; text?: string }>; isError?: boolean };
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return {
|
||||
content: [{ type: 'text', text: `Tool call failed: ${msg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up on close
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid) {
|
||||
const key = `${serverId}:${sid}`;
|
||||
this.httpSessions.delete(key);
|
||||
this.log.info(`${LOG_TAG} HTTP session closed: ${key}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect server to transport
|
||||
mcpServer.connect(transport).catch((e: unknown) => {
|
||||
this.log.error(`${LOG_TAG} Failed to connect HTTP session server:`, e);
|
||||
});
|
||||
|
||||
return { server: mcpServer, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sessions whose transport has been closed but not cleaned up
|
||||
*/
|
||||
private cleanupStaleSessions(): void {
|
||||
let cleaned = 0;
|
||||
for (const [key, session] of this.httpSessions) {
|
||||
// sessionId becomes undefined after transport.close()
|
||||
if (!session.transport.sessionId) {
|
||||
this.httpSessions.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
if (cleaned > 0) {
|
||||
this.log.info(`${LOG_TAG} Cleaned up ${cleaned} stale HTTP session(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
private readBody(req: http.IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalSize = 0;
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
totalSize += chunk.length;
|
||||
if (totalSize > MAX_BODY_SIZE) {
|
||||
req.destroy();
|
||||
reject(new Error(`Request body exceeds ${MAX_BODY_SIZE} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
resolve(JSON.parse(raw));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
14
qimingclaw/crates/qiming-mcp-stdio-proxy/src/constants.ts
Normal file
14
qimingclaw/crates/qiming-mcp-stdio-proxy/src/constants.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Package constants — injected at build time by esbuild define (see build.mjs)
|
||||
*
|
||||
* Falls back to static values when running via tsc in development.
|
||||
*/
|
||||
|
||||
export const PKG_NAME = process.env.__MCP_PROXY_PKG_NAME__ || 'qiming-mcp-stdio-proxy';
|
||||
export const PKG_VERSION = process.env.__MCP_PROXY_PKG_VERSION__ || '0.0.0-dev';
|
||||
|
||||
/**
|
||||
* MCP protocol header names
|
||||
* Defined by MCP Streamable HTTP specification
|
||||
*/
|
||||
export const MCP_SESSION_ID_HEADER = 'mcp-session-id';
|
||||
280
qimingclaw/crates/qiming-mcp-stdio-proxy/src/customStdio.ts
Normal file
280
qimingclaw/crates/qiming-mcp-stdio-proxy/src/customStdio.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Custom Stdio Transport — fixes Windows console popup issue
|
||||
*
|
||||
* The MCP SDK's StdioClientTransport sets:
|
||||
* windowsHide: process.platform === 'win32' && isElectron()
|
||||
*
|
||||
* But when running via ELECTRON_RUN_AS_NODE=1, isElectron() returns false,
|
||||
* causing console popup windows on Windows.
|
||||
*
|
||||
* This custom transport always sets windowsHide: true on Windows.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { PassThrough, Readable } from 'stream';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { serializeMessage } from '@modelcontextprotocol/sdk/shared/stdio.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
function logDebug(msg: string): void {
|
||||
process.stderr.write(`[customStdio] ${msg}\n`);
|
||||
}
|
||||
|
||||
export interface CustomStdioServerParameters {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
stderr?: 'inherit' | 'pipe' | 'overlapped';
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export class CustomStdioClientTransport implements Transport {
|
||||
private _process: ChildProcess | undefined;
|
||||
private _readBuffer = new ReadBuffer();
|
||||
private _stderrStream: PassThrough | null = null;
|
||||
private _serverParams: CustomStdioServerParameters;
|
||||
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
constructor(server: CustomStdioServerParameters) {
|
||||
this._serverParams = server;
|
||||
if (server.stderr === 'pipe' || server.stderr === 'overlapped') {
|
||||
this._stderrStream = new PassThrough();
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this._process) {
|
||||
throw new Error('StdioClientTransport already started!');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const mergedEnv = {
|
||||
...getDefaultEnvironment(),
|
||||
...this._serverParams.env,
|
||||
};
|
||||
|
||||
logDebug(`Starting "${this._serverParams.command}" with PATH: ${(mergedEnv.PATH || '').split(';').slice(0, 3).join(';')}...`);
|
||||
|
||||
// On Windows, resolve .cmd/.bat files if command not found directly
|
||||
let command = this._serverParams.command;
|
||||
let useShell = false;
|
||||
const isWindows = process.platform === 'win32';
|
||||
const cmdExtensions = ['.cmd', '.bat', '.exe'];
|
||||
|
||||
if (isWindows && !cmdExtensions.some(ext => command.toLowerCase().endsWith(ext))) {
|
||||
// Try to find the command with .cmd extension in PATH
|
||||
const pathDirs = (mergedEnv.PATH || '').split(';');
|
||||
for (const dir of pathDirs) {
|
||||
for (const ext of cmdExtensions) {
|
||||
const fullPath = path.join(dir, command + ext);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
command = fullPath;
|
||||
logDebug(`Resolved "${this._serverParams.command}" to "${command}"`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (command !== this._serverParams.command) break;
|
||||
}
|
||||
}
|
||||
|
||||
// For .cmd/.bat files on Windows, we need shell: true
|
||||
if (isWindows && (command.toLowerCase().endsWith('.cmd') || command.toLowerCase().endsWith('.bat'))) {
|
||||
useShell = true;
|
||||
// Quote the command if it contains spaces, otherwise cmd.exe
|
||||
// misparses paths like "D:\Program Files\...\npx.cmd"
|
||||
if (command.includes(' ')) {
|
||||
command = `"${command}"`;
|
||||
}
|
||||
logDebug(`Using shell: true for ${command}`);
|
||||
}
|
||||
|
||||
this._process = spawn(command, this._serverParams.args ?? [], {
|
||||
env: mergedEnv,
|
||||
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
|
||||
shell: useShell,
|
||||
windowsHide: true,
|
||||
cwd: this._serverParams.cwd,
|
||||
});
|
||||
|
||||
this._process.on('error', (error) => {
|
||||
reject(error);
|
||||
this.onerror?.(error);
|
||||
});
|
||||
|
||||
this._process.on('spawn', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
this._process.on('close', (_code) => {
|
||||
this._process = undefined;
|
||||
this.onclose?.();
|
||||
});
|
||||
|
||||
this._process.stdin?.on('error', (error) => {
|
||||
this.onerror?.(error);
|
||||
});
|
||||
|
||||
this._process.stdout?.on('data', (chunk) => {
|
||||
this._readBuffer.append(chunk);
|
||||
this.processReadBuffer();
|
||||
});
|
||||
|
||||
this._process.stdout?.on('error', (error) => {
|
||||
this.onerror?.(error);
|
||||
});
|
||||
|
||||
if (this._stderrStream && this._process.stderr) {
|
||||
this._process.stderr.pipe(this._stderrStream);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get stderr(): Readable | null {
|
||||
if (this._stderrStream) {
|
||||
return this._stderrStream;
|
||||
}
|
||||
return this._process?.stderr ?? null;
|
||||
}
|
||||
|
||||
get pid(): number | null {
|
||||
return this._process?.pid ?? null;
|
||||
}
|
||||
|
||||
private processReadBuffer(): void {
|
||||
while (true) {
|
||||
try {
|
||||
const message = this._readBuffer.readMessage();
|
||||
if (message === null) {
|
||||
break;
|
||||
}
|
||||
this.onmessage?.(message);
|
||||
} catch (error) {
|
||||
this.onerror?.(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this._process) {
|
||||
const processToClose = this._process;
|
||||
this._process = undefined;
|
||||
|
||||
const closePromise = new Promise<void>((resolve) => {
|
||||
processToClose.once('close', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
processToClose.stdin?.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await Promise.race([closePromise, new Promise<void>((resolve) => setTimeout(resolve, 2000).unref())]);
|
||||
|
||||
if (processToClose.exitCode === null) {
|
||||
try {
|
||||
processToClose.kill('SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await Promise.race([closePromise, new Promise<void>((resolve) => setTimeout(resolve, 2000).unref())]);
|
||||
}
|
||||
|
||||
if (processToClose.exitCode === null) {
|
||||
try {
|
||||
processToClose.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
this._readBuffer.clear();
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this._process?.stdin) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
const json = serializeMessage(message);
|
||||
if (this._process.stdin.write(json)) {
|
||||
resolve();
|
||||
} else {
|
||||
this._process.stdin.once('drain', resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ReadBuffer {
|
||||
private _buffer?: Buffer;
|
||||
|
||||
append(chunk: Buffer): void {
|
||||
this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
|
||||
}
|
||||
|
||||
readMessage(): JSONRPCMessage | null {
|
||||
if (!this._buffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separatorIndex = this._buffer.indexOf('\n');
|
||||
if (separatorIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const line = this._buffer.subarray(0, separatorIndex);
|
||||
this._buffer = this._buffer.subarray(separatorIndex + 1);
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(line.toString('utf-8'));
|
||||
return obj as JSONRPCMessage;
|
||||
} catch {
|
||||
throw new Error('Failed to parse JSON-RPC message');
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this._buffer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_INHERITED_ENV_VARS =
|
||||
process.platform === 'win32'
|
||||
? [
|
||||
'APPDATA',
|
||||
'HOMEDRIVE',
|
||||
'HOMEPATH',
|
||||
'LOCALAPPDATA',
|
||||
'PATH',
|
||||
'PROCESSOR_ARCHITECTURE',
|
||||
'SYSTEMDRIVE',
|
||||
'SYSTEMROOT',
|
||||
'TEMP',
|
||||
'USERNAME',
|
||||
'USERPROFILE',
|
||||
'PROGRAMFILES',
|
||||
]
|
||||
: ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER'];
|
||||
|
||||
function getDefaultEnvironment(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const key of DEFAULT_INHERITED_ENV_VARS) {
|
||||
const value = process.env[key];
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (value.startsWith('()')) {
|
||||
continue;
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
120
qimingclaw/crates/qiming-mcp-stdio-proxy/src/detect.ts
Normal file
120
qimingclaw/crates/qiming-mcp-stdio-proxy/src/detect.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Protocol auto-detection — determine whether a URL serves Streamable HTTP or SSE
|
||||
*
|
||||
* Aligned with workspace/mcp-proxy (Rust) detection logic:
|
||||
* - Only probe Streamable HTTP (POST initialize)
|
||||
* - Default to SSE if probe fails
|
||||
*/
|
||||
|
||||
import { logInfo, logWarn } from './logger.js';
|
||||
import { MCP_SESSION_ID_HEADER } from './constants.js';
|
||||
|
||||
/**
|
||||
* Detect the MCP transport protocol of a remote URL.
|
||||
*
|
||||
* Strategy (matches Rust mcp-proxy):
|
||||
* 1. Send a JSON-RPC initialize POST to probe for Streamable HTTP.
|
||||
* 2. Check 4 criteria — any match means streamable-http:
|
||||
* a. Response has `mcp-session-id` header
|
||||
* b. Content-Type is `text/event-stream` with 2xx status
|
||||
* c. Response body is valid JSON-RPC 2.0
|
||||
* d. Status is 406 Not Acceptable
|
||||
* 3. If probe fails or no criteria match → default to SSE.
|
||||
*/
|
||||
export async function detectProtocol(
|
||||
url: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<'sse' | 'stream'> {
|
||||
logInfo(`Auto-detecting protocol for ${url}...`);
|
||||
|
||||
try {
|
||||
const reqHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
...headers,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: reqHeaders,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'qiming-mcp-detect', version: '1.0.0' },
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Check 1: mcp-session-id header (definitive Streamable HTTP marker)
|
||||
if (res.headers.get(MCP_SESSION_ID_HEADER)) {
|
||||
logInfo(`Detected streamable-http protocol for ${url} (${MCP_SESSION_ID_HEADER} header)`);
|
||||
cleanupSession(url, res.headers.get(MCP_SESSION_ID_HEADER)!, headers);
|
||||
await res.text().catch(() => {});
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
|
||||
// Check 2: text/event-stream content-type with success status
|
||||
if (ct.includes('text/event-stream') && res.ok) {
|
||||
logInfo(`Detected streamable-http protocol for ${url} (event-stream response)`);
|
||||
cleanupSession(url, res.headers.get(MCP_SESSION_ID_HEADER), headers);
|
||||
// Abort the stream to free the connection
|
||||
controller.abort();
|
||||
return 'stream';
|
||||
}
|
||||
|
||||
// Read body for JSON-RPC check
|
||||
let bodyText = '';
|
||||
try { bodyText = await res.text(); } catch { /* ignore */ }
|
||||
|
||||
// Check 3: valid JSON-RPC 2.0 response
|
||||
try {
|
||||
const json = JSON.parse(bodyText);
|
||||
if (json && json.jsonrpc === '2.0') {
|
||||
logInfo(`Detected streamable-http protocol for ${url} (JSON-RPC 2.0 response)`);
|
||||
cleanupSession(url, res.headers.get(MCP_SESSION_ID_HEADER), headers);
|
||||
return 'stream';
|
||||
}
|
||||
} catch { /* not JSON */ }
|
||||
|
||||
// Check 4: 406 Not Acceptable (may indicate Streamable HTTP)
|
||||
if (res.status === 406) {
|
||||
logInfo(`Detected streamable-http protocol for ${url} (406 Not Acceptable)`);
|
||||
return 'stream';
|
||||
}
|
||||
} catch {
|
||||
// Probe failed (timeout, connection refused, etc.)
|
||||
}
|
||||
|
||||
// Default to SSE (matches Rust mcp-proxy behavior)
|
||||
logWarn(`Could not detect streamable-http for ${url}, defaulting to SSE`);
|
||||
return 'sse';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphan session — fire-and-forget DELETE so the server
|
||||
* can discard the half-initialized session we created during probing.
|
||||
*/
|
||||
function cleanupSession(
|
||||
url: string,
|
||||
sessionId: string | null,
|
||||
headers?: Record<string, string>,
|
||||
): void {
|
||||
if (!sessionId) return;
|
||||
fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { [MCP_SESSION_ID_HEADER]: sessionId, ...headers },
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
}).catch(() => {});
|
||||
}
|
||||
47
qimingclaw/crates/qiming-mcp-stdio-proxy/src/errors.ts
Normal file
47
qimingclaw/crates/qiming-mcp-stdio-proxy/src/errors.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Unified error types for qiming-mcp-stdio-proxy
|
||||
*
|
||||
* Provides structured error handling across all modules.
|
||||
*/
|
||||
|
||||
/** Connection error - used for transport layer connection failures */
|
||||
export class ConnectionError extends Error {
|
||||
constructor(
|
||||
public readonly serverId: string,
|
||||
public readonly cause: Error,
|
||||
) {
|
||||
super(`Connection failed for "${serverId}": ${cause.message}`);
|
||||
this.name = 'ConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Tool call error - used for tool execution failures */
|
||||
export class ToolCallError extends Error {
|
||||
constructor(
|
||||
public readonly toolName: string,
|
||||
public readonly serverId: string | undefined,
|
||||
public readonly cause: Error,
|
||||
) {
|
||||
super(`Tool "${toolName}" call failed: ${cause.message}`);
|
||||
this.name = 'ToolCallError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Health check error - used for heartbeat detection failures */
|
||||
export class HealthCheckError extends Error {
|
||||
constructor(
|
||||
public readonly serverId: string,
|
||||
public readonly consecutiveFailures: number,
|
||||
) {
|
||||
super(`Health check failed for "${serverId}" (${consecutiveFailures} consecutive failures)`);
|
||||
this.name = 'HealthCheckError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Configuration error - used for config validation failures */
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
27
qimingclaw/crates/qiming-mcp-stdio-proxy/src/filter.ts
Normal file
27
qimingclaw/crates/qiming-mcp-stdio-proxy/src/filter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Tool filtering — whitelist/blacklist tools by name
|
||||
*/
|
||||
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export interface ToolFilter {
|
||||
allowTools?: Set<string>;
|
||||
denyTools?: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter tools by allow/deny lists.
|
||||
* - If allowTools is set, only tools in the set are returned.
|
||||
* - If denyTools is set, tools in the set are excluded.
|
||||
* - If both are set, allowTools takes precedence.
|
||||
* - If neither is set, all tools are returned unchanged.
|
||||
*/
|
||||
export function filterTools(tools: Tool[], filter: ToolFilter): Tool[] {
|
||||
if (filter.allowTools && filter.allowTools.size > 0) {
|
||||
return tools.filter((t) => filter.allowTools!.has(t.name));
|
||||
}
|
||||
if (filter.denyTools && filter.denyTools.size > 0) {
|
||||
return tools.filter((t) => !filter.denyTools!.has(t.name));
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
316
qimingclaw/crates/qiming-mcp-stdio-proxy/src/index.ts
Normal file
316
qimingclaw/crates/qiming-mcp-stdio-proxy/src/index.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* qiming-mcp-stdio-proxy — CLI entry point
|
||||
*
|
||||
* A pure TypeScript MCP proxy with three operating modes:
|
||||
*
|
||||
* 1. **Default (stdio aggregation)**: Aggregates multiple MCP servers
|
||||
* (stdio + streamable-http + SSE) into a single stdio endpoint.
|
||||
* Usage: qiming-mcp-stdio-proxy --config '{"mcpServers":{...}}'
|
||||
*
|
||||
* 2. **convert**: Connects to a single remote MCP service and exposes via stdio.
|
||||
* Usage: qiming-mcp-stdio-proxy convert [URL] [OPTIONS]
|
||||
*
|
||||
* 3. **proxy**: Starts PersistentMcpBridge as a Streamable HTTP server.
|
||||
* Usage: qiming-mcp-stdio-proxy proxy --port 18099 --config '{"mcpServers":{...}}'
|
||||
*
|
||||
* This file handles CLI argument parsing and routes to the appropriate mode.
|
||||
* Mode implementations live in modes/*.ts.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type { McpServersConfig } from './types.js';
|
||||
import { logError } from './logger.js';
|
||||
import { runStdio } from './modes/stdio.js';
|
||||
import { runConvert } from './modes/convert.js';
|
||||
import { runProxy } from './modes/proxy.js';
|
||||
import { validateConfig } from './validation.js';
|
||||
|
||||
// ========== CLI Argument Types ==========
|
||||
|
||||
type CliArgs =
|
||||
| { mode: 'stdio'; config: McpServersConfig; allowTools?: string[]; denyTools?: string[] }
|
||||
| {
|
||||
mode: 'convert';
|
||||
url?: string;
|
||||
config?: McpServersConfig;
|
||||
name?: string;
|
||||
protocol?: 'sse' | 'stream';
|
||||
allowTools?: string[];
|
||||
denyTools?: string[];
|
||||
pingIntervalMs?: number;
|
||||
pingTimeoutMs?: number;
|
||||
}
|
||||
| { mode: 'proxy'; port: number; config: McpServersConfig };
|
||||
|
||||
// ========== CLI Parser ==========
|
||||
|
||||
function parseCliArgs(): CliArgs {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
logError('Missing arguments');
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const subcommand = args[0];
|
||||
|
||||
// Mode: convert
|
||||
if (subcommand === 'convert') {
|
||||
return parseConvertArgs(args.slice(1));
|
||||
}
|
||||
|
||||
// Mode: proxy
|
||||
if (subcommand === 'proxy') {
|
||||
return parseProxyArgs(args.slice(1));
|
||||
}
|
||||
|
||||
// Default mode: stdio aggregation (--config required)
|
||||
return parseStdioArgs(args);
|
||||
}
|
||||
|
||||
function parseStdioArgs(args: string[]): CliArgs & { mode: 'stdio' } {
|
||||
let configJson: string | undefined;
|
||||
let configFile: string | undefined;
|
||||
let allowTools: string[] | undefined;
|
||||
let denyTools: string[] | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--config' && i + 1 < args.length) {
|
||||
i++;
|
||||
configJson = args[i];
|
||||
} else if (arg === '--config-file' && i + 1 < args.length) {
|
||||
i++;
|
||||
configFile = args[i];
|
||||
} else if (arg === '--allow-tools' && i + 1 < args.length) {
|
||||
i++;
|
||||
allowTools = args[i].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
} else if (arg === '--deny-tools' && i + 1 < args.length) {
|
||||
i++;
|
||||
denyTools = args[i].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
if (!configJson && !configFile) {
|
||||
logError('Missing --config or --config-file argument');
|
||||
logError('Usage: qiming-mcp-stdio-proxy --config \'{"mcpServers":{...}}\'');
|
||||
logError(' or: qiming-mcp-stdio-proxy --config-file /path/to/config.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (configJson && configFile) {
|
||||
logError('Cannot use both --config and --config-file');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (allowTools && denyTools) {
|
||||
logError('Cannot use both --allow-tools and --deny-tools');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = configFile ? parseConfigFile(configFile) : parseConfigJson(configJson!);
|
||||
return { mode: 'stdio', config, allowTools, denyTools };
|
||||
}
|
||||
|
||||
function parseConvertArgs(args: string[]): CliArgs & { mode: 'convert' } {
|
||||
let url: string | undefined;
|
||||
let config: McpServersConfig | undefined;
|
||||
let name: string | undefined;
|
||||
let protocol: 'sse' | 'stream' | undefined;
|
||||
let allowTools: string[] | undefined;
|
||||
let denyTools: string[] | undefined;
|
||||
let pingIntervalMs: number | undefined;
|
||||
let pingTimeoutMs: number | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--config' && i + 1 < args.length) {
|
||||
i++;
|
||||
config = parseConfigJson(args[i]);
|
||||
} else if ((arg === '-n' || arg === '--name') && i + 1 < args.length) {
|
||||
i++;
|
||||
name = args[i];
|
||||
} else if (arg === '--protocol' && i + 1 < args.length) {
|
||||
i++;
|
||||
const p = args[i];
|
||||
if (p !== 'sse' && p !== 'stream') {
|
||||
logError(`Invalid protocol: "${p}" (must be "sse" or "stream")`);
|
||||
process.exit(1);
|
||||
}
|
||||
protocol = p;
|
||||
} else if (arg === '--allow-tools' && i + 1 < args.length) {
|
||||
i++;
|
||||
allowTools = args[i].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
} else if (arg === '--deny-tools' && i + 1 < args.length) {
|
||||
i++;
|
||||
denyTools = args[i].split(',').map((s) => s.trim()).filter(Boolean);
|
||||
} else if (arg === '--ping-interval' && i + 1 < args.length) {
|
||||
i++;
|
||||
pingIntervalMs = parseInt(args[i], 10);
|
||||
if (isNaN(pingIntervalMs)) {
|
||||
logError(`Invalid ping interval: "${args[i]}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (arg === '--ping-timeout' && i + 1 < args.length) {
|
||||
i++;
|
||||
pingTimeoutMs = parseInt(args[i], 10);
|
||||
if (isNaN(pingTimeoutMs)) {
|
||||
logError(`Invalid ping timeout: "${args[i]}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (!arg.startsWith('-') && !url) {
|
||||
url = arg;
|
||||
} else {
|
||||
logError(`Unknown argument: "${arg}"`);
|
||||
printConvertUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!url && !config) {
|
||||
logError('Either URL or --config is required for convert mode');
|
||||
printConvertUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (allowTools && denyTools) {
|
||||
logError('Cannot use both --allow-tools and --deny-tools');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { mode: 'convert', url, config, name, protocol, allowTools, denyTools, pingIntervalMs, pingTimeoutMs };
|
||||
}
|
||||
|
||||
function parseProxyArgs(args: string[]): CliArgs & { mode: 'proxy' } {
|
||||
let port: number | undefined;
|
||||
let config: McpServersConfig | undefined;
|
||||
let configFile: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--port' && i + 1 < args.length) {
|
||||
i++;
|
||||
const portStr = args[i];
|
||||
port = parseInt(portStr, 10);
|
||||
if (isNaN(port) || port < 0 || port > 65535) {
|
||||
logError(`Invalid port: "${portStr}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (arg === '--config' && i + 1 < args.length) {
|
||||
i++;
|
||||
config = parseConfigJson(args[i]);
|
||||
} else if (arg === '--config-file' && i + 1 < args.length) {
|
||||
i++;
|
||||
configFile = args[i];
|
||||
} else {
|
||||
logError(`Unknown argument: "${arg}"`);
|
||||
printProxyUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (port === undefined) {
|
||||
logError('--port is required for proxy mode');
|
||||
printProxyUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (configFile) {
|
||||
config = parseConfigFile(configFile);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
logError('--config or --config-file is required for proxy mode');
|
||||
printProxyUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { mode: 'proxy', port, config };
|
||||
}
|
||||
|
||||
function parseConfigJson(json: string): McpServersConfig {
|
||||
try {
|
||||
const raw = JSON.parse(json);
|
||||
return validateConfig(raw);
|
||||
} catch (e) {
|
||||
logError(`Failed to parse --config JSON: ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfigFile(filePath: string): McpServersConfig {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const raw = JSON.parse(content);
|
||||
return validateConfig(raw);
|
||||
} catch (e) {
|
||||
logError(`Failed to read or parse config file "${filePath}": ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Usage Messages ==========
|
||||
|
||||
function printUsage(): void {
|
||||
logError('Usage:');
|
||||
logError(' qiming-mcp-stdio-proxy --config \'{"mcpServers":{...}}\' [OPTIONS] (stdio aggregation)');
|
||||
logError(' qiming-mcp-stdio-proxy --config-file <FILE> [OPTIONS] (stdio aggregation from file)');
|
||||
logError(' qiming-mcp-stdio-proxy convert [URL] [OPTIONS] (remote → stdio)');
|
||||
logError(' qiming-mcp-stdio-proxy proxy --port <PORT> --config \'...\' (HTTP server)');
|
||||
logError('');
|
||||
logError('Options (stdio / convert):');
|
||||
logError(' --config <JSON> MCP config JSON string');
|
||||
logError(' --config-file <FILE> MCP config JSON file path');
|
||||
logError(' --allow-tools <TOOLS> Tool whitelist (comma-separated)');
|
||||
logError(' --deny-tools <TOOLS> Tool blacklist (comma-separated)');
|
||||
}
|
||||
|
||||
function printConvertUsage(): void {
|
||||
logError('Usage: qiming-mcp-stdio-proxy convert [URL] [OPTIONS]');
|
||||
logError('');
|
||||
logError('Arguments:');
|
||||
logError(' [URL] MCP service URL');
|
||||
logError('');
|
||||
logError('Options:');
|
||||
logError(' --config <JSON> MCP config JSON (alternative to URL)');
|
||||
logError(' -n, --name <NAME> Service name (for multi-service configs)');
|
||||
logError(' --protocol <sse|stream> Protocol type (auto-detect if omitted)');
|
||||
logError(' --allow-tools <TOOLS> Tool whitelist (comma-separated)');
|
||||
logError(' --deny-tools <TOOLS> Tool blacklist (comma-separated)');
|
||||
logError(' --ping-interval <MS> Heartbeat ping interval (default: 20000)');
|
||||
logError(' --ping-timeout <MS> Heartbeat ping timeout (default: 5000)');
|
||||
}
|
||||
|
||||
function printProxyUsage(): void {
|
||||
logError('Usage: qiming-mcp-stdio-proxy proxy --port <PORT> --config \'{"mcpServers":{...}}\'');
|
||||
logError(' or: qiming-mcp-stdio-proxy proxy --port <PORT> --config-file <FILE>');
|
||||
}
|
||||
|
||||
// ========== Entry Point ==========
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseCliArgs();
|
||||
|
||||
switch (args.mode) {
|
||||
case 'stdio':
|
||||
await runStdio(args.config, args.allowTools, args.denyTools);
|
||||
break;
|
||||
case 'convert':
|
||||
await runConvert(args);
|
||||
break;
|
||||
case 'proxy':
|
||||
await runProxy(args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logError(`Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
main().catch((error) => {
|
||||
logError(`Fatal error: ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
55
qimingclaw/crates/qiming-mcp-stdio-proxy/src/lib.ts
Normal file
55
qimingclaw/crates/qiming-mcp-stdio-proxy/src/lib.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Library entry point — re-exports for consumers
|
||||
*
|
||||
* CLI entry point remains at index.ts (bundled by esbuild).
|
||||
* This file provides typed exports for library consumers (e.g. Electron client).
|
||||
*/
|
||||
|
||||
// Bridge
|
||||
export { PersistentMcpBridge } from './bridge.js';
|
||||
export type { BridgeLogger } from './bridge.js';
|
||||
|
||||
// Custom stdio transport
|
||||
export { CustomStdioClientTransport } from './customStdio.js';
|
||||
export type { CustomStdioServerParameters } from './customStdio.js';
|
||||
|
||||
// Transport module
|
||||
export {
|
||||
buildBaseEnv,
|
||||
buildRequestHeaders,
|
||||
connectStdio,
|
||||
connectStreamable,
|
||||
connectSse,
|
||||
connectBridge,
|
||||
connectHttp,
|
||||
} from './transport/index.js';
|
||||
export type { ConnectedClient } from './transport/types.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
StdioServerEntry,
|
||||
StreamableServerEntry,
|
||||
SseServerEntry,
|
||||
BridgeServerEntry,
|
||||
HttpServerEntry,
|
||||
McpServerEntry,
|
||||
McpServersConfig,
|
||||
} from './types.js';
|
||||
export { isSseEntry, isStreamableEntry, isBridgeEntry } from './types.js';
|
||||
|
||||
// Filter
|
||||
export { filterTools } from './filter.js';
|
||||
export type { ToolFilter } from './filter.js';
|
||||
|
||||
// Protocol detection
|
||||
export { detectProtocol } from './detect.js';
|
||||
|
||||
// Shared module
|
||||
export { discoverTools, createToolProxyServer, setupGracefulShutdown } from './shared/index.js';
|
||||
export type { ToolResolver, ToolProxyServerOptions } from './shared/index.js';
|
||||
|
||||
// Errors
|
||||
export { ConnectionError, ToolCallError, HealthCheckError, ConfigError } from './errors.js';
|
||||
|
||||
// Validation
|
||||
export { validateConfig } from './validation.js';
|
||||
105
qimingclaw/crates/qiming-mcp-stdio-proxy/src/logger.ts
Normal file
105
qimingclaw/crates/qiming-mcp-stdio-proxy/src/logger.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Logging utilities — stderr only, stdout is the MCP JSON-RPC channel.
|
||||
*
|
||||
* When the environment variable MCP_PROXY_LOG_FILE is set, log lines are
|
||||
* also appended to that file so the Electron host can tail them into main.log.
|
||||
*
|
||||
* Log rotation: file is named by date (e.g. mcp-proxy-2026-03-09.log).
|
||||
* A new file is created each day. Old files beyond MAX_LOG_FILES are deleted.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const logFilePath = process.env.MCP_PROXY_LOG_FILE;
|
||||
const MAX_LOG_FILES = 7;
|
||||
|
||||
let logStream: fs.WriteStream | null = null;
|
||||
let logDir = '';
|
||||
let logBaseName = '';
|
||||
let logExt = '';
|
||||
let currentDateStr = '';
|
||||
|
||||
function dateStr(): string {
|
||||
const d = new Date();
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
function openLogFile(): void {
|
||||
if (!logFilePath) return;
|
||||
|
||||
const today = dateStr();
|
||||
if (today === currentDateStr && logStream) return;
|
||||
|
||||
// Close previous stream
|
||||
if (logStream) {
|
||||
try { logStream.end(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
currentDateStr = today;
|
||||
const dated = path.join(logDir, `${logBaseName}-${today}${logExt}`);
|
||||
try {
|
||||
logStream = fs.createWriteStream(dated, { flags: 'a' });
|
||||
} catch {
|
||||
logStream = null;
|
||||
}
|
||||
|
||||
// Cleanup old log files
|
||||
cleanupOldLogs();
|
||||
}
|
||||
|
||||
function cleanupOldLogs(): void {
|
||||
if (!logDir || !logBaseName) return;
|
||||
try {
|
||||
const prefix = `${logBaseName}-`;
|
||||
const files = fs.readdirSync(logDir)
|
||||
.filter(f => f.startsWith(prefix) && f.endsWith(logExt))
|
||||
.sort()
|
||||
.reverse();
|
||||
for (let i = MAX_LOG_FILES; i < files.length; i++) {
|
||||
try { fs.unlinkSync(path.join(logDir, files[i])); } catch { /* ignore */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (logFilePath) {
|
||||
try {
|
||||
logDir = path.dirname(logFilePath);
|
||||
if (logDir && !fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
const fullName = path.basename(logFilePath);
|
||||
const dotIdx = fullName.lastIndexOf('.');
|
||||
if (dotIdx > 0) {
|
||||
logBaseName = fullName.substring(0, dotIdx);
|
||||
logExt = fullName.substring(dotIdx);
|
||||
} else {
|
||||
logBaseName = fullName;
|
||||
logExt = '.log';
|
||||
}
|
||||
openLogFile();
|
||||
} catch {
|
||||
// If file creation fails, continue without file logging
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
const d = new Date();
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
const pad3 = (n: number) => String(n).padStart(3, '0');
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
|
||||
}
|
||||
|
||||
export function log(level: string, msg: string): void {
|
||||
const line = `[${timestamp()}] [${level.toLowerCase()}] [qiming-mcp-proxy] ${msg}\n`;
|
||||
process.stderr.write(line);
|
||||
if (logFilePath) {
|
||||
openLogFile(); // Rotate if date changed
|
||||
logStream?.write(line);
|
||||
}
|
||||
}
|
||||
|
||||
export const logInfo = (msg: string) => log('INFO', msg);
|
||||
export const logWarn = (msg: string) => log('WARN', msg);
|
||||
export const logError = (msg: string) => log('ERROR', msg);
|
||||
133
qimingclaw/crates/qiming-mcp-stdio-proxy/src/modes/convert.ts
Normal file
133
qimingclaw/crates/qiming-mcp-stdio-proxy/src/modes/convert.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Mode: convert (remote URL → stdio)
|
||||
*
|
||||
* Connects to a single remote MCP service (SSE or Streamable HTTP)
|
||||
* and exposes it as a stdio MCP endpoint. Supports tool filtering.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
import type { McpServersConfig, StreamableServerEntry, SseServerEntry } from '../types.js';
|
||||
import { logInfo, logWarn, logError } from '../logger.js';
|
||||
import { connectStreamable, connectSse, buildRequestHeaders } from '../transport/index.js';
|
||||
import { filterTools } from '../filter.js';
|
||||
import type { ToolFilter } from '../filter.js';
|
||||
import { detectProtocol } from '../detect.js';
|
||||
import { discoverTools, createToolProxyServer, setupGracefulShutdown } from '../shared/index.js';
|
||||
|
||||
export interface ConvertArgs {
|
||||
url?: string;
|
||||
config?: McpServersConfig;
|
||||
name?: string;
|
||||
protocol?: 'sse' | 'stream';
|
||||
allowTools?: string[];
|
||||
denyTools?: string[];
|
||||
pingIntervalMs?: number;
|
||||
pingTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function runConvert(args: ConvertArgs): Promise<void> {
|
||||
// 1. Resolve the target URL and headers
|
||||
let targetUrl: string;
|
||||
let targetHeaders: Record<string, string> | undefined;
|
||||
let protocolHint: 'sse' | 'stream' | undefined = args.protocol;
|
||||
|
||||
if (args.url) {
|
||||
targetUrl = args.url;
|
||||
} else if (args.config) {
|
||||
const serverEntries = Object.entries(args.config.mcpServers);
|
||||
if (serverEntries.length === 0) {
|
||||
logError('No servers found in config');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// If --name specified, find that entry; otherwise use the first entry
|
||||
let selected: [string, typeof serverEntries[0][1]];
|
||||
if (args.name) {
|
||||
const found = serverEntries.find(([id]) => id === args.name);
|
||||
if (!found) {
|
||||
logError(`Server "${args.name}" not found in config. Available: ${serverEntries.map(([id]) => id).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
selected = found;
|
||||
} else {
|
||||
if (serverEntries.length > 1) {
|
||||
logWarn(`Multiple servers in config, using first: "${serverEntries[0][0]}". Use --name to select.`);
|
||||
}
|
||||
selected = serverEntries[0];
|
||||
}
|
||||
|
||||
const [, entry] = selected;
|
||||
if (!('url' in entry) || typeof entry.url !== 'string') {
|
||||
logError('Selected server entry must have a "url" field for convert mode');
|
||||
process.exit(1);
|
||||
}
|
||||
targetUrl = entry.url;
|
||||
|
||||
// Extract headers/authToken from entry
|
||||
const httpEntry = entry as StreamableServerEntry | SseServerEntry;
|
||||
targetHeaders = buildRequestHeaders(httpEntry);
|
||||
// If entry has explicit transport, use it as protocol hint
|
||||
if ('transport' in entry && entry.transport === 'sse' && !protocolHint) {
|
||||
protocolHint = 'sse';
|
||||
}
|
||||
} else {
|
||||
logError('Either URL or --config is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Detect protocol
|
||||
let protocol = protocolHint;
|
||||
if (!protocol) {
|
||||
protocol = await detectProtocol(targetUrl, targetHeaders);
|
||||
}
|
||||
|
||||
logInfo(`Connecting to ${targetUrl} via ${protocol === 'sse' ? 'SSE' : 'Streamable HTTP'}...`);
|
||||
|
||||
// 3. Connect to the remote server
|
||||
const entryId = 'remote';
|
||||
let connected: { client: Client; cleanup: () => Promise<void> };
|
||||
|
||||
if (protocol === 'sse') {
|
||||
const sseEntry: SseServerEntry = { url: targetUrl, transport: 'sse', pingIntervalMs: args.pingIntervalMs, pingTimeoutMs: args.pingTimeoutMs };
|
||||
if (targetHeaders) sseEntry.headers = targetHeaders;
|
||||
connected = await connectSse(entryId, sseEntry);
|
||||
} else {
|
||||
const streamEntry: StreamableServerEntry = { url: targetUrl, pingIntervalMs: args.pingIntervalMs, pingTimeoutMs: args.pingTimeoutMs };
|
||||
if (targetHeaders) streamEntry.headers = targetHeaders;
|
||||
connected = await connectStreamable(entryId, streamEntry);
|
||||
}
|
||||
|
||||
const { client: remoteClient, cleanup } = connected;
|
||||
|
||||
// 4. Discover tools
|
||||
const allTools = await discoverTools(remoteClient);
|
||||
logInfo(`Remote server has ${allTools.length} tool(s)`);
|
||||
|
||||
// 5. Apply tool filtering
|
||||
const toolFilter: ToolFilter = {};
|
||||
if (args.allowTools) toolFilter.allowTools = new Set(args.allowTools);
|
||||
if (args.denyTools) toolFilter.denyTools = new Set(args.denyTools);
|
||||
|
||||
const filteredTools = filterTools(allTools, toolFilter);
|
||||
const filteredNames = new Set(filteredTools.map((t) => t.name));
|
||||
|
||||
if (filteredTools.length !== allTools.length) {
|
||||
logInfo(`After filtering: ${filteredTools.length} tool(s)`);
|
||||
}
|
||||
|
||||
// 6. Create stdio MCP server that proxies to the remote client
|
||||
const { server } = await createToolProxyServer({
|
||||
tools: filteredTools,
|
||||
resolveClient: (name) => filteredNames.has(name) ? remoteClient : undefined,
|
||||
});
|
||||
|
||||
logInfo('Convert proxy running on stdio');
|
||||
|
||||
// Graceful shutdown
|
||||
setupGracefulShutdown(async () => {
|
||||
try { await remoteClient.close(); } catch { /* ignore */ }
|
||||
try { await cleanup(); } catch { /* ignore */ }
|
||||
try { await server.close(); } catch { /* ignore */ }
|
||||
});
|
||||
}
|
||||
51
qimingclaw/crates/qiming-mcp-stdio-proxy/src/modes/proxy.ts
Normal file
51
qimingclaw/crates/qiming-mcp-stdio-proxy/src/modes/proxy.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Mode: proxy (Streamable HTTP server)
|
||||
*
|
||||
* Starts PersistentMcpBridge as a Streamable HTTP server,
|
||||
* exposing stdio MCP servers over HTTP on a specified port.
|
||||
*/
|
||||
|
||||
import type { McpServersConfig } from '../types.js';
|
||||
import { logInfo, logWarn, logError } from '../logger.js';
|
||||
import { PersistentMcpBridge } from '../bridge.js';
|
||||
import { setupGracefulShutdown } from '../shared/index.js';
|
||||
|
||||
export interface ProxyArgs {
|
||||
port: number;
|
||||
config: McpServersConfig;
|
||||
}
|
||||
|
||||
export async function runProxy(args: ProxyArgs): Promise<void> {
|
||||
const { port, config } = args;
|
||||
|
||||
// Filter to only stdio entries for PersistentMcpBridge
|
||||
const stdioEntries: Record<string, { command: string; args?: string[]; env?: Record<string, string> }> = {};
|
||||
for (const [id, entry] of Object.entries(config.mcpServers)) {
|
||||
if (!('url' in entry)) {
|
||||
stdioEntries[id] = entry;
|
||||
} else {
|
||||
logWarn(`Skipping non-stdio server "${id}" in proxy mode (only stdio servers supported)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(stdioEntries).length === 0) {
|
||||
logError('No stdio servers found in config for proxy mode');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logInfo(`Starting proxy HTTP server on port ${port} with ${Object.keys(stdioEntries).length} server(s)...`);
|
||||
|
||||
const bridge = new PersistentMcpBridge({
|
||||
info: (...a: unknown[]) => logInfo(a.map(String).join(' ')),
|
||||
warn: (...a: unknown[]) => logWarn(a.map(String).join(' ')),
|
||||
error: (...a: unknown[]) => logError(a.map(String).join(' ')),
|
||||
});
|
||||
|
||||
await bridge.start(stdioEntries, { port });
|
||||
|
||||
logInfo(`Proxy HTTP server running on port ${port}`);
|
||||
|
||||
setupGracefulShutdown(async () => {
|
||||
await bridge.stop();
|
||||
});
|
||||
}
|
||||
182
qimingclaw/crates/qiming-mcp-stdio-proxy/src/modes/stdio.ts
Normal file
182
qimingclaw/crates/qiming-mcp-stdio-proxy/src/modes/stdio.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Mode: stdio aggregation
|
||||
*
|
||||
* Aggregates multiple MCP servers (stdio + streamable-http + SSE)
|
||||
* into a single stdio MCP endpoint.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import type { McpServersConfig } from '../types.js';
|
||||
import { isSseEntry, isStreamableEntry, needsProtocolDetection } from '../types.js';
|
||||
import { logInfo, logWarn, logError } from '../logger.js';
|
||||
import { buildBaseEnv, connectStdio, connectStreamable, connectSse, buildRequestHeaders } from '../transport/index.js';
|
||||
import { discoverTools, createToolProxyServer, setupGracefulShutdown } from '../shared/index.js';
|
||||
import { filterTools } from '../filter.js';
|
||||
import type { ToolFilter } from '../filter.js';
|
||||
import { detectProtocol } from '../detect.js';
|
||||
|
||||
export async function runStdio(
|
||||
config: McpServersConfig,
|
||||
allowTools?: string[],
|
||||
denyTools?: string[],
|
||||
): Promise<void> {
|
||||
const entries = Object.entries(config.mcpServers);
|
||||
|
||||
if (entries.length === 0) {
|
||||
logError('No MCP servers configured in mcpServers');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logInfo(
|
||||
`Starting proxy with ${entries.length} server(s): ${entries.map(([id]) => id).join(', ')}`,
|
||||
);
|
||||
|
||||
// ---- Phase 1: Connect to all MCP servers (stdio + streamable + sse) ----
|
||||
|
||||
const baseEnv = buildBaseEnv();
|
||||
|
||||
const clients = new Map<string, Client>();
|
||||
const cleanups = new Map<string, () => Promise<void>>();
|
||||
const toolToClient = new Map<string, Client>();
|
||||
const toolToServer = new Map<string, string>();
|
||||
const toolsByName = new Map<string, Tool>();
|
||||
|
||||
// Filter out persistent entries (handled by PersistentMcpBridge, not this proxy)
|
||||
const connectableEntries = entries.filter(([id, entry]) => {
|
||||
if ((entry as any).persistent) {
|
||||
logWarn(`Skipping persistent server "${id}" (handled by PersistentMcpBridge)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Connect to all servers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
connectableEntries.map(async ([id, entry]) => {
|
||||
try {
|
||||
let connected: { client: Client; cleanup: () => Promise<void> };
|
||||
|
||||
if (isSseEntry(entry)) {
|
||||
connected = await connectSse(id, entry);
|
||||
} else if (isStreamableEntry(entry)) {
|
||||
connected = await connectStreamable(id, entry);
|
||||
} else if (needsProtocolDetection(entry)) {
|
||||
// No explicit transport — probe the URL to determine protocol
|
||||
const detected = await detectProtocol(entry.url, buildRequestHeaders(entry));
|
||||
if (detected === 'sse') {
|
||||
connected = await connectSse(id, { ...entry, transport: 'sse' });
|
||||
} else {
|
||||
connected = await connectStreamable(id, entry);
|
||||
}
|
||||
} else {
|
||||
connected = await connectStdio(id, entry, baseEnv);
|
||||
}
|
||||
|
||||
return { id, entry, connected };
|
||||
} catch (e) {
|
||||
throw new Error(`Server "${id}": ${e}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logError(`Failed to connect: ${result.reason}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { id, entry, connected } = result.value;
|
||||
const { client, cleanup } = connected;
|
||||
clients.set(id, client);
|
||||
cleanups.set(id, cleanup);
|
||||
|
||||
let serverTools = await discoverTools(client);
|
||||
|
||||
// Per-server tool filtering (allowTools/denyTools in config entry)
|
||||
if (entry.allowTools || entry.denyTools) {
|
||||
const perFilter: ToolFilter = {};
|
||||
if (entry.allowTools) perFilter.allowTools = new Set(entry.allowTools);
|
||||
if (entry.denyTools) perFilter.denyTools = new Set(entry.denyTools);
|
||||
const before = serverTools.length;
|
||||
serverTools = filterTools(serverTools, perFilter);
|
||||
if (serverTools.length !== before) {
|
||||
logInfo(`Server "${id}": filtered ${before} → ${serverTools.length} tool(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
logInfo(
|
||||
`Server "${id}": ${serverTools.length} tool(s)${serverTools.length > 0 ? ' — ' + serverTools.map((t) => t.name).join(', ') : ''}`,
|
||||
);
|
||||
|
||||
for (const tool of serverTools) {
|
||||
if (toolToClient.has(tool.name)) {
|
||||
logWarn(
|
||||
`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`,
|
||||
);
|
||||
}
|
||||
toolToClient.set(tool.name, client);
|
||||
toolToServer.set(tool.name, id);
|
||||
toolsByName.set(tool.name, tool);
|
||||
}
|
||||
}
|
||||
|
||||
if (clients.size === 0) {
|
||||
logError('Failed to connect to any MCP server');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const aggregatedTools = Array.from(toolsByName.values());
|
||||
logInfo(`Aggregated ${aggregatedTools.length} unique tool(s) from ${clients.size} server(s)`);
|
||||
|
||||
// ---- Phase 1.5: Apply tool filtering (allow/deny) ----
|
||||
|
||||
const toolFilter: ToolFilter = {};
|
||||
if (allowTools) toolFilter.allowTools = new Set(allowTools);
|
||||
if (denyTools) toolFilter.denyTools = new Set(denyTools);
|
||||
|
||||
const filteredTools = filterTools(aggregatedTools, toolFilter);
|
||||
const filteredNames = new Set(filteredTools.map((t) => t.name));
|
||||
|
||||
if (filteredTools.length !== aggregatedTools.length) {
|
||||
logInfo(`After filtering: ${filteredTools.length} tool(s) — ${filteredTools.map((t) => t.name).join(', ')}`);
|
||||
}
|
||||
|
||||
// ---- Phase 2: Create the aggregating MCP server ----
|
||||
|
||||
const { server } = await createToolProxyServer({
|
||||
tools: filteredTools,
|
||||
resolveClient: (name) => filteredNames.has(name) ? toolToClient.get(name) : undefined,
|
||||
errorLabel: (name) => `"${name}" (server: "${toolToServer.get(name) || 'unknown'}")`,
|
||||
});
|
||||
|
||||
logInfo('Proxy server running on stdio');
|
||||
|
||||
// ---- Graceful shutdown ----
|
||||
|
||||
setupGracefulShutdown(async () => {
|
||||
for (const [id, client] of clients) {
|
||||
try {
|
||||
await client.close();
|
||||
logInfo(`Closed client "${id}"`);
|
||||
} catch (e) {
|
||||
logError(`Failed to close client "${id}": ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, cleanup] of cleanups) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (e) {
|
||||
logError(`Failed cleanup for "${id}": ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await server.close();
|
||||
} catch {
|
||||
// Ignore close errors during shutdown
|
||||
}
|
||||
});
|
||||
}
|
||||
584
qimingclaw/crates/qiming-mcp-stdio-proxy/src/resilient.ts
Normal file
584
qimingclaw/crates/qiming-mcp-stdio-proxy/src/resilient.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Resilient Transport Wrapper for MCP
|
||||
*
|
||||
* Provides heartbeat monitoring, automatic reconnection, and request queueing
|
||||
* for MCP transports (HTTP, SSE, Stdio).
|
||||
*
|
||||
* Retry strategy: exponential backoff 1s → 2s → 4s → ... → 60s (capped),
|
||||
* unlimited retries. Matches the Rust mcp-proxy CappedExponentialBackoff.
|
||||
*
|
||||
* NOTE: This class is a pure Transport decorator with NO MCP protocol knowledge.
|
||||
* - Health checks are delegated to the caller via `healthCheckFn`
|
||||
* - MCP session re-initialization on reconnect is handled by caller via `onreconnect`
|
||||
*/
|
||||
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { logInfo, logWarn, logError } from './logger.js';
|
||||
|
||||
/** Logger interface — compatible with console, electron-log, and BridgeLogger */
|
||||
export interface ResilientLogger {
|
||||
info: (...args: unknown[]) => void;
|
||||
warn: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface ResilientTransportOptions {
|
||||
/** Connection factory function */
|
||||
connectParams: () => Promise<Transport>;
|
||||
/** Heartbeat check interval (ms). Default: 20000. Set to 0 to disable. */
|
||||
pingIntervalMs?: number;
|
||||
/** Max consecutive failures before reconnecting. Default: 3 */
|
||||
maxConsecutiveFailures?: number;
|
||||
/** Timeout for health check (ms). Default: 5000 */
|
||||
pingTimeoutMs?: number;
|
||||
/** Base backoff delay for reconnect (ms). Default: 1000 */
|
||||
reconnectDelayMs?: number;
|
||||
/** Max backoff delay cap (ms). Default: 60000 */
|
||||
maxReconnectDelayMs?: number;
|
||||
/** Max queued requests during reconnect. Default: 100 */
|
||||
maxQueueSize?: number;
|
||||
/** Server name/ID for logging */
|
||||
name?: string;
|
||||
/**
|
||||
* Optional custom logger. When running inside the Electron main process
|
||||
* (e.g. PersistentMcpBridge), pass electron-log so heartbeat/reconnect
|
||||
* logs appear in main.log alongside other application logs.
|
||||
* Defaults to the built-in stderr logger (logger.js).
|
||||
*/
|
||||
logger?: ResilientLogger;
|
||||
/**
|
||||
* Optional health check function. Called periodically to verify the
|
||||
* connection is still alive. Should return true if healthy, false otherwise.
|
||||
* If not provided, only transport-level liveness is checked.
|
||||
*
|
||||
* Example: Use MCP Client's listTools() method for health checking:
|
||||
* healthCheckFn: async () => {
|
||||
* try { await client.listTools(); return true; }
|
||||
* catch { return false; }
|
||||
* }
|
||||
*/
|
||||
healthCheckFn?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
// Heartbeat interval for SSE/HTTP connections using ping()
|
||||
// Reduced to 20s to keep connection alive (servers may close idle connections after 60s)
|
||||
pingIntervalMs: 30000,
|
||||
maxConsecutiveFailures: 3,
|
||||
pingTimeoutMs: 5000,
|
||||
reconnectDelayMs: 1000,
|
||||
maxReconnectDelayMs: 60000,
|
||||
maxQueueSize: 100,
|
||||
name: 'remote',
|
||||
healthCheckFn: undefined as (() => Promise<boolean>) | undefined,
|
||||
};
|
||||
|
||||
/** Default logger that delegates to logger.js (stderr) */
|
||||
const defaultLogger: ResilientLogger = {
|
||||
info: (...args) => logInfo(args.map(String).join(' ')),
|
||||
warn: (...args) => logWarn(args.map(String).join(' ')),
|
||||
error: (...args) => logError(args.map(String).join(' ')),
|
||||
};
|
||||
|
||||
export class ResilientTransportWrapper implements Transport {
|
||||
private options: Required<Omit<ResilientTransportOptions, 'healthCheckFn'>> & { healthCheckFn?: () => Promise<boolean> };
|
||||
private log: ResilientLogger;
|
||||
private activeTransport: Transport | null = null;
|
||||
private healthCheckFn?: () => Promise<boolean>;
|
||||
|
||||
// Note: Using setTimeout for response-driven heartbeat (not setInterval)
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private consecutiveFailures = 0;
|
||||
private consecutivePingTimeouts = 0;
|
||||
private heartbeatOkCount = 0;
|
||||
|
||||
/** Guard to prevent concurrent health check executions */
|
||||
private healthCheckInProgress = false;
|
||||
|
||||
private state: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed' = 'idle';
|
||||
|
||||
/** Current retry attempt count (reset on successful connect) */
|
||||
private retryAttempt = 0;
|
||||
|
||||
// Handlers required by the Transport interface
|
||||
// These are set by SDK's Protocol.connect() and should be called when events occur
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
/**
|
||||
* Optional reconnect handler. Called after a successful reconnect (transport
|
||||
* re-established) but before resuming normal operation. The caller should
|
||||
* use this to re-initialize the MCP session (e.g., call client.connect()).
|
||||
*
|
||||
* If this handler throws, the reconnect is treated as failed and a retry
|
||||
* with backoff is scheduled.
|
||||
*/
|
||||
onreconnect?: () => Promise<void>;
|
||||
|
||||
// Queue for messages sent while reconnecting
|
||||
private messageQueue: JSONRPCMessage[] = [];
|
||||
|
||||
/** Flag to track if we've already logged an error during this reconnect cycle */
|
||||
private hasLoggedError = false;
|
||||
|
||||
constructor(options: ResilientTransportOptions) {
|
||||
this.log = options.logger ?? defaultLogger;
|
||||
this.healthCheckFn = options.healthCheckFn;
|
||||
|
||||
// Build options by starting from defaults and only overriding with
|
||||
// explicitly provided values. Spreading { pingIntervalMs: undefined }
|
||||
// would clobber the default 20000, causing setInterval(fn, undefined)
|
||||
// → interval ~0ms (fires every tick).
|
||||
this.options = {
|
||||
...DEFAULT_OPTIONS,
|
||||
logger: this.log,
|
||||
connectParams: options.connectParams,
|
||||
healthCheckFn: options.healthCheckFn,
|
||||
...(options.name !== undefined && { name: options.name }),
|
||||
...(options.pingIntervalMs !== undefined && { pingIntervalMs: options.pingIntervalMs }),
|
||||
...(options.pingTimeoutMs !== undefined && { pingTimeoutMs: options.pingTimeoutMs }),
|
||||
...(options.maxConsecutiveFailures !== undefined && { maxConsecutiveFailures: options.maxConsecutiveFailures }),
|
||||
...(options.reconnectDelayMs !== undefined && { reconnectDelayMs: options.reconnectDelayMs }),
|
||||
...(options.maxReconnectDelayMs !== undefined && { maxReconnectDelayMs: options.maxReconnectDelayMs }),
|
||||
...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the health check function after construction.
|
||||
* Useful when the caller needs to create the wrapper before having
|
||||
* a Client instance available.
|
||||
*/
|
||||
setHealthCheckFn(fn: (() => Promise<boolean>) | undefined): void {
|
||||
this.healthCheckFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate backoff delay using capped exponential backoff.
|
||||
* 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
|
||||
*/
|
||||
private getBackoffDelay(): number {
|
||||
const delay = this.options.reconnectDelayMs * Math.pow(2, this.retryAttempt);
|
||||
return Math.min(delay, this.options.maxReconnectDelayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the transport and connects to the backend
|
||||
*
|
||||
* This method is idempotent - if the transport is already connected or
|
||||
* connecting, subsequent calls will return immediately without creating
|
||||
* duplicate connections. This is important because MCP SDK's client.connect()
|
||||
* internally calls transport.start(), and we also call it explicitly in bridge.ts.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Idempotent check: if already connected, connecting, or retrying, return early
|
||||
if (this.state === 'connected' || this.state === 'connecting' || this.state === 'reconnecting') {
|
||||
// DEBUG: Log when start() is skipped due to idempotent check
|
||||
// this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ⏭️ start() skipped (state: ${this.state})`);
|
||||
return;
|
||||
}
|
||||
this.state = 'connecting';
|
||||
await this.performConnect(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable heartbeat monitoring. Call this AFTER the MCP client has
|
||||
* completed its initialize handshake (client.connect()), otherwise
|
||||
* the health check may fail if it requires an initialized session.
|
||||
*/
|
||||
enableHeartbeat(): void {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
private async performConnect(initial = false): Promise<void> {
|
||||
if (this.state === 'closed') return;
|
||||
|
||||
try {
|
||||
// Clean up old transport BEFORE creating new one to prevent stale event handlers
|
||||
if (this.activeTransport) {
|
||||
const oldTransport = this.activeTransport;
|
||||
const endpoint = (oldTransport as any)?._endpoint?.href || (oldTransport as any)?._endpoint || 'no-endpoint';
|
||||
const sessionId = endpoint.includes('sessionId=')
|
||||
? endpoint.split('sessionId=')[1].split('&')[0]
|
||||
: 'n/a';
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🧹 Cleaning up old transport (session: ${sessionId})`);
|
||||
|
||||
// Detach handlers first to prevent stale events
|
||||
oldTransport.onclose = undefined;
|
||||
oldTransport.onerror = undefined;
|
||||
oldTransport.onmessage = undefined;
|
||||
try {
|
||||
await oldTransport.close();
|
||||
} catch { /* ignore */ }
|
||||
this.activeTransport = null;
|
||||
}
|
||||
|
||||
this.activeTransport = await this.options.connectParams();
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔧 Created new transport (${this.activeTransport.constructor.name})`);
|
||||
|
||||
// Bind handlers to the inner transport
|
||||
this.bindInnerTransport(this.activeTransport);
|
||||
|
||||
await this.activeTransport.start();
|
||||
|
||||
this.state = 'connected';
|
||||
this.consecutiveFailures = 0;
|
||||
this.consecutivePingTimeouts = 0;
|
||||
this.heartbeatOkCount = 0;
|
||||
// Reset error logging flag on successful connect
|
||||
this.hasLoggedError = false;
|
||||
|
||||
// Extract session info from SSE endpoint (if available)
|
||||
const endpoint = (this.activeTransport as any)?._endpoint;
|
||||
if (endpoint) {
|
||||
const endpointUrl = new URL(endpoint);
|
||||
const sessionId = endpointUrl.searchParams.get('session_id') || endpointUrl.pathname.split('/').pop() || endpoint;
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ Connected via ${this.activeTransport.constructor.name} (session: ${sessionId}, endpoint: ${endpoint})`);
|
||||
} else {
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ Connected via ${this.activeTransport.constructor.name}`);
|
||||
}
|
||||
|
||||
// On reconnect (not initial), invoke the onreconnect handler for MCP session re-initialization
|
||||
if (!initial && this.onreconnect) {
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Invoking reconnect handler...`);
|
||||
try {
|
||||
await this.onreconnect();
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ Reconnect handler completed`);
|
||||
} catch (err) {
|
||||
// Reconnect handler failed — treat as a connect failure, preserve backoff
|
||||
this.retryAttempt++;
|
||||
const delay = this.getBackoffDelay();
|
||||
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] ❌ Reconnect handler failed: ${err}`);
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
|
||||
this.state = 'reconnecting';
|
||||
this.cleanupTransport();
|
||||
setTimeout(() => {
|
||||
this.performConnect();
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset retry count only after successful connect (and reconnect handler if applicable)
|
||||
this.retryAttempt = 0;
|
||||
|
||||
// Clear any requests queued during onreconnect (they may use stale session)
|
||||
if (this.messageQueue.length > 0) {
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🗑️ Dropping ${this.messageQueue.length} requests queued during reconnect`);
|
||||
this.messageQueue = [];
|
||||
}
|
||||
|
||||
// Only start heartbeat on reconnects — initial connections need
|
||||
// the caller to invoke enableHeartbeat() after client.connect()
|
||||
// completes the MCP initialize handshake. Sending health checks before
|
||||
// initialize may cause errors.
|
||||
if (!initial) {
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.retryAttempt++;
|
||||
|
||||
if (initial) {
|
||||
// For initial connection failure, throw to let caller handle retry logic.
|
||||
// Caller (e.g., bridge.ts) has its own retry mechanism with max attempts.
|
||||
this.state = 'idle';
|
||||
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] ❌ Initial connection failed: ${err}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// For reconnection failures, schedule automatic retry with backoff
|
||||
const delay = this.getBackoffDelay();
|
||||
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] ❌ Connect failed (attempt ${this.retryAttempt}): ${err}`);
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Retrying in ${delay}ms...`);
|
||||
this.state = 'reconnecting';
|
||||
setTimeout(() => {
|
||||
this.performConnect();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
private bindInnerTransport(transport: Transport) {
|
||||
// Flag to prevent both onerror and onclose from triggering reconnect
|
||||
let hasInitiatedReconnect = false;
|
||||
|
||||
transport.onclose = () => {
|
||||
if (this.state !== 'closed') {
|
||||
// Get session_id from current transport
|
||||
const endpoint = (transport as any)?._endpoint?.href || (transport as any)?._endpoint || 'no-endpoint';
|
||||
const sessionId = endpoint.includes('sessionId=')
|
||||
? endpoint.split('sessionId=')[1].split('&')[0]
|
||||
: 'n/a';
|
||||
|
||||
// Forward to SDK handler (protected to ensure reconnect always triggers)
|
||||
if (this.onclose) {
|
||||
try { this.onclose(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Only trigger reconnect if onerror hasn't already done so
|
||||
if (!hasInitiatedReconnect && this.state !== 'reconnecting') {
|
||||
hasInitiatedReconnect = true;
|
||||
this.triggerReconnect(sessionId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
transport.onerror = (error: Error) => {
|
||||
if (this.state !== 'closed') {
|
||||
// Handle undefined error.message (e.g., SDK's SseError)
|
||||
const errorMsg = error?.message ?? String(error);
|
||||
|
||||
// Get session_id from current transport
|
||||
const endpoint = (transport as any)?._endpoint?.href || (transport as any)?._endpoint || 'no-endpoint';
|
||||
const sessionId = endpoint.includes('sessionId=')
|
||||
? endpoint.split('sessionId=')[1].split('&')[0]
|
||||
: 'n/a';
|
||||
|
||||
// Forward to SDK handler (protected to ensure reconnect logic continues)
|
||||
if (this.onerror) {
|
||||
try { this.onerror(error); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (this.state === 'connecting') {
|
||||
// Log during initial connection - performConnect will handle the error
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Connection error (session: ${sessionId}): ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger reconnect once per transport
|
||||
if (!hasInitiatedReconnect && this.state !== 'reconnecting') {
|
||||
hasInitiatedReconnect = true;
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Transport error (session: ${sessionId}): ${errorMsg}`);
|
||||
this.triggerReconnect(sessionId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
transport.onmessage = (message: JSONRPCMessage) => {
|
||||
// Forward to SDK handler
|
||||
if (this.onmessage) {
|
||||
this.onmessage(message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private startHeartbeat() {
|
||||
this.stopHeartbeat();
|
||||
if (this.options.pingIntervalMs <= 0) return;
|
||||
|
||||
// Use response-driven scheduling: schedule next check AFTER current one completes.
|
||||
// This prevents request piling when network is slow.
|
||||
this.scheduleNextHealthCheck();
|
||||
}
|
||||
|
||||
private stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearTimeout(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next health check after pingIntervalMs.
|
||||
* Called after each health check completes (response-driven heartbeat).
|
||||
*/
|
||||
private scheduleNextHealthCheck() {
|
||||
if (this.state !== 'connected' || this.options.pingIntervalMs <= 0) return;
|
||||
// Clear any existing timer before scheduling new one
|
||||
if (this.heartbeatTimer) {
|
||||
clearTimeout(this.heartbeatTimer);
|
||||
}
|
||||
this.heartbeatTimer = setTimeout(() => {
|
||||
this.checkHealth();
|
||||
}, this.options.pingIntervalMs);
|
||||
}
|
||||
|
||||
private async checkHealth() {
|
||||
// Prevent concurrent health check executions
|
||||
if (this.healthCheckInProgress) {
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] ⚠️ Health check already in progress, skipping`);
|
||||
// Don't schedule here - let the in-progress check's finally block handle scheduling
|
||||
return;
|
||||
}
|
||||
if (this.state !== 'connected' || !this.activeTransport) return;
|
||||
|
||||
this.healthCheckInProgress = true;
|
||||
try {
|
||||
let isHealthy: boolean;
|
||||
|
||||
if (this.healthCheckFn) {
|
||||
// Use caller-provided health check function with timeout
|
||||
isHealthy = await Promise.race([
|
||||
this.healthCheckFn(),
|
||||
this.createTimeoutPromise(this.options.pingTimeoutMs, false),
|
||||
]);
|
||||
} else {
|
||||
// No health check function - use lightweight transport liveness check
|
||||
// For SSE/HTTP transports, onclose/onerror handlers already monitor connection health
|
||||
// This avoids creating new connections for each health check
|
||||
isHealthy = this.activeTransport !== null && this.state === 'connected';
|
||||
}
|
||||
|
||||
if (!isHealthy) {
|
||||
this.consecutivePingTimeouts++;
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] ⏱️ Health check failed (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
|
||||
if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
|
||||
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive health check failures reached. Reconnecting...`);
|
||||
this.triggerReconnect();
|
||||
return; // Don't schedule next check when reconnecting
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Health check passed — server is alive
|
||||
this.heartbeatOkCount++;
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Health check OK (count: ${this.heartbeatOkCount})`);
|
||||
this.consecutiveFailures = 0;
|
||||
this.consecutivePingTimeouts = 0;
|
||||
} catch (err) {
|
||||
this.consecutiveFailures++;
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Health check error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
|
||||
if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
|
||||
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive health check errors reached. Reconnecting...`);
|
||||
this.triggerReconnect();
|
||||
return; // Don't schedule next check when reconnecting
|
||||
}
|
||||
} finally {
|
||||
this.healthCheckInProgress = false;
|
||||
// Only schedule next check if still connected (not reconnecting/closing)
|
||||
if (this.state === 'connected') {
|
||||
this.scheduleNextHealthCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTimeoutPromise<T>(ms: number, fallback: T): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach handlers from and close the current active transport.
|
||||
*/
|
||||
private cleanupTransport() {
|
||||
if (this.activeTransport) {
|
||||
this.activeTransport.onclose = undefined;
|
||||
this.activeTransport.onerror = undefined;
|
||||
this.activeTransport.onmessage = undefined;
|
||||
try {
|
||||
this.activeTransport.close();
|
||||
} catch { /* ignore */ }
|
||||
this.activeTransport = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current transport and schedule a reconnect with exponential backoff.
|
||||
*/
|
||||
private triggerReconnect(fromSessionId?: string) {
|
||||
if (this.state === 'reconnecting' || this.state === 'closed') return;
|
||||
|
||||
this.state = 'reconnecting';
|
||||
this.stopHeartbeat();
|
||||
this.cleanupTransport();
|
||||
|
||||
// Clear queued messages from old session - they use stale session_id
|
||||
const droppedCount = this.messageQueue.length;
|
||||
if (droppedCount > 0) {
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🗑️ Dropping ${droppedCount} queued requests from old session`);
|
||||
this.messageQueue = [];
|
||||
}
|
||||
|
||||
const delay = this.getBackoffDelay();
|
||||
this.retryAttempt++;
|
||||
const sessionInfo = fromSessionId ? ` (session: ${fromSessionId})` : '';
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Reconnecting in ${delay}ms (attempt ${this.retryAttempt})${sessionInfo}...`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.performConnect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private async flushQueue() {
|
||||
if (!this.activeTransport || this.state !== 'connected') return;
|
||||
|
||||
if (this.messageQueue.length > 0) {
|
||||
// Get current session for logging
|
||||
const endpoint = (this.activeTransport as any)?._endpoint?.href || 'no-endpoint';
|
||||
const sessionId = endpoint.includes('sessionId=')
|
||||
? endpoint.split('sessionId=')[1].split('&')[0]
|
||||
: 'n/a';
|
||||
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Flushing ${this.messageQueue.length} queued requests (new session: ${sessionId})...`);
|
||||
}
|
||||
|
||||
const queueToFlush = [...this.messageQueue];
|
||||
this.messageQueue = [];
|
||||
|
||||
for (let i = 0; i < queueToFlush.length; i++) {
|
||||
const msg = queueToFlush[i];
|
||||
try {
|
||||
await this.activeTransport.send(msg);
|
||||
} catch (e) {
|
||||
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Error flushing queue: ${e}`);
|
||||
// If sending fails, put this message and remaining ones back at the front of the queue
|
||||
const failedAndRemaining = queueToFlush.slice(i);
|
||||
this.messageQueue = [...failedAndRemaining, ...this.messageQueue];
|
||||
this.triggerReconnect();
|
||||
break; // Stop flushing until reconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Transport Interface Methods ---
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (this.state === 'closed') {
|
||||
throw new Error('Transport is closed');
|
||||
}
|
||||
|
||||
if (this.state === 'reconnecting' || this.state === 'connecting') {
|
||||
// Queue the message
|
||||
if (this.messageQueue.length >= this.options.maxQueueSize) {
|
||||
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Message queue full, dropping oldest request.`);
|
||||
this.messageQueue.shift(); // Drop oldest
|
||||
}
|
||||
this.messageQueue.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.activeTransport) {
|
||||
throw new Error('No active transport to send message');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.activeTransport.send(message);
|
||||
} catch (err) {
|
||||
// Queue it and reconnect
|
||||
this.messageQueue.push(message);
|
||||
this.triggerReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.state === 'closed') return;
|
||||
this.state = 'closed';
|
||||
this.stopHeartbeat();
|
||||
this.messageQueue = [];
|
||||
|
||||
if (this.activeTransport) {
|
||||
// Prevent bubble up closure
|
||||
this.activeTransport.onclose = undefined;
|
||||
this.activeTransport.onerror = undefined;
|
||||
this.activeTransport.onmessage = undefined;
|
||||
await this.activeTransport.close();
|
||||
this.activeTransport = null;
|
||||
}
|
||||
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Tool discovery utilities
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* Discover all tools from a connected MCP client, handling pagination.
|
||||
*/
|
||||
export async function discoverTools(client: Client): Promise<Tool[]> {
|
||||
const tools: Tool[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : undefined);
|
||||
tools.push(...page.tools);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
return tools;
|
||||
}
|
||||
13
qimingclaw/crates/qiming-mcp-stdio-proxy/src/shared/index.ts
Normal file
13
qimingclaw/crates/qiming-mcp-stdio-proxy/src/shared/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Shared helpers — deduplicated logic used across modes
|
||||
*
|
||||
* This module re-exports utilities from sub-modules:
|
||||
* - discovery: Tool discovery from MCP clients
|
||||
* - proxy-server: Tool proxy server creation
|
||||
* - shutdown: Graceful shutdown handlers
|
||||
*/
|
||||
|
||||
export { discoverTools } from './discovery.js';
|
||||
export { createToolProxyServer } from './proxy-server.js';
|
||||
export type { ToolResolver, ToolProxyServerOptions } from './proxy-server.js';
|
||||
export { setupGracefulShutdown } from './shutdown.js';
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Tool proxy server creation
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { logError } from '../logger.js';
|
||||
import { PKG_NAME, PKG_VERSION } from '../constants.js';
|
||||
|
||||
/**
|
||||
* Callback that resolves a tool name to the client that owns it.
|
||||
* Returns undefined if the tool is unknown or filtered.
|
||||
*/
|
||||
export type ToolResolver = (toolName: string) => Client | undefined;
|
||||
|
||||
export interface ToolProxyServerOptions {
|
||||
/** Tools to expose via ListTools */
|
||||
tools: Tool[];
|
||||
/** Resolves a tool name to the upstream client */
|
||||
resolveClient: ToolResolver;
|
||||
/** Optional label for error logging (e.g. server name) */
|
||||
errorLabel?: (toolName: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stdio MCP server that proxies tool calls to upstream clients.
|
||||
*
|
||||
* Sets up Server with ListTools + CallTool handlers, connects to a
|
||||
* StdioServerTransport, and returns both for lifecycle management.
|
||||
*/
|
||||
export async function createToolProxyServer(
|
||||
opts: ToolProxyServerOptions,
|
||||
): Promise<{ server: Server; transport: StdioServerTransport }> {
|
||||
const { tools, resolveClient, errorLabel } = opts;
|
||||
|
||||
const server = new Server(
|
||||
{ name: PKG_NAME, version: PKG_VERSION },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: toolArgs } = request.params;
|
||||
const client = resolveClient(name);
|
||||
|
||||
if (!client) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Unknown tool: "${name}"` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.callTool({ name, arguments: toolArgs });
|
||||
return result;
|
||||
} catch (e) {
|
||||
const label = errorLabel ? errorLabel(name) : `"${name}"`;
|
||||
logError(`Tool ${label} call failed: ${e}`);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Tool call failed: ${e}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
return { server, transport };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Graceful shutdown utilities
|
||||
*/
|
||||
|
||||
import { logInfo, logError } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Register SIGINT/SIGTERM handlers that run a cleanup function once,
|
||||
* then exit. Guards against double-invocation.
|
||||
*/
|
||||
export function setupGracefulShutdown(cleanupFn: () => Promise<void>): void {
|
||||
let isShuttingDown = false;
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
logInfo(`Received ${signal}, shutting down...`);
|
||||
|
||||
try {
|
||||
await cleanupFn();
|
||||
} catch (e) {
|
||||
logError(`Shutdown cleanup error: ${e}`);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Environment utilities for child processes
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
|
||||
*/
|
||||
export function buildBaseEnv(): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) {
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
// Child MCP servers should run normally, not as Electron Node.js instances
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* HTTP headers utilities
|
||||
*/
|
||||
|
||||
import type { StreamableServerEntry, SseServerEntry } from '../types.js';
|
||||
|
||||
/**
|
||||
* Build HTTP headers from entry config (merge headers + authToken)
|
||||
*/
|
||||
export function buildRequestHeaders(
|
||||
entry: StreamableServerEntry | SseServerEntry,
|
||||
): Record<string, string> | undefined {
|
||||
const headers: Record<string, string> = {};
|
||||
if (entry.headers) {
|
||||
Object.assign(headers, entry.headers);
|
||||
}
|
||||
if (entry.authToken) {
|
||||
headers['Authorization'] = `Bearer ${entry.authToken}`;
|
||||
}
|
||||
return Object.keys(headers).length > 0 ? headers : undefined;
|
||||
}
|
||||
290
qimingclaw/crates/qiming-mcp-stdio-proxy/src/transport/index.ts
Normal file
290
qimingclaw/crates/qiming-mcp-stdio-proxy/src/transport/index.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Transport layer — connect to upstream MCP servers via stdio, Streamable HTTP, or SSE
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { CustomStdioClientTransport } from '../customStdio.js';
|
||||
import type { StdioServerEntry, StreamableServerEntry, SseServerEntry } from '../types.js';
|
||||
import { logError, logInfo, logWarn } from '../logger.js';
|
||||
import { ResilientTransportWrapper } from '../resilient.js';
|
||||
|
||||
// Re-export sub-modules
|
||||
export { buildBaseEnv } from './env.js';
|
||||
export { buildRequestHeaders } from './headers.js';
|
||||
export type { ConnectedClient } from './types.js';
|
||||
|
||||
const DEFAULT_STDIO_CONNECTION_TIMEOUT_MS = 60_000;
|
||||
// HTTP timeout reduced to 20s to avoid race with qimingcode's 30s session/new timeout
|
||||
// When a server fails (e.g., auth error), ResilientTransportWrapper retries indefinitely,
|
||||
// but this timeout ensures we fail fast and let other servers load.
|
||||
const DEFAULT_HTTP_CONNECTION_TIMEOUT_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Helper to wrap a promise with a timeout
|
||||
*/
|
||||
async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(message)), ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
clearTimeout(timeoutId!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a stdio MCP server (spawn child process)
|
||||
*/
|
||||
export async function connectStdio(
|
||||
id: string,
|
||||
entry: StdioServerEntry,
|
||||
baseEnv: Record<string, string>,
|
||||
): Promise<import('./types.js').ConnectedClient> {
|
||||
logInfo(`Connecting to "${id}" (stdio): ${entry.command} ${(entry.args || []).join(' ')}`);
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: id,
|
||||
connectParams: async () => {
|
||||
const t = new CustomStdioClientTransport({
|
||||
command: entry.command,
|
||||
args: entry.args || [],
|
||||
env: { ...baseEnv, ...(entry.env || {}) },
|
||||
stderr: 'pipe',
|
||||
});
|
||||
if (t.stderr) {
|
||||
t.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString().trim();
|
||||
if (text) {
|
||||
process.stderr.write(`[child:${id}] ${text}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return t;
|
||||
},
|
||||
// No heartbeat for stdio — child process close/error events handle detection
|
||||
pingIntervalMs: 0,
|
||||
});
|
||||
|
||||
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
||||
|
||||
// Set reconnect handler to re-establish MCP session via client.connect()
|
||||
// Note: We need to clear the SDK's internal _transport reference without closing it.
|
||||
// client.close() would close the wrapper transport, so we manually clear the reference.
|
||||
wrapper.onreconnect = async () => {
|
||||
logInfo(`[${id}] Reconnecting MCP session...`);
|
||||
// Clear SDK's transport reference without closing it (wrapper is already reconnected)
|
||||
(client as any)._transport = undefined;
|
||||
await client.connect(wrapper);
|
||||
logInfo(`[${id}] MCP session reconnected`);
|
||||
};
|
||||
|
||||
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_STDIO_CONNECTION_TIMEOUT_MS;
|
||||
|
||||
try {
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
await wrapper.start();
|
||||
await client.connect(wrapper);
|
||||
})(),
|
||||
timeoutMs,
|
||||
`Connection initialization timed out after ${timeoutMs / 1000}s`
|
||||
);
|
||||
} catch (err) {
|
||||
try { await wrapper.close(); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
transport: wrapper,
|
||||
cleanup: async () => {
|
||||
try { await wrapper.close(); } catch { /* ignore */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to a Streamable HTTP MCP server
|
||||
*
|
||||
* Supports PersistentMcpBridge endpoints and any remote MCP service using
|
||||
* the Streamable HTTP transport protocol.
|
||||
*/
|
||||
export async function connectStreamable(
|
||||
id: string,
|
||||
entry: StreamableServerEntry,
|
||||
): Promise<import('./types.js').ConnectedClient> {
|
||||
logInfo(`Connecting to "${id}" (streamable-http): ${entry.url}`);
|
||||
|
||||
const { buildRequestHeaders } = await import('./headers.js');
|
||||
const headers = buildRequestHeaders(entry);
|
||||
const url = new URL(entry.url);
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: id,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(
|
||||
url,
|
||||
headers ? { requestInit: { headers } } : undefined,
|
||||
);
|
||||
},
|
||||
// Use provided interval or default (30s)
|
||||
pingIntervalMs: entry.pingIntervalMs,
|
||||
pingTimeoutMs: entry.pingTimeoutMs,
|
||||
});
|
||||
|
||||
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
||||
|
||||
// Set health check function to use ping() for HTTP-based transports
|
||||
// ping() is lighter than listTools() and sufficient for connection health
|
||||
wrapper.setHealthCheckFn(async () => {
|
||||
try {
|
||||
const { tools } = await client.listTools();
|
||||
logInfo(`[proxy-${id}] Streamable HTTP health check OK: ${tools?.length ?? 0} tools`);
|
||||
return true;
|
||||
} catch(error) {
|
||||
logError(`[proxy-${id}] Streamable HTTP health check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Set reconnect handler to re-establish MCP session via client.connect()
|
||||
// Note: We need to clear the SDK's internal _transport reference without closing it.
|
||||
// client.close() would close the wrapper transport, so we manually clear the reference.
|
||||
// Note: Do NOT call enableHeartbeat() here - performConnect will call startHeartbeat()
|
||||
// after flushQueue() completes, ensuring health checks use the correct session.
|
||||
wrapper.onreconnect = async () => {
|
||||
logInfo(`[${id}] Reconnecting MCP session...`);
|
||||
// Clear SDK's transport reference without closing it (wrapper is already reconnected)
|
||||
(client as any)._transport = undefined;
|
||||
await client.connect(wrapper);
|
||||
// Note: heartbeat is restarted by ResilientTransport.performConnect after flushQueue()
|
||||
logInfo(`[${id}] MCP session reconnected`);
|
||||
};
|
||||
|
||||
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
|
||||
|
||||
try {
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
await wrapper.start();
|
||||
await client.connect(wrapper);
|
||||
wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
|
||||
})(),
|
||||
timeoutMs,
|
||||
`Connection initialization timed out after ${timeoutMs / 1000}s`
|
||||
);
|
||||
} catch (err) {
|
||||
try { await wrapper.close(); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
transport: wrapper,
|
||||
cleanup: async () => {
|
||||
try { await wrapper.close(); } catch { /* ignore */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an SSE MCP server
|
||||
*
|
||||
* Uses the legacy SSE (Server-Sent Events) transport for MCP servers
|
||||
* that don't support the newer Streamable HTTP protocol.
|
||||
*/
|
||||
export async function connectSse(
|
||||
id: string,
|
||||
entry: SseServerEntry,
|
||||
): Promise<import('./types.js').ConnectedClient> {
|
||||
logInfo(`Connecting to "${id}" (sse): ${entry.url}`);
|
||||
|
||||
const { buildRequestHeaders } = await import('./headers.js');
|
||||
const headers = buildRequestHeaders(entry);
|
||||
const url = new URL(entry.url);
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: id,
|
||||
connectParams: async () => {
|
||||
return new SSEClientTransport(
|
||||
url,
|
||||
headers ? { requestInit: { headers } } : undefined,
|
||||
);
|
||||
},
|
||||
// Use provided interval or default (30s)
|
||||
pingIntervalMs: entry.pingIntervalMs,
|
||||
pingTimeoutMs: entry.pingTimeoutMs,
|
||||
});
|
||||
|
||||
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
||||
|
||||
// Set health check function to use ping() for HTTP-based transports
|
||||
// ping() is lighter than listTools() and sufficient for connection health
|
||||
wrapper.setHealthCheckFn(async () => {
|
||||
try {
|
||||
// Get current session_id from transport
|
||||
const innerTransport = (wrapper as any).activeTransport;
|
||||
const endpoint = innerTransport?._endpoint?.href || innerTransport?._endpoint || 'no-endpoint';
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
logInfo(`[proxy-${id}] SSE health check OK: ${Array.isArray(tools) ? tools.length : 0} tools (endpoint: ${endpoint})`);
|
||||
return true;
|
||||
} catch(error) {
|
||||
logError(`[proxy-${id}] SSE health check failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Set reconnect handler to re-establish MCP session via client.connect()
|
||||
// Note: We need to clear the SDK's internal _transport reference without closing it.
|
||||
// client.close() would close the wrapper transport, so we manually clear the reference.
|
||||
// Note: Do NOT call enableHeartbeat() here - performConnect will call startHeartbeat()
|
||||
// after flushQueue() completes, ensuring health checks use the correct session.
|
||||
wrapper.onreconnect = async () => {
|
||||
// Get new session_id from transport
|
||||
const innerTransport = (wrapper as any).activeTransport;
|
||||
const endpoint = innerTransport?._endpoint?.href || innerTransport?._endpoint || 'no-endpoint';
|
||||
|
||||
logInfo(`[${id}] Reconnecting MCP session (endpoint: ${endpoint})...`);
|
||||
// Clear SDK's transport reference without closing it (wrapper is already reconnected)
|
||||
(client as any)._transport = undefined;
|
||||
await client.connect(wrapper);
|
||||
// Note: heartbeat is restarted by ResilientTransport.performConnect after flushQueue()
|
||||
logInfo(`[${id}] MCP session reconnected (endpoint: ${endpoint})`);
|
||||
};
|
||||
|
||||
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
|
||||
|
||||
try {
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
await wrapper.start();
|
||||
await client.connect(wrapper);
|
||||
wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
|
||||
})(),
|
||||
timeoutMs,
|
||||
`Connection initialization timed out after ${timeoutMs / 1000}s`
|
||||
);
|
||||
} catch (err) {
|
||||
try { await wrapper.close(); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
transport: wrapper,
|
||||
cleanup: async () => {
|
||||
try { await wrapper.close(); } catch { /* ignore */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated Use connectStreamable instead */
|
||||
export const connectBridge = connectStreamable;
|
||||
|
||||
/** @deprecated Use connectStreamable instead */
|
||||
export const connectHttp = connectStreamable;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Transport types
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
|
||||
export interface ConnectedClient {
|
||||
client: Client;
|
||||
cleanup: () => Promise<void>;
|
||||
transport: Transport;
|
||||
}
|
||||
101
qimingclaw/crates/qiming-mcp-stdio-proxy/src/types.ts
Normal file
101
qimingclaw/crates/qiming-mcp-stdio-proxy/src/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Types for MCP server configuration
|
||||
*/
|
||||
|
||||
/** stdio 类型: spawn 子进程 */
|
||||
export interface StdioServerEntry {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
/** 工具白名单(只暴露指定工具) */
|
||||
allowTools?: string[];
|
||||
/** 工具黑名单(排除指定工具) */
|
||||
denyTools?: string[];
|
||||
/** Connection initialization timeout (ms). Defaults to 60000 for stdio. */
|
||||
connectionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streamable HTTP 类型: 连接远程 MCP server (Streamable HTTP)
|
||||
*
|
||||
* 用于连接 PersistentMcpBridge 等长生命周期的 MCP server,
|
||||
* 或直接连接支持 Streamable HTTP 协议的远程 MCP 服务。
|
||||
*/
|
||||
export interface StreamableServerEntry {
|
||||
url: string;
|
||||
transport?: 'streamable-http';
|
||||
headers?: Record<string, string>;
|
||||
authToken?: string;
|
||||
/** 工具白名单(只暴露指定工具) */
|
||||
allowTools?: string[];
|
||||
/** 工具黑名单(排除指定工具) */
|
||||
denyTools?: string[];
|
||||
/** Heartbeat ping interval configuration (ms) */
|
||||
pingIntervalMs?: number;
|
||||
/** Heartbeat ping timeout configuration (ms) */
|
||||
pingTimeoutMs?: number;
|
||||
/** Connection initialization timeout (ms). Defaults to 30000. */
|
||||
connectionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 类型: 连接远程 MCP server (Server-Sent Events)
|
||||
*
|
||||
* 用于连接支持 SSE 传输的远程 MCP 服务(旧版 MCP 协议)。
|
||||
*/
|
||||
export interface SseServerEntry {
|
||||
url: string;
|
||||
transport: 'sse';
|
||||
headers?: Record<string, string>;
|
||||
authToken?: string;
|
||||
/** 工具白名单(只暴露指定工具) */
|
||||
allowTools?: string[];
|
||||
/** 工具黑名单(排除指定工具) */
|
||||
denyTools?: string[];
|
||||
/** Heartbeat ping interval configuration (ms) */
|
||||
pingIntervalMs?: number;
|
||||
/** Heartbeat ping timeout configuration (ms) */
|
||||
pingTimeoutMs?: number;
|
||||
/** Connection initialization timeout (ms). Defaults to 30000. */
|
||||
connectionTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export type McpServerEntry = StdioServerEntry | StreamableServerEntry | SseServerEntry;
|
||||
|
||||
export function isSseEntry(entry: McpServerEntry): entry is SseServerEntry {
|
||||
return 'url' in entry && (entry as SseServerEntry).transport === 'sse';
|
||||
}
|
||||
|
||||
export function isStreamableEntry(entry: McpServerEntry): entry is StreamableServerEntry {
|
||||
return (
|
||||
'url' in entry &&
|
||||
typeof (entry as StreamableServerEntry).url === 'string' &&
|
||||
(entry as StreamableServerEntry).transport === 'streamable-http'
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if URL entry has no explicit transport → needs auto-detection */
|
||||
export function needsProtocolDetection(entry: McpServerEntry): entry is StreamableServerEntry {
|
||||
return (
|
||||
'url' in entry &&
|
||||
typeof (entry as StreamableServerEntry).url === 'string' &&
|
||||
!isSseEntry(entry) &&
|
||||
!isStreamableEntry(entry)
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use StreamableServerEntry instead */
|
||||
export type BridgeServerEntry = StreamableServerEntry;
|
||||
|
||||
/** @deprecated Use StreamableServerEntry instead */
|
||||
export type HttpServerEntry = StreamableServerEntry;
|
||||
|
||||
/** @deprecated Use isStreamableEntry instead */
|
||||
export const isBridgeEntry = isStreamableEntry;
|
||||
|
||||
/** @deprecated Use isStreamableEntry instead */
|
||||
export const isHttpEntry = isStreamableEntry;
|
||||
|
||||
export interface McpServersConfig {
|
||||
mcpServers: Record<string, McpServerEntry>;
|
||||
}
|
||||
66
qimingclaw/crates/qiming-mcp-stdio-proxy/src/validation.ts
Normal file
66
qimingclaw/crates/qiming-mcp-stdio-proxy/src/validation.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Runtime configuration validation
|
||||
*/
|
||||
|
||||
import type { McpServersConfig, McpServerEntry, StdioServerEntry, StreamableServerEntry, SseServerEntry } from './types.js';
|
||||
import { ConfigError } from './errors.js';
|
||||
|
||||
/**
|
||||
* Validate config at runtime
|
||||
*/
|
||||
export function validateConfig(config: unknown): McpServersConfig {
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new ConfigError('Config must be an object');
|
||||
}
|
||||
|
||||
const cfg = config as Record<string, unknown>;
|
||||
|
||||
if (!cfg.mcpServers || typeof cfg.mcpServers !== 'object') {
|
||||
throw new ConfigError('Config must contain a "mcpServers" object');
|
||||
}
|
||||
|
||||
for (const [id, entry] of Object.entries(cfg.mcpServers as Record<string, unknown>)) {
|
||||
validateServerEntry(id, entry);
|
||||
}
|
||||
|
||||
return cfg as unknown as McpServersConfig;
|
||||
}
|
||||
|
||||
function validateServerEntry(id: string, entry: unknown): void {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
throw new ConfigError(`Server "${id}": entry must be an object`);
|
||||
}
|
||||
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
// Check if it's a URL type (streamable-http or sse)
|
||||
if (typeof e.url === 'string') {
|
||||
validateUrlEntry(id, e as Partial<StreamableServerEntry>);
|
||||
} else if (typeof e.command === 'string') {
|
||||
validateStdioEntry(id, e as Partial<StdioServerEntry>);
|
||||
} else {
|
||||
throw new ConfigError(`Server "${id}": must have either "url" or "command" property`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateUrlEntry(id: string, entry: Partial<StreamableServerEntry>): void {
|
||||
try {
|
||||
new URL(entry.url!);
|
||||
} catch {
|
||||
throw new ConfigError(`Server "${id}": invalid URL "${entry.url}"`);
|
||||
}
|
||||
|
||||
if (entry.transport && !['streamable-http', 'sse'].includes(entry.transport)) {
|
||||
throw new ConfigError(`Server "${id}": transport must be "streamable-http" or "sse"`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateStdioEntry(id: string, entry: Partial<StdioServerEntry>): void {
|
||||
if (entry.args && !Array.isArray(entry.args)) {
|
||||
throw new ConfigError(`Server "${id}": args must be an array`);
|
||||
}
|
||||
|
||||
if (entry.env && typeof entry.env !== 'object') {
|
||||
throw new ConfigError(`Server "${id}": env must be an object`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { writeFileSync, from 'fs';
|
||||
import { rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
// Test constants
|
||||
const TEST_CONFIG_DIR = join(tmpdir(), 'test-mcp-remote');
|
||||
const TEST_URL = 'https://mcp.coze.cn/v1/plugins/7407724292865130515';
|
||||
const TEST_AUTH_TOKEN = 'Bearer cztei_lXvC4tNAIKkQhbPznVjfSu0OrKQobXrIItc35faJCWHVVyVMPsg7R239WpICZQx8Z'; // 临时 token,const TEST_HEADERS = {
|
||||
Authorization: TEST_AUTH_TOKEN,
|
||||
};
|
||||
|
||||
// Helper to create a test config file
|
||||
function createTestConfig(serverName: string, headers?: Record<string, string>): string {
|
||||
const configPath = join(TEST_CONFIG_DIR, `${serverName}.json`);
|
||||
const config = {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http' as headers: headers || TEST_HEADERS,
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(configPath, JSON.stringify(config), 'utf-8');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
// Helper to cleanup test files
|
||||
function cleanupTestFiles() {
|
||||
try {
|
||||
const files = readdirSync(TEST_CONFIG_DIR);
|
||||
for (const file of files) {
|
||||
rmSync(file);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
describe('Remote MCP Server with Streamable HTTP', () => {
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
cleanupTestFiles();
|
||||
configPath = createTestConfig('coze_plugin_tianyancha', TEST_HEADERS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTestFiles();
|
||||
});
|
||||
|
||||
it('should create config file with correct format', () => {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
|
||||
expect(config.mcpServers['coze_plugin_tianyancha']).toBeDefined();
|
||||
expect(config.mcpServers['coze_plugin_tianyancha'].url).toBe(TEST_URL);
|
||||
expect(config.mcpServers['coze_plugin_tianyancha].transport).toBe('streamable-http');
|
||||
expect(config.mcpServers['coze_plugin_tianyancha].headers).toEqual(TEST_HEADERS);
|
||||
});
|
||||
|
||||
it('should fail gracefully when auth token is invalid', async () => {
|
||||
const invalidConfig = createTestConfig('coze_plugin_tianyancha', {
|
||||
Authorization: 'Bearer invalid-token',
|
||||
});
|
||||
configPath = createTestConfig('coze_plugin_tianyancha', invalidConfig);
|
||||
writeFileSync(configPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
// Since这是是本地测试,我们不需要真正去连接服务器
|
||||
// 模拟连接失败即可测试 headers 传递
|
||||
|
||||
const mockStdio = vi.fn().mockResolvedValue({ mcpServers: {} });
|
||||
const mockConnectStreamable = vi.fn().mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
|
||||
vi.doMock('../../transport/index', () => ({
|
||||
buildRequestHeaders: vi.fn(() => ({})),
|
||||
connectStreamable: mockConnectStreamable,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
|
||||
const { connectStreamable } = await import('../transport/index.js');
|
||||
|
||||
// Test that headers were passed
|
||||
const result = await connectStreamable('coze_plugin_tianyancha', entry);
|
||||
expect(buildRequestHeaders).toHaveBeenCalledWith(entry);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should pass auth token to connectStreamable transport', async () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
headers: TEST_HEADERS,
|
||||
};
|
||||
|
||||
|
||||
const result = await connectStreamable('coze_plugin_tianyancha', entry);
|
||||
|
||||
expect(buildRequestHeaders).toHaveBeenCalledWith(entry);
|
||||
expect(result).toEqual({
|
||||
Authorization: TEST_AUTH_TOKEN,
|
||||
});
|
||||
});
|
||||
});
|
||||
483
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/bridge.test.ts
Normal file
483
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/bridge.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Smoke tests: PersistentMcpBridge
|
||||
*
|
||||
* Verifies the bridge lifecycle (start/stop), HTTP routing, tool proxying,
|
||||
* and auto-restart behavior using the same mock MCP server fixture.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import * as path from 'path';
|
||||
import * as net from 'net';
|
||||
import { PersistentMcpBridge } from '../src/bridge.js';
|
||||
import type { BridgeLogger } from '../src/bridge.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
||||
/**
|
||||
* 动态占用一个随机可用端口,返回端口号和释放函数。
|
||||
* 用于模拟端口冲突,测试 start() 的容错清理逻辑。
|
||||
*/
|
||||
function occupyRandomPort(): Promise<{ port: number; release: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as net.AddressInfo;
|
||||
resolve({
|
||||
port: addr.port,
|
||||
release: () => new Promise<void>((res) => server.close(() => res())),
|
||||
});
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const MOCK_SERVER = path.resolve('tests/fixtures/mock-mcp-server.mjs');
|
||||
|
||||
/** Silent logger that captures messages for assertions */
|
||||
function createTestLogger(): BridgeLogger & { messages: string[] } {
|
||||
const messages: string[] = [];
|
||||
return {
|
||||
messages,
|
||||
info: (...args: unknown[]) => messages.push(`INFO: ${args.map(String).join(' ')}`),
|
||||
warn: (...args: unknown[]) => messages.push(`WARN: ${args.map(String).join(' ')}`),
|
||||
error: (...args: unknown[]) => messages.push(`ERROR: ${args.map(String).join(' ')}`),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PersistentMcpBridge', () => {
|
||||
let bridge: PersistentMcpBridge;
|
||||
|
||||
afterEach(async () => {
|
||||
if (bridge) {
|
||||
await bridge.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Construction & initial state ----
|
||||
|
||||
it('isRunning() returns false before start()', () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
expect(bridge.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('getBridgeUrl() returns null before start()', () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
expect(bridge.getBridgeUrl('anything')).toBeNull();
|
||||
});
|
||||
|
||||
it('isServerHealthy() returns false for unknown server', () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
expect(bridge.isServerHealthy('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a custom logger', () => {
|
||||
const logger = createTestLogger();
|
||||
bridge = new PersistentMcpBridge(logger);
|
||||
expect(bridge.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('stop() is safe to call before start()', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
await expect(bridge.stop()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// ---- Lifecycle with real mock server ----
|
||||
|
||||
it('start() spawns server and exposes HTTP bridge', async () => {
|
||||
const logger = createTestLogger();
|
||||
bridge = new PersistentMcpBridge(logger);
|
||||
|
||||
await bridge.start({
|
||||
'mock': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["bridge-tool-a","bridge-tool-b"]', '--name', 'bridge-mock'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(bridge.isRunning()).toBe(true);
|
||||
expect(bridge.isServerHealthy('mock')).toBe(true);
|
||||
|
||||
const url = bridge.getBridgeUrl('mock');
|
||||
expect(url).not.toBeNull();
|
||||
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/mcp\/mock$/);
|
||||
});
|
||||
|
||||
it('getBridgeUrl() returns null for unknown server after start', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'mock': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER],
|
||||
},
|
||||
});
|
||||
|
||||
expect(bridge.getBridgeUrl('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('stop() cleans up and sets isRunning to false', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'mock': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER],
|
||||
},
|
||||
});
|
||||
|
||||
expect(bridge.isRunning()).toBe(true);
|
||||
|
||||
await bridge.stop();
|
||||
expect(bridge.isRunning()).toBe(false);
|
||||
expect(bridge.isServerHealthy('mock')).toBe(false);
|
||||
expect(bridge.getBridgeUrl('mock')).toBeNull();
|
||||
});
|
||||
|
||||
// ---- HTTP bridge integration ----
|
||||
|
||||
it('proxies tools via HTTP bridge (list + call)', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'mock': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["alpha"]', '--name', 'proxy-test'],
|
||||
},
|
||||
});
|
||||
|
||||
const url = bridge.getBridgeUrl('mock')!;
|
||||
expect(url).toBeTruthy();
|
||||
|
||||
// Connect as an MCP client via StreamableHTTP
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
// List tools
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('alpha');
|
||||
|
||||
// Call tool
|
||||
const result = await client.callTool({ name: 'alpha', arguments: {} });
|
||||
expect(result.content).toEqual([
|
||||
{ type: 'text', text: 'proxy-test:alpha' },
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 404 for invalid HTTP paths', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'mock': { command: 'node', args: [MOCK_SERVER] },
|
||||
});
|
||||
|
||||
const url = bridge.getBridgeUrl('mock')!;
|
||||
// Extract base URL (without /mcp/mock)
|
||||
const baseUrl = url.replace('/mcp/mock', '');
|
||||
|
||||
const res = await fetch(`${baseUrl}/invalid`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 503 for unknown server ID', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'mock': { command: 'node', args: [MOCK_SERVER] },
|
||||
});
|
||||
|
||||
const url = bridge.getBridgeUrl('mock')!;
|
||||
const baseUrl = url.replace('/mcp/mock', '');
|
||||
|
||||
const res = await fetch(`${baseUrl}/mcp/nonexistent`, { method: 'POST' });
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
// ---- Multiple servers (parallel start/stop) ----
|
||||
|
||||
/**
|
||||
* 验证 3 个 server 并行启动:start() 返回后全部健康,且各自 URL 正确路由。
|
||||
* 如果内部退化为串行,此测试仍应通过(正确性不变),但性能会下降。
|
||||
*/
|
||||
it('starts 3 servers in parallel and all become healthy', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'svc-alpha': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["alpha-tool"]', '--name', 'alpha'],
|
||||
},
|
||||
'svc-beta': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["beta-tool"]', '--name', 'beta'],
|
||||
},
|
||||
'svc-gamma': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["gamma-tool"]', '--name', 'gamma'],
|
||||
},
|
||||
});
|
||||
|
||||
// 全部健康
|
||||
expect(bridge.isServerHealthy('svc-alpha')).toBe(true);
|
||||
expect(bridge.isServerHealthy('svc-beta')).toBe(true);
|
||||
expect(bridge.isServerHealthy('svc-gamma')).toBe(true);
|
||||
|
||||
// URL 路由各自独立
|
||||
expect(bridge.getBridgeUrl('svc-alpha')).toMatch(/\/mcp\/svc-alpha$/);
|
||||
expect(bridge.getBridgeUrl('svc-beta')).toMatch(/\/mcp\/svc-beta$/);
|
||||
expect(bridge.getBridgeUrl('svc-gamma')).toMatch(/\/mcp\/svc-gamma$/);
|
||||
|
||||
// 每个 server 只暴露自己的工具
|
||||
for (const [serverId, toolName, namePrefix] of [
|
||||
['svc-alpha', 'alpha-tool', 'alpha'],
|
||||
['svc-beta', 'beta-tool', 'beta'],
|
||||
['svc-gamma', 'gamma-tool', 'gamma'],
|
||||
] as const) {
|
||||
const url = bridge.getBridgeUrl(serverId)!;
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
const client = new Client({ name: 'parallel-test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
try {
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe(toolName);
|
||||
const result = await client.callTool({ name: toolName, arguments: {} });
|
||||
expect(result.content).toEqual([{ type: 'text', text: `${namePrefix}:${toolName}` }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('stops all servers and bridge becomes not running', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'stop-a': { command: 'node', args: [MOCK_SERVER, '--name', 'stop-a'] },
|
||||
'stop-b': { command: 'node', args: [MOCK_SERVER, '--name', 'stop-b'] },
|
||||
'stop-c': { command: 'node', args: [MOCK_SERVER, '--name', 'stop-c'] },
|
||||
});
|
||||
|
||||
expect(bridge.isRunning()).toBe(true);
|
||||
|
||||
await bridge.stop();
|
||||
|
||||
// 并行停止后整体不再运行
|
||||
expect(bridge.isRunning()).toBe(false);
|
||||
// 每个 server 均已清理
|
||||
expect(bridge.isServerHealthy('stop-a')).toBe(false);
|
||||
expect(bridge.isServerHealthy('stop-b')).toBe(false);
|
||||
expect(bridge.isServerHealthy('stop-c')).toBe(false);
|
||||
expect(bridge.getBridgeUrl('stop-a')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles multiple concurrent servers', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'server-a': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["tool-a"]', '--name', 'a'],
|
||||
},
|
||||
'server-b': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["tool-b"]', '--name', 'b'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(bridge.isServerHealthy('server-a')).toBe(true);
|
||||
expect(bridge.isServerHealthy('server-b')).toBe(true);
|
||||
|
||||
const urlA = bridge.getBridgeUrl('server-a')!;
|
||||
const urlB = bridge.getBridgeUrl('server-b')!;
|
||||
expect(urlA).toContain('/mcp/server-a');
|
||||
expect(urlB).toContain('/mcp/server-b');
|
||||
|
||||
// Verify each proxies its own tools
|
||||
for (const [url, expectedTool, expectedPrefix] of [
|
||||
[urlA, 'tool-a', 'a:tool-a'],
|
||||
[urlB, 'tool-b', 'b:tool-b'],
|
||||
] as const) {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
try {
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools[0].name).toBe(expectedTool);
|
||||
const result = await client.callTool({ name: expectedTool, arguments: {} });
|
||||
expect(result.content).toEqual([{ type: 'text', text: expectedPrefix }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Error handling ----
|
||||
|
||||
it('handles server spawn failure gracefully', async () => {
|
||||
const logger = createTestLogger();
|
||||
bridge = new PersistentMcpBridge(logger);
|
||||
|
||||
await bridge.start({
|
||||
'bad': {
|
||||
command: 'nonexistent-binary-that-does-not-exist',
|
||||
args: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Bridge should still be running (HTTP server started)
|
||||
expect(bridge.isRunning()).toBe(true);
|
||||
// But the server should not be healthy
|
||||
expect(bridge.isServerHealthy('bad')).toBe(false);
|
||||
expect(bridge.getBridgeUrl('bad')).toBeNull();
|
||||
|
||||
// Logger should capture the failure
|
||||
const hasError = logger.messages.some((m) => m.includes('Failed to start server'));
|
||||
expect(hasError).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证 start() 中 HTTP server 启动失败(端口冲突)时:
|
||||
* 1. start() 应抛出异常
|
||||
* 2. bridge 处于未运行状态(isRunning = false)
|
||||
* 3. 所有 spawnAndConnect 完成后子进程被正确关闭(无孤儿进程)
|
||||
* 4. bridge 实例回到可重用状态,可以重新正常启动
|
||||
*/
|
||||
it('start() throws and cleans up spawned processes when HTTP server fails to bind', async () => {
|
||||
const { port, release } = await occupyRandomPort();
|
||||
|
||||
try {
|
||||
const logger = createTestLogger();
|
||||
bridge = new PersistentMcpBridge(logger);
|
||||
|
||||
// 使用被占用的端口 → startHttpServer 应抛 EADDRINUSE
|
||||
await expect(
|
||||
bridge.start(
|
||||
{ 'mock': { command: 'node', args: [MOCK_SERVER, '--name', 'cleanup-test'] } },
|
||||
{ port },
|
||||
),
|
||||
).rejects.toThrow();
|
||||
|
||||
// 失败后 bridge 必须处于未运行状态
|
||||
expect(bridge.isRunning()).toBe(false);
|
||||
expect(bridge.getBridgeUrl('mock')).toBeNull();
|
||||
|
||||
// 失败日志记录(来自 catch 块)
|
||||
expect(logger.messages.some((m) => m.includes('启动失败'))).toBe(true);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
|
||||
// 释放端口后,同一个 bridge 实例应能正常重新启动
|
||||
await bridge.start({
|
||||
'mock-retry': { command: 'node', args: [MOCK_SERVER, '--name', 'retry'] },
|
||||
});
|
||||
expect(bridge.isRunning()).toBe(true);
|
||||
expect(bridge.isServerHealthy('mock-retry')).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证 stop() 并行关闭多个活跃 HTTP session:
|
||||
* 即使同时存在多个 session,stop() 也能正确完成并清理全部 session。
|
||||
*/
|
||||
it('stop() closes multiple active HTTP sessions in parallel', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start({
|
||||
'mock': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["tool-x","tool-y"]', '--name', 'multi-session'],
|
||||
},
|
||||
});
|
||||
|
||||
const url = bridge.getBridgeUrl('mock')!;
|
||||
|
||||
// 建立 3 个独立 HTTP session
|
||||
const connections: Array<{ client: Client; transport: StreamableHTTPClientTransport }> = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
const client = new Client({ name: `multi-session-client-${i}`, version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
connections.push({ client, transport });
|
||||
}
|
||||
|
||||
// 3 个 session 建立期间,bridge 应保持健康
|
||||
expect(bridge.isServerHealthy('mock')).toBe(true);
|
||||
|
||||
// 调用 stop(),验证多 session 并行关闭后 bridge 完全停止
|
||||
await bridge.stop();
|
||||
|
||||
expect(bridge.isRunning()).toBe(false);
|
||||
expect(bridge.isServerHealthy('mock')).toBe(false);
|
||||
expect(bridge.getBridgeUrl('mock')).toBeNull();
|
||||
|
||||
// 清理客户端(stop 后 transport 可能已关闭,忽略错误)
|
||||
for (const { client, transport } of connections) {
|
||||
try { await client.close(); } catch { /* ignore */ }
|
||||
try { await transport.close(); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Logger ----
|
||||
|
||||
it('logs lifecycle events via injected logger', async () => {
|
||||
const logger = createTestLogger();
|
||||
bridge = new PersistentMcpBridge(logger);
|
||||
|
||||
await bridge.start({
|
||||
'mock': { command: 'node', args: [MOCK_SERVER] },
|
||||
});
|
||||
|
||||
await bridge.stop();
|
||||
|
||||
const hasStarting = logger.messages.some((m) => m.includes('Starting with'));
|
||||
const hasReady = logger.messages.some((m) => m.includes('Bridge ready'));
|
||||
const hasStopped = logger.messages.some((m) => m.includes('Stopped'));
|
||||
expect(hasStarting).toBe(true);
|
||||
expect(hasReady).toBe(true);
|
||||
expect(hasStopped).toBe(true);
|
||||
});
|
||||
|
||||
// ---- Explicit port ----
|
||||
|
||||
it('start() with explicit port listens on that port', async () => {
|
||||
bridge = new PersistentMcpBridge();
|
||||
|
||||
await bridge.start(
|
||||
{
|
||||
'mock': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["port-tool"]', '--name', 'port-test'],
|
||||
},
|
||||
},
|
||||
{ port: 18199 },
|
||||
);
|
||||
|
||||
expect(bridge.isRunning()).toBe(true);
|
||||
|
||||
const url = bridge.getBridgeUrl('mock');
|
||||
expect(url).toBe('http://127.0.0.1:18199/mcp/mock');
|
||||
|
||||
// Verify it's actually listening on that port by connecting
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url!));
|
||||
const client = new Client({ name: 'port-test-client', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('port-tool');
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
145
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/detect.test.ts
Normal file
145
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/detect.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Unit tests: detect.ts — protocol auto-detection
|
||||
*
|
||||
* Spins up minimal HTTP servers to simulate Streamable HTTP and SSE endpoints
|
||||
* so detectProtocol can probe them.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import * as http from 'http';
|
||||
import { detectProtocol } from '../src/detect.js';
|
||||
import { MCP_SESSION_ID_HEADER } from '../src/constants.js';
|
||||
|
||||
/** Start an HTTP server that responds according to the handler, returns URL + close fn */
|
||||
async function startMockServer(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server = http.createServer(handler);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const addr = server.address() as { port: number };
|
||||
return {
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectProtocol', () => {
|
||||
const servers: Array<{ close: () => Promise<void> }> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const s of servers) {
|
||||
await s.close();
|
||||
}
|
||||
servers.length = 0;
|
||||
});
|
||||
|
||||
it('detects streamable-http when server responds with JSON to POST', async () => {
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }));
|
||||
} else if (req.method === 'DELETE') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('stream');
|
||||
});
|
||||
|
||||
it('defaults to SSE when POST probe is rejected (e.g. 405)', async () => {
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
// Reject POST so streamable-http probe fails
|
||||
res.writeHead(405);
|
||||
res.end();
|
||||
} else if (req.method === 'GET') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
// Write initial SSE comment to keep connection alive briefly
|
||||
res.write(': keep-alive\n\n');
|
||||
// Don't end — SSE streams are long-lived; detectProtocol will abort
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('sse');
|
||||
});
|
||||
|
||||
it('defaults to sse when server rejects probe', async () => {
|
||||
const mock = await startMockServer((_req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end('Internal Server Error');
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('sse');
|
||||
});
|
||||
|
||||
it('defaults to sse when server is unreachable', async () => {
|
||||
// Use a port that's almost certainly not listening
|
||||
const result = await detectProtocol('http://127.0.0.1:1');
|
||||
expect(result).toBe('sse');
|
||||
});
|
||||
|
||||
it('passes custom headers to probes', async () => {
|
||||
let receivedAuth = '';
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
receivedAuth = req.headers['authorization'] as string;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }));
|
||||
} else if (req.method === 'DELETE') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
await detectProtocol(mock.url, { Authorization: 'Bearer test-token' });
|
||||
expect(receivedAuth).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('sends DELETE to clean up orphan session after streamable-http detection', async () => {
|
||||
let deleteReceived = false;
|
||||
let deleteSessionId = '';
|
||||
const mock = await startMockServer((req, res) => {
|
||||
if (req.method === 'POST') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
[MCP_SESSION_ID_HEADER]: 'test-session-123',
|
||||
});
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }));
|
||||
} else if (req.method === 'DELETE') {
|
||||
deleteReceived = true;
|
||||
deleteSessionId = req.headers[MCP_SESSION_ID_HEADER] as string;
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
servers.push(mock);
|
||||
|
||||
const result = await detectProtocol(mock.url);
|
||||
expect(result).toBe('stream');
|
||||
// Poll for the fire-and-forget DELETE to arrive
|
||||
await vi.waitFor(() => {
|
||||
expect(deleteReceived).toBe(true);
|
||||
expect(deleteSessionId).toBe('test-session-123');
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Unit tests: filter.ts — tool filtering
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { filterTools } from '../src/filter.js';
|
||||
import type { ToolFilter } from '../src/filter.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
function makeTool(name: string): Tool {
|
||||
return {
|
||||
name,
|
||||
description: `Tool: ${name}`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
}
|
||||
|
||||
const toolA = makeTool('tool-a');
|
||||
const toolB = makeTool('tool-b');
|
||||
const toolC = makeTool('tool-c');
|
||||
const allTools = [toolA, toolB, toolC];
|
||||
|
||||
describe('filterTools', () => {
|
||||
it('returns all tools when filter is empty', () => {
|
||||
const result = filterTools(allTools, {});
|
||||
expect(result).toEqual(allTools);
|
||||
});
|
||||
|
||||
it('returns all tools when allowTools is an empty set', () => {
|
||||
const result = filterTools(allTools, { allowTools: new Set() });
|
||||
expect(result).toEqual(allTools);
|
||||
});
|
||||
|
||||
it('returns all tools when denyTools is an empty set', () => {
|
||||
const result = filterTools(allTools, { denyTools: new Set() });
|
||||
expect(result).toEqual(allTools);
|
||||
});
|
||||
|
||||
it('filters by allowTools — only matching tools returned', () => {
|
||||
const filter: ToolFilter = { allowTools: new Set(['tool-a', 'tool-c']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(['tool-a', 'tool-c']);
|
||||
});
|
||||
|
||||
it('filters by denyTools — matching tools excluded', () => {
|
||||
const filter: ToolFilter = { denyTools: new Set(['tool-b']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(['tool-a', 'tool-c']);
|
||||
});
|
||||
|
||||
it('allowTools takes precedence over denyTools', () => {
|
||||
const filter: ToolFilter = {
|
||||
allowTools: new Set(['tool-a']),
|
||||
denyTools: new Set(['tool-a']),
|
||||
};
|
||||
const result = filterTools(allTools, filter);
|
||||
// allowTools wins — tool-a is allowed
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('tool-a');
|
||||
});
|
||||
|
||||
it('returns empty array when no tools match allowTools', () => {
|
||||
const filter: ToolFilter = { allowTools: new Set(['nonexistent']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when all tools match denyTools', () => {
|
||||
const filter: ToolFilter = { denyTools: new Set(['tool-a', 'tool-b', 'tool-c']) };
|
||||
const result = filterTools(allTools, filter);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles empty tools array', () => {
|
||||
const filter: ToolFilter = { allowTools: new Set(['tool-a']) };
|
||||
const result = filterTools([], filter);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
66
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/fixtures/mock-mcp-server.mjs
vendored
Normal file
66
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/fixtures/mock-mcp-server.mjs
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Mock MCP Server — test fixture for qiming-mcp-stdio-proxy
|
||||
*
|
||||
* Usage:
|
||||
* node mock-mcp-server.mjs --tools '["tool-a","tool-b"]' --name 'my-server'
|
||||
*
|
||||
* Flags:
|
||||
* --tools <json> Array of tool names to register (default: ["mock-tool"])
|
||||
* --name <string> Server name (default: "mock-server")
|
||||
* --assert-no-env <VAR> Exit with error if env variable VAR is set (for env isolation tests)
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
// ---- Parse arguments ----
|
||||
|
||||
function getArg(flag) {
|
||||
const idx = process.argv.indexOf(flag);
|
||||
return idx >= 0 && idx + 1 < process.argv.length ? process.argv[idx + 1] : null;
|
||||
}
|
||||
|
||||
const toolNames = JSON.parse(getArg('--tools') || '["mock-tool"]');
|
||||
const serverName = getArg('--name') || 'mock-server';
|
||||
|
||||
// ---- Env assertion (for testing env isolation) ----
|
||||
|
||||
const assertNoEnv = getArg('--assert-no-env');
|
||||
if (assertNoEnv && process.env[assertNoEnv]) {
|
||||
process.stderr.write(
|
||||
`[${serverName}] ASSERTION FAILED: env var "${assertNoEnv}" should not be set (value: "${process.env[assertNoEnv]}")\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---- Build tools ----
|
||||
|
||||
const tools = toolNames.map((name) => ({
|
||||
name,
|
||||
description: `Mock tool: ${name} (from ${serverName})`,
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
}));
|
||||
|
||||
// ---- Create server ----
|
||||
|
||||
const server = new Server(
|
||||
{ name: serverName, version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => ({
|
||||
content: [{ type: 'text', text: `${serverName}:${request.params.name}` }],
|
||||
}));
|
||||
|
||||
// ---- Start ----
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.stderr.write(`[${serverName}] Mock MCP server running with tools: ${toolNames.join(', ')}\n`);
|
||||
586
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/index.test.ts
Normal file
586
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/index.test.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* Integration tests: qiming-mcp-stdio-proxy
|
||||
*
|
||||
* Tests the proxy as a whole by spawning it as a child process and communicating
|
||||
* via MCP protocol (JSON-RPC over stdio). Uses a mock MCP server fixture for
|
||||
* child server simulation.
|
||||
*
|
||||
* Cross-platform: tests run on Windows, macOS, and Linux.
|
||||
* Requires `npm run build` before running (tests use compiled dist/index.js).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
|
||||
// ========== Paths ==========
|
||||
|
||||
const PROXY_SCRIPT = path.resolve('dist/index.js');
|
||||
const MOCK_SERVER = path.resolve('tests/fixtures/mock-mcp-server.mjs');
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/** Build a clean env Record<string, string> from process.env (filter undefined) */
|
||||
function cleanEnv(extra?: Record<string, string>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (v !== undefined) env[k] = v;
|
||||
}
|
||||
return { ...env, ...extra };
|
||||
}
|
||||
|
||||
/** Spawn proxy process and collect exit code + stderr */
|
||||
function spawnProxy(
|
||||
args: string[],
|
||||
options?: { env?: Record<string, string>; timeoutMs?: number },
|
||||
): Promise<{ code: number | null; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('node', [PROXY_SCRIPT, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: options?.env,
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
resolve({ code, stderr });
|
||||
});
|
||||
|
||||
// Safety timeout
|
||||
const timeout = options?.timeoutMs ?? 10000;
|
||||
setTimeout(() => proc.kill(), timeout);
|
||||
});
|
||||
}
|
||||
|
||||
/** Create config JSON for a single mock MCP server */
|
||||
function singleServerConfig(
|
||||
toolNames: string[] = ['mock-tool'],
|
||||
serverName = 'mock',
|
||||
extra?: { assertNoEnv?: string },
|
||||
): string {
|
||||
const args = [MOCK_SERVER, '--tools', JSON.stringify(toolNames), '--name', serverName];
|
||||
if (extra?.assertNoEnv) {
|
||||
args.push('--assert-no-env', extra.assertNoEnv);
|
||||
}
|
||||
return JSON.stringify({
|
||||
mcpServers: { [serverName]: { command: 'node', args } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Create config JSON for multiple mock MCP servers */
|
||||
function multiServerConfig(
|
||||
servers: { name: string; tools: string[]; assertNoEnv?: string }[],
|
||||
): string {
|
||||
const mcpServers: Record<string, { command: string; args: string[] }> = {};
|
||||
for (const s of servers) {
|
||||
const args = [MOCK_SERVER, '--tools', JSON.stringify(s.tools), '--name', s.name];
|
||||
if (s.assertNoEnv) args.push('--assert-no-env', s.assertNoEnv);
|
||||
mcpServers[s.name] = { command: 'node', args };
|
||||
}
|
||||
return JSON.stringify({ mcpServers });
|
||||
}
|
||||
|
||||
/** Connect an MCP client to the proxy (for E2E tests) */
|
||||
async function connectToProxy(
|
||||
configJson: string,
|
||||
extraEnv?: Record<string, string>,
|
||||
): Promise<Client> {
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [PROXY_SCRIPT, '--config', configJson],
|
||||
...(extraEnv ? { env: cleanEnv(extraEnv) } : {}),
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
}
|
||||
|
||||
// ========== Tests ==========
|
||||
|
||||
describe('qiming-mcp-stdio-proxy', () => {
|
||||
beforeAll(() => {
|
||||
if (!fs.existsSync(PROXY_SCRIPT)) {
|
||||
throw new Error(
|
||||
`Build output not found at ${PROXY_SCRIPT}. Run "npm run build" first.`,
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(MOCK_SERVER)) {
|
||||
throw new Error(`Mock server fixture not found at ${MOCK_SERVER}.`);
|
||||
}
|
||||
});
|
||||
|
||||
// Track clients for cleanup after each test
|
||||
const activeClients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const client of activeClients) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
activeClients.length = 0;
|
||||
});
|
||||
|
||||
// ---- CLI Error Handling (all platforms) ----
|
||||
|
||||
describe('CLI error handling', () => {
|
||||
it('exits with error when --config is missing', async () => {
|
||||
const { code, stderr } = await spawnProxy([]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Missing');
|
||||
expect(stderr).toContain('Usage:');
|
||||
});
|
||||
|
||||
it('exits with error for invalid JSON config', async () => {
|
||||
const { code, stderr } = await spawnProxy(['--config', '{not-json!!}']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Failed to parse --config JSON');
|
||||
});
|
||||
|
||||
it('exits with error when mcpServers key is missing', async () => {
|
||||
const { code, stderr } = await spawnProxy(['--config', '{"foo":"bar"}']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('must contain a "mcpServers" object');
|
||||
});
|
||||
|
||||
it('exits with error for empty mcpServers', async () => {
|
||||
const { code, stderr } = await spawnProxy(['--config', '{"mcpServers":{}}']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('No MCP servers configured');
|
||||
});
|
||||
|
||||
it('exits with error when all child servers fail to connect', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: {
|
||||
bad: { command: 'node', args: ['-e', 'process.exit(1)'], connectionTimeoutMs: 5000 },
|
||||
},
|
||||
});
|
||||
const { code, stderr } = await spawnProxy(['--config', config]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Failed to connect');
|
||||
expect(stderr).toContain('Failed to connect to any MCP server');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Tool Aggregation (all platforms) ----
|
||||
|
||||
describe('Tool aggregation — single server', () => {
|
||||
it('lists tools from a single child server', async () => {
|
||||
const client = await connectToProxy(
|
||||
singleServerConfig(['tool-alpha', 'tool-beta']),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['tool-alpha', 'tool-beta']);
|
||||
});
|
||||
|
||||
it('returns tool descriptions from child server', async () => {
|
||||
const client = await connectToProxy(singleServerConfig(['described-tool'], 'desc-srv'));
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools[0].description).toContain('described-tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool aggregation — multiple servers', () => {
|
||||
it('aggregates tools from multiple child servers', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'server-a', tools: ['tool-a1', 'tool-a2'] },
|
||||
{ name: 'server-b', tools: ['tool-b1'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual(['tool-a1', 'tool-a2', 'tool-b1']);
|
||||
});
|
||||
|
||||
it('handles tool name collision — last server wins, tool appears once', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'server-first', tools: ['shared-tool', 'unique-first'] },
|
||||
{ name: 'server-second', tools: ['shared-tool', 'unique-second'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
// shared-tool should appear only once (from server-second, which overwrites server-first)
|
||||
const sharedTools = tools.filter((t) => t.name === 'shared-tool');
|
||||
expect(sharedTools).toHaveLength(1);
|
||||
// Description should be from server-second (last writer)
|
||||
expect(sharedTools[0].description).toContain('server-second');
|
||||
// Total: shared-tool + unique-first + unique-second = 3
|
||||
expect(tools).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Tool Routing (all platforms) ----
|
||||
|
||||
describe('Tool routing', () => {
|
||||
it('routes tools/call to the correct child server', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'alpha', tools: ['tool-from-alpha'] },
|
||||
{ name: 'beta', tools: ['tool-from-beta'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
// Call tool-from-alpha → should be routed to "alpha" server
|
||||
const resultA = await client.callTool({ name: 'tool-from-alpha', arguments: {} });
|
||||
expect(resultA.content).toBeDefined();
|
||||
// Mock server responds with "serverName:toolName"
|
||||
const textA = (resultA.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(textA).toBe('alpha:tool-from-alpha');
|
||||
|
||||
// Call tool-from-beta → should be routed to "beta" server
|
||||
const resultB = await client.callTool({ name: 'tool-from-beta', arguments: {} });
|
||||
const textB = (resultB.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(textB).toBe('beta:tool-from-beta');
|
||||
});
|
||||
|
||||
it('returns error for unknown tool name', async () => {
|
||||
const client = await connectToProxy(singleServerConfig(['existing-tool']));
|
||||
activeClients.push(client);
|
||||
|
||||
const result = await client.callTool({ name: 'nonexistent-tool', arguments: {} });
|
||||
expect(result.isError).toBe(true);
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toContain('Unknown tool');
|
||||
expect(text).toContain('nonexistent-tool');
|
||||
});
|
||||
|
||||
it('routes collision tool to last-registered server', async () => {
|
||||
const client = await connectToProxy(
|
||||
multiServerConfig([
|
||||
{ name: 'first', tools: ['colliding-tool'] },
|
||||
{ name: 'second', tools: ['colliding-tool'] },
|
||||
]),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const result = await client.callTool({ name: 'colliding-tool', arguments: {} });
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
// Should be routed to "second" (last writer wins)
|
||||
expect(text).toBe('second:colliding-tool');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Partial Startup (all platforms) ----
|
||||
|
||||
describe('Partial startup', () => {
|
||||
it('continues with remaining servers when one fails to connect', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: {
|
||||
'bad-server': {
|
||||
command: 'node',
|
||||
args: ['-e', 'process.exit(1)'],
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
'good-server': {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["working-tool"]', '--name', 'good'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = await connectToProxy(config);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('working-tool');
|
||||
|
||||
// Verify the working tool is callable
|
||||
const result = await client.callTool({ name: 'working-tool', arguments: {} });
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toBe('good:working-tool');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Environment Handling (all platforms) ----
|
||||
|
||||
describe('Environment handling', () => {
|
||||
it('strips ELECTRON_RUN_AS_NODE from child server environment', async () => {
|
||||
// The mock server uses --assert-no-env to exit(1) if the var is set.
|
||||
// If the proxy correctly strips ELECTRON_RUN_AS_NODE, the mock server
|
||||
// starts successfully. If not stripped, mock server exits and proxy
|
||||
// fails to connect → client.connect() would throw.
|
||||
const config = singleServerConfig(['env-tool'], 'env-test', {
|
||||
assertNoEnv: 'ELECTRON_RUN_AS_NODE',
|
||||
});
|
||||
|
||||
const client = await connectToProxy(config, { ELECTRON_RUN_AS_NODE: '1' });
|
||||
activeClients.push(client);
|
||||
|
||||
// If we reach here, the mock server started → env was stripped
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('env-tool');
|
||||
});
|
||||
|
||||
it('merges per-server env variables into child environment', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: 'node',
|
||||
args: [MOCK_SERVER, '--tools', '["env-merge-tool"]', '--name', 'env-merge'],
|
||||
env: { CUSTOM_TEST_VAR: 'hello-from-config' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const client = await connectToProxy(config);
|
||||
activeClients.push(client);
|
||||
|
||||
// If env merge fails, child server won't start and this will throw
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Signal Handling (macOS/Linux only — Windows has no POSIX signals) ----
|
||||
|
||||
describe.skipIf(isWindows)('Signal handling (macOS/Linux)', () => {
|
||||
it('gracefully shuts down on SIGTERM', async () => {
|
||||
const config = singleServerConfig(['signal-tool']);
|
||||
|
||||
const proc = spawn('node', [PROXY_SCRIPT, '--config', config], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// Wait for proxy to be fully ready
|
||||
await new Promise<void>((resolve) => {
|
||||
const onData = (data: Buffer) => {
|
||||
if (data.toString().includes('Proxy server running')) {
|
||||
proc.stderr?.off('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
proc.stderr?.on('data', onData);
|
||||
// Timeout fallback
|
||||
setTimeout(resolve, 8000);
|
||||
});
|
||||
|
||||
// Send SIGTERM
|
||||
proc.kill('SIGTERM');
|
||||
|
||||
// Wait for exit
|
||||
const code = await new Promise<number | null>((resolve) => {
|
||||
proc.on('exit', (c) => resolve(c));
|
||||
setTimeout(() => {
|
||||
proc.kill('SIGKILL');
|
||||
resolve(null);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(stderr).toContain('Received SIGTERM');
|
||||
expect(stderr).toContain('shutting down');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cross-Platform Smoke (all platforms) ----
|
||||
|
||||
describe('Cross-platform compatibility', () => {
|
||||
it('proxy starts and serves tools on current platform', async () => {
|
||||
// Basic smoke test: proxy + mock server work on current OS
|
||||
const client = await connectToProxy(
|
||||
singleServerConfig(['cross-platform-tool'], 'platform-test'),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'cross-platform-tool',
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBeFalsy();
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toBe('platform-test:cross-platform-tool');
|
||||
});
|
||||
|
||||
it('handles config with special characters in tool names', async () => {
|
||||
const client = await connectToProxy(
|
||||
singleServerConfig(['tool-with-dashes', 'tool_with_underscores', 'tool.with.dots']),
|
||||
);
|
||||
activeClients.push(client);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map((t) => t.name).sort()).toEqual([
|
||||
'tool-with-dashes',
|
||||
'tool.with.dots',
|
||||
'tool_with_underscores',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Convert CLI error handling ----
|
||||
|
||||
describe('convert CLI error handling', () => {
|
||||
it('exits with error when no URL or --config provided', async () => {
|
||||
const { code, stderr } = await spawnProxy(['convert']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Either URL or --config is required');
|
||||
});
|
||||
|
||||
it('exits with error for invalid --protocol value', async () => {
|
||||
const { code, stderr } = await spawnProxy(['convert', 'http://example.com', '--protocol', 'invalid']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Invalid protocol');
|
||||
});
|
||||
|
||||
it('exits with error when both --allow-tools and --deny-tools used', async () => {
|
||||
const { code, stderr } = await spawnProxy([
|
||||
'convert', 'http://example.com',
|
||||
'--allow-tools', 'a',
|
||||
'--deny-tools', 'b',
|
||||
]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Cannot use both --allow-tools and --deny-tools');
|
||||
});
|
||||
|
||||
it('exits with error for unknown convert argument', async () => {
|
||||
const { code, stderr } = await spawnProxy(['convert', '--bad-flag']);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Unknown argument');
|
||||
});
|
||||
|
||||
it('exits with error when --name not found in config', async () => {
|
||||
const config = JSON.stringify({
|
||||
mcpServers: { svc: { url: 'http://example.com' } },
|
||||
});
|
||||
const { code, stderr } = await spawnProxy([
|
||||
'convert', '--config', config, '--name', 'nonexistent',
|
||||
]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('not found in config');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Proxy CLI error handling ----
|
||||
|
||||
describe('proxy CLI error handling', () => {
|
||||
it('exits with error when --port is missing', async () => {
|
||||
const config = singleServerConfig(['tool']);
|
||||
const { code, stderr } = await spawnProxy(['proxy', '--config', config]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('--port is required');
|
||||
});
|
||||
|
||||
it('exits with error when --config is missing', async () => {
|
||||
const { code, stderr } = await spawnProxy(['proxy', '--port', '9999']);
|
||||
expect(code).toBe(1);
|
||||
// CLI 支持 --config 或 --config-file,错误信息为 "--config or --config-file is required"
|
||||
expect(stderr).toContain('--config');
|
||||
expect(stderr).toContain('required');
|
||||
});
|
||||
|
||||
it('exits with error for invalid port', async () => {
|
||||
const config = singleServerConfig(['tool']);
|
||||
const { code, stderr } = await spawnProxy(['proxy', '--port', '70000', '--config', config]);
|
||||
expect(code).toBe(1);
|
||||
expect(stderr).toContain('Invalid port');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Proxy mode integration ----
|
||||
|
||||
describe('proxy mode', () => {
|
||||
it('starts HTTP server and serves tools via StreamableHTTP', async () => {
|
||||
const config = singleServerConfig(['proxy-tool'], 'proxy-mock');
|
||||
|
||||
// Start proxy in HTTP server mode (port 0 = random)
|
||||
const proc = spawn('node', [PROXY_SCRIPT, 'proxy', '--port', '0', '--config', config], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stderrBuf = '';
|
||||
|
||||
// 1) 等待 HTTP 端口输出
|
||||
const port = await new Promise<number>((resolve, reject) => {
|
||||
const tryMatch = () => {
|
||||
const match = stderrBuf.match(/HTTP server listening on 127\.0\.0\.1:(\d+)/);
|
||||
if (match) {
|
||||
resolve(parseInt(match[1], 10));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
stderrBuf += data.toString();
|
||||
tryMatch();
|
||||
});
|
||||
setTimeout(() => reject(new Error(`Timed out waiting for proxy server. stderr: ${stderrBuf}`)), 10000);
|
||||
});
|
||||
|
||||
// 2) 等待 bridge 就绪(子进程 spawn 与 listTools 完成后再接受 /mcp/<serverId> 请求)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (stderrBuf.includes('Bridge ready on port')) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onData = (data: Buffer) => {
|
||||
stderrBuf += data.toString();
|
||||
if (stderrBuf.includes('Bridge ready on port')) {
|
||||
proc.stderr?.off('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
proc.stderr?.on('data', onData);
|
||||
setTimeout(() => reject(new Error(`Timed out waiting for bridge ready. stderr: ${stderrBuf}`)), 15000);
|
||||
});
|
||||
|
||||
try {
|
||||
// Connect via StreamableHTTP to the bridge endpoint
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${port}/mcp/proxy-mock`),
|
||||
);
|
||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('proxy-tool');
|
||||
|
||||
const result = await client.callTool({ name: 'proxy-tool', arguments: {} });
|
||||
const text = (result.content as { type: string; text: string }[])[0]?.text;
|
||||
expect(text).toBe('proxy-mock:proxy-tool');
|
||||
|
||||
await client.close();
|
||||
await transport.close();
|
||||
} finally {
|
||||
proc.kill('SIGTERM');
|
||||
await new Promise<void>((resolve) => {
|
||||
proc.on('exit', () => resolve());
|
||||
setTimeout(() => { proc.kill('SIGKILL'); resolve(); }, 3000);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Test: Remote MCP Server with Streamable HTTP (auth headers)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { writeFileSync, rmSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { buildRequestHeaders } from '../src/transport/headers.js';
|
||||
import type { StreamableServerEntry } from '../src/types.js';
|
||||
|
||||
// Test constants
|
||||
const TEST_CONFIG_DIR = join(tmpdir(), 'test-mcp-remote-headers');
|
||||
const TEST_URL = 'https://mcp.coze.cn/v1/plugins/7407724292865130515';
|
||||
|
||||
// 注意: 这是临时测试 token,不要固化到代码中
|
||||
const TEST_AUTH_TOKEN = process.env.TEST_AUTH_TOKEN || 'Bearer cztei_xxx';
|
||||
const TEST_HEADERS = {
|
||||
Authorization: TEST_AUTH_TOKEN,
|
||||
};
|
||||
|
||||
// Helper to create a test config file
|
||||
function createTestConfig(serverName: string, headers?: Record<string, string>): string {
|
||||
const configPath = join(TEST_CONFIG_DIR, `${serverName}.json`);
|
||||
const config = {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
headers: headers || TEST_HEADERS,
|
||||
},
|
||||
},
|
||||
};
|
||||
writeFileSync(configPath, JSON.stringify(config), 'utf-8');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
// Helper to cleanup test files
|
||||
function cleanupTestFiles() {
|
||||
try {
|
||||
const files = readdirSync(TEST_CONFIG_DIR);
|
||||
for (const file of files) {
|
||||
rmSync(file);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildRequestHeaders', () => {
|
||||
beforeEach(() => {
|
||||
cleanupTestFiles();
|
||||
});
|
||||
|
||||
it('should return undefined when entry has no headers or authToken', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return headers with Bearer prefix when entry has authToken', () => {
|
||||
const rawToken = 'my-auth-token';
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
authToken: rawToken,
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({
|
||||
Authorization: `Bearer ${rawToken}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return headers directly when entry has headers', () => {
|
||||
const customHeaders = {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
Authorization: 'CustomAuth custom-token',
|
||||
};
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
headers: customHeaders,
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual(customHeaders);
|
||||
});
|
||||
|
||||
it('should let authToken override headers.Authorization when both are provided', () => {
|
||||
const customHeaders = {
|
||||
Authorization: 'CustomAuth custom-token',
|
||||
};
|
||||
const entry: StreamableServerEntry = {
|
||||
url: TEST_URL,
|
||||
transport: 'streamable-http',
|
||||
headers: customHeaders,
|
||||
authToken: 'override-token',
|
||||
};
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
// authToken 会覆盖 headers 中的 Authorization
|
||||
expect(result).toEqual({
|
||||
Authorization: 'Bearer override-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined when entry is a stdio entry (no url)', () => {
|
||||
const entry = {
|
||||
command: 'npx',
|
||||
args: ['-y', 'some-mcp-server'],
|
||||
} as unknown as StreamableServerEntry;
|
||||
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProtocol with auth failure', () => {
|
||||
// 这个测试需要真实的网络请求, 仅用于调试目的
|
||||
// 注意: 不要在 CI 中启用, 因为 token 会过期
|
||||
it.skip('should handle 401 auth failure gracefully', async () => {
|
||||
const { detectProtocol } = await import('../src/detect.js');
|
||||
|
||||
const invalidToken = 'Bearer invalid-token-for-testing';
|
||||
const result = await detectProtocol(TEST_URL, {
|
||||
Authorization: invalidToken,
|
||||
});
|
||||
|
||||
// 即使鉴权失败, 也应该返回一个协议类型(默认 sse)
|
||||
expect(['sse', 'stream']).toContain(result);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Integration tests for ResilientTransportWrapper using real HTTP/SSE servers.
|
||||
*
|
||||
* These tests use the demo servers to simulate realistic network scenarios
|
||||
* including heartbeat concurrency, reconnection, and timing issues.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ResilientTransportWrapper } from '../src/resilient.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// ---- Mock HTTP Server for testing ----
|
||||
|
||||
class MockHttpMcpServer {
|
||||
private server: Server | null = null;
|
||||
private port: number;
|
||||
private requestDelay: number = 0;
|
||||
private shouldFail: boolean = false;
|
||||
private requestCount: number = 0;
|
||||
|
||||
constructor(port: number) {
|
||||
this.port = port;
|
||||
}
|
||||
|
||||
setRequestDelay(ms: number) {
|
||||
this.requestDelay = ms;
|
||||
}
|
||||
|
||||
setShouldFail(fail: boolean) {
|
||||
this.shouldFail = fail;
|
||||
}
|
||||
|
||||
getRequestCount(): number {
|
||||
return this.requestCount;
|
||||
}
|
||||
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server = createServer(async (req, res) => {
|
||||
this.requestCount++;
|
||||
|
||||
// Simulate slow network
|
||||
if (this.requestDelay > 0) {
|
||||
await new Promise(r => setTimeout(r, this.requestDelay));
|
||||
}
|
||||
|
||||
if (this.shouldFail) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Server error' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read body
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const body = chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString()) : {};
|
||||
|
||||
// Handle MCP requests
|
||||
if (body.method === 'initialize') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'mcp-session-id': randomUUID(),
|
||||
});
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
serverInfo: { name: 'mock-server', version: '1.0' },
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.method === 'tools/list') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {
|
||||
tools: [
|
||||
{ name: 'echo', description: 'Echo tool', inputSchema: {} },
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Default response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
result: {},
|
||||
}));
|
||||
});
|
||||
|
||||
this.server.listen(this.port, '127.0.0.1', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.server) {
|
||||
this.server.close(() => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('ResilientTransportWrapper - Integration Tests', () => {
|
||||
let mockServer: MockHttpMcpServer;
|
||||
const TEST_PORT = 19080;
|
||||
const TEST_URL = `http://127.0.0.1:${TEST_PORT}/mcp`;
|
||||
|
||||
beforeEach(() => {
|
||||
mockServer = new MockHttpMcpServer(TEST_PORT);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await mockServer.stop();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Heartbeat Concurrency Protection', () => {
|
||||
it('should prevent multiple concurrent health checks with slow network', async () => {
|
||||
// Start mock server with slow response
|
||||
mockServer.setRequestDelay(500); // 500ms delay per request
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCalls = 0;
|
||||
let concurrentCalls = 0;
|
||||
let maxConcurrentCalls = 0;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'concurrency-test',
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 10000, // Long timeout to not interfere
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
concurrentCalls++;
|
||||
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
|
||||
|
||||
// Simulate slow health check
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
concurrentCalls--;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first health check
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// At this point, first health check is in progress (started but not completed)
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Trigger more timer events while health check is in progress
|
||||
// These should be blocked by healthCheckInProgress guard
|
||||
await vi.advanceTimersByTimeAsync(1000); // Would trigger second check if not guarded
|
||||
await vi.advanceTimersByTimeAsync(1000); // Would trigger third check
|
||||
|
||||
// Complete the first health check
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// Only 1 call should have started because of the guard
|
||||
expect(maxConcurrentCalls).toBe(1);
|
||||
|
||||
// After first check completes, next one can be scheduled
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should handle rapid heartbeat timer triggers without stacking', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
const healthCheckTimes: number[] = [];
|
||||
let startTime = Date.now();
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'stacking-test',
|
||||
pingIntervalMs: 500,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckTimes.push(Date.now() - startTime);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 10 "heartbeat cycles"
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await vi.advanceTimersByTimeAsync(10); // Allow async to complete
|
||||
}
|
||||
|
||||
expect(healthCheckTimes.length).toBe(10);
|
||||
|
||||
// Verify each check is ~500ms apart (response-driven scheduling)
|
||||
for (let i = 1; i < healthCheckTimes.length; i++) {
|
||||
const diff = healthCheckTimes[i] - healthCheckTimes[i - 1];
|
||||
// Should be at least 500ms (pingIntervalMs), not stacked
|
||||
expect(diff).toBeGreaterThanOrEqual(500);
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response-Driven Scheduling', () => {
|
||||
it('should schedule next check only after current one completes', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let checkCompleted = false;
|
||||
let nextCheckScheduledBeforeComplete = false;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'response-driven-test',
|
||||
pingIntervalMs: 1000,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
// Check if next check was already scheduled (it shouldn't be)
|
||||
// This is a bit indirect, but we can verify by timing
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
checkCompleted = true;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first check
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// Check is in progress, not completed yet
|
||||
expect(checkCompleted).toBe(false);
|
||||
|
||||
// Complete the check
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(checkCompleted).toBe(true);
|
||||
|
||||
// Now next check should be scheduled 1000ms after completion
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
// Should not have triggered yet
|
||||
// (we'd need another counter to verify this properly)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2);
|
||||
// Now it should trigger
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should not schedule next check when state changes during health check', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCount = 0;
|
||||
let shouldFailHealthCheck = false;
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'state-change-test',
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCount++;
|
||||
if (shouldFailHealthCheck) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// First check passes
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(1);
|
||||
|
||||
// Make health checks fail
|
||||
shouldFailHealthCheck = true;
|
||||
|
||||
// Second check fails
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(2);
|
||||
|
||||
// Third check fails, triggers reconnect
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCount).toBe(3);
|
||||
|
||||
// After max failures, state should be reconnecting
|
||||
// No more health checks should run during reconnection
|
||||
const countBeforeReconnect = healthCheckCount;
|
||||
await vi.advanceTimersByTimeAsync(50); // During reconnection delay
|
||||
|
||||
// Should not have increased because state is reconnecting
|
||||
expect(healthCheckCount).toBe(countBeforeReconnect);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-World Scenarios', () => {
|
||||
it('should handle server restart with pending health checks', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
let healthCheckCount = 0;
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'restart-test',
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 3,
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCount++;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
// Simulate reconnection handling
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run a few successful heartbeats
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
expect(healthCheckCount).toBe(2);
|
||||
|
||||
// Simulate server going down
|
||||
mockServer.setShouldFail(true);
|
||||
|
||||
// Wait for health checks to fail
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Bring server back up
|
||||
mockServer.setShouldFail(false);
|
||||
|
||||
// Wait for reconnection
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should maintain heartbeat rhythm despite slow health checks', async () => {
|
||||
await mockServer.start();
|
||||
|
||||
const checkTimes: number[] = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'rhythm-test',
|
||||
pingIntervalMs: 1000,
|
||||
connectParams: async () => {
|
||||
return new StreamableHTTPClientTransport(new URL(TEST_URL));
|
||||
},
|
||||
healthCheckFn: async () => {
|
||||
checkTimes.push(Date.now() - startTime);
|
||||
// Simulate variable health check duration
|
||||
await new Promise(r => setTimeout(r, Math.random() * 200));
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 5 cycles
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(250); // Account for health check duration
|
||||
}
|
||||
|
||||
expect(checkTimes.length).toBe(5);
|
||||
|
||||
// Verify that checks don't stack up despite variable duration
|
||||
// Each check should start approximately 1000ms after the previous one completed
|
||||
for (let i = 1; i < checkTimes.length; i++) {
|
||||
const diff = checkTimes[i] - checkTimes[i - 1];
|
||||
// Should be around 1000ms + health check duration
|
||||
expect(diff).toBeGreaterThanOrEqual(1000);
|
||||
expect(diff).toBeLessThan(1500); // Not stacked
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
599
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/resilient.test.ts
Normal file
599
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/resilient.test.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ResilientTransportWrapper } from '../src/resilient.js';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
class MockTransport implements Transport {
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
|
||||
startCalls = 0;
|
||||
closeCalls = 0;
|
||||
sentMessages: JSONRPCMessage[] = [];
|
||||
shouldFailStart = false;
|
||||
shouldFailSend = false;
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.startCalls++;
|
||||
if (this.shouldFailStart) {
|
||||
throw new Error('Mock start failure');
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls++;
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (this.shouldFailSend) {
|
||||
throw new Error('Mock send failure');
|
||||
}
|
||||
this.sentMessages.push(message);
|
||||
}
|
||||
|
||||
// Helper to simulate incoming messages
|
||||
simulateMessage(msg: JSONRPCMessage) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to simulate transport error
|
||||
simulateError(err: Error) {
|
||||
if (this.onerror) {
|
||||
this.onerror(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to simulate transport close
|
||||
simulateClose() {
|
||||
if (this.onclose) {
|
||||
this.onclose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('ResilientTransportWrapper', () => {
|
||||
let mockTransports: MockTransport[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mockTransports = [];
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const createWrapper = (options = {}) => {
|
||||
return new ResilientTransportWrapper({
|
||||
name: 'test-wrapper',
|
||||
connectParams: async () => {
|
||||
const t = new MockTransport();
|
||||
mockTransports.push(t);
|
||||
return t;
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
it('should connect successfully', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await wrapper.start();
|
||||
|
||||
expect(mockTransports.length).toBe(1);
|
||||
expect(mockTransports[0].startCalls).toBe(1);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should queue messages while reconnecting, then flush them', async () => {
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
await wrapper.start();
|
||||
|
||||
const firstTransport = mockTransports[0];
|
||||
|
||||
// Simulate drop
|
||||
firstTransport.simulateClose();
|
||||
|
||||
// Wrapper state should now be reconnecting
|
||||
|
||||
const testMsg: JSONRPCMessage = { jsonrpc: '2.0', method: 'test', params: { a: 1 } };
|
||||
|
||||
// Send while reconnecting
|
||||
await wrapper.send(testMsg);
|
||||
|
||||
// Ensure it wasn't sent to the closed transport
|
||||
expect(firstTransport.sentMessages.length).toBe(0);
|
||||
|
||||
// Fast forward to trigger reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// A new transport should be created
|
||||
expect(mockTransports.length).toBe(2);
|
||||
const secondTransport = mockTransports[1];
|
||||
|
||||
// The queued message should have been flushed
|
||||
expect(secondTransport.sentMessages.length).toBe(1);
|
||||
expect(secondTransport.sentMessages[0]).toEqual(testMsg);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should reconnect if heartbeat fails repeatedly', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
return false; // Always fail health check
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first heartbeat failure
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(mockTransports.length).toBe(1); // Still same transport
|
||||
|
||||
// Trigger second heartbeat failure (maxConsecutiveFailures = 2)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Advance for reconnect delay
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should have reconnected (new transport created)
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should intercept inner transport errors and trigger reconnect', async () => {
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
await wrapper.start();
|
||||
|
||||
const firstTransport = mockTransports[0];
|
||||
firstTransport.simulateError(new Error('Test error'));
|
||||
|
||||
// Advance time for reconnect delay
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should have reconnected
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should pass transparent messages down to onmessage handler', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await wrapper.start();
|
||||
|
||||
let receivedMsg: JSONRPCMessage | null = null;
|
||||
wrapper.onmessage = (msg) => {
|
||||
receivedMsg = msg;
|
||||
};
|
||||
|
||||
const testMsg: JSONRPCMessage = { jsonrpc: '2.0', method: 'notify', params: { x: 1 } };
|
||||
mockTransports[0].simulateMessage(testMsg);
|
||||
|
||||
expect(receivedMsg).toEqual(testMsg);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should handle flushQueue errors by putting messages back', async () => {
|
||||
const wrapper = createWrapper({ maxQueueSize: 5 });
|
||||
await wrapper.start();
|
||||
|
||||
const firstTransport = mockTransports[0];
|
||||
firstTransport.simulateClose(); // puts it in reconnecting state
|
||||
|
||||
// Add messages to queue
|
||||
await wrapper.send({ jsonrpc: '2.0', method: 'test1' });
|
||||
await wrapper.send({ jsonrpc: '2.0', method: 'test2' });
|
||||
|
||||
// Advance time to reconnect
|
||||
let newTransportReady = false;
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
const spy = vi.spyOn(global, 'setTimeout').mockImplementationOnce((cb: any, ms?: number) => {
|
||||
// simulate the factory creating the second one and failing its send
|
||||
const id = originalSetTimeout(() => {
|
||||
cb();
|
||||
if (mockTransports.length > 1) {
|
||||
mockTransports[1].shouldFailSend = true;
|
||||
newTransportReady = true;
|
||||
}
|
||||
}, ms);
|
||||
return id as any;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
// Test the queue size management limit
|
||||
firstTransport.simulateClose();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await wrapper.send({ jsonrpc: '2.0', method: `spam${i}` });
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should throw when sending on a closed transport', async () => {
|
||||
const wrapper = createWrapper();
|
||||
await wrapper.start();
|
||||
await wrapper.close();
|
||||
|
||||
await expect(wrapper.send({ jsonrpc: '2.0', method: 'test' })).rejects.toThrow('Transport is closed');
|
||||
});
|
||||
|
||||
it('should throw when initial connect fails', async () => {
|
||||
const wrapper = new ResilientTransportWrapper({
|
||||
name: 'fail-connect',
|
||||
reconnectDelayMs: 100,
|
||||
connectParams: async () => {
|
||||
throw new Error('Initial network error');
|
||||
}
|
||||
});
|
||||
|
||||
// start() should throw on initial connection failure
|
||||
// Caller is responsible for retry logic
|
||||
await expect(wrapper.start()).rejects.toThrow('Initial network error');
|
||||
|
||||
// Clean up
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should call healthCheckFn for heartbeat', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 3,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Advance timer to trigger first heartbeat
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
// Allow async healthCheckFn to complete
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// healthCheckFn should have been called once
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Advance for another heartbeat
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should trigger reconnect when healthCheckFn fails repeatedly', async () => {
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
return false; // Always fail
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first heartbeat failure
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(mockTransports.length).toBe(1); // Still same transport
|
||||
|
||||
// Trigger second heartbeat failure (maxConsecutiveFailures = 2)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Advance for reconnect delay
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should have reconnected (new transport created)
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should trigger onreconnect event on reconnect', async () => {
|
||||
let onreconnectCalls = 0;
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
onreconnectCalls++;
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
|
||||
// Simulate disconnect
|
||||
mockTransports[0].simulateClose();
|
||||
|
||||
// Advance time for reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// onreconnect should have been called
|
||||
expect(onreconnectCalls).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should handle onreconnect failure with backoff', async () => {
|
||||
let reconnectAttempts = 0;
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
reconnectAttempts++;
|
||||
throw new Error('Reconnect handler failed');
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
|
||||
// Simulate disconnect
|
||||
mockTransports[0].simulateClose();
|
||||
|
||||
// Advance time for first reconnect attempt (100ms delay)
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
expect(reconnectAttempts).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
// onreconnect failed, should retry with backoff (400ms = 100 * 2^2)
|
||||
await vi.advanceTimersByTimeAsync(450);
|
||||
expect(reconnectAttempts).toBe(2);
|
||||
expect(mockTransports.length).toBe(3);
|
||||
|
||||
// Next retry with backoff (800ms = 100 * 2^3)
|
||||
await vi.advanceTimersByTimeAsync(850);
|
||||
expect(reconnectAttempts).toBe(3);
|
||||
expect(mockTransports.length).toBe(4);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should succeed when onreconnect handler succeeds', async () => {
|
||||
let reconnectAttempts = 0;
|
||||
const wrapper = createWrapper({ reconnectDelayMs: 100 });
|
||||
|
||||
wrapper.onreconnect = async () => {
|
||||
reconnectAttempts++;
|
||||
// Success - no throw
|
||||
};
|
||||
|
||||
await wrapper.start();
|
||||
|
||||
// Simulate disconnect
|
||||
mockTransports[0].simulateClose();
|
||||
|
||||
// Advance time for reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// onreconnect should have succeeded
|
||||
expect(reconnectAttempts).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
// No more reconnects should happen
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(reconnectAttempts).toBe(1);
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should use transport liveness check when no healthCheckFn provided', async () => {
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 500,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
// No healthCheckFn provided
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Trigger heartbeat - should pass because transport is alive
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// Should still be on same transport (health check passed via liveness)
|
||||
expect(mockTransports.length).toBe(1);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should prevent concurrent health check executions', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
let healthCheckResolve: () => void;
|
||||
let healthCheckPromise: Promise<boolean>;
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
pingTimeoutMs: 5000, // Long timeout to test concurrency
|
||||
maxConsecutiveFailures: 3,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
// Return a promise that doesn't resolve immediately
|
||||
healthCheckPromise = new Promise((resolve) => {
|
||||
healthCheckResolve = () => resolve(true);
|
||||
});
|
||||
return healthCheckPromise;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first heartbeat - this will start but not complete
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
|
||||
// healthCheckFn should have been called once and is now pending
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Trigger more timers while health check is in progress
|
||||
// These should be skipped due to healthCheckInProgress guard
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
// Still only 1 call because the first one hasn't resolved yet
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Now resolve the health check
|
||||
healthCheckResolve!();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now the next heartbeat can run
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should use response-driven scheduling (setTimeout not setInterval)', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
const healthCheckTimes: number[] = [];
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
healthCheckTimes.push(Date.now());
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Run for 5 heartbeat cycles
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10); // Allow async to complete
|
||||
}
|
||||
|
||||
expect(healthCheckCalls).toBe(5);
|
||||
|
||||
// Verify timing - each check should be ~1000ms apart
|
||||
// (not stacked up like setInterval would do if checks were slow)
|
||||
for (let i = 1; i < healthCheckTimes.length; i++) {
|
||||
const diff = healthCheckTimes[i] - healthCheckTimes[i - 1];
|
||||
expect(diff).toBeGreaterThanOrEqual(1000);
|
||||
}
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should not schedule next health check when state changes to reconnecting', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
return false; // Always fail
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger first failure
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBe(1);
|
||||
|
||||
// Trigger second failure - should trigger reconnect
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBe(2);
|
||||
|
||||
// After hitting maxConsecutiveFailures, no more health checks should be scheduled
|
||||
// because state is now 'reconnecting'
|
||||
const callsBeforeReconnect = healthCheckCalls;
|
||||
|
||||
// Advance time - no new health checks should run during reconnect
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(healthCheckCalls).toBe(callsBeforeReconnect);
|
||||
|
||||
// Complete the reconnect
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// After reconnect, heartbeat should restart
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(healthCheckCalls).toBeGreaterThan(callsBeforeReconnect);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
|
||||
it('should schedule next check in finally block only when connected', async () => {
|
||||
let healthCheckCalls = 0;
|
||||
let shouldFailHealthCheck = true;
|
||||
|
||||
const wrapper = createWrapper({
|
||||
pingIntervalMs: 1000,
|
||||
maxConsecutiveFailures: 2,
|
||||
reconnectDelayMs: 100,
|
||||
healthCheckFn: async () => {
|
||||
healthCheckCalls++;
|
||||
if (shouldFailHealthCheck) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
await wrapper.start();
|
||||
wrapper.enableHeartbeat();
|
||||
|
||||
// Trigger failures to cause reconnect
|
||||
await vi.advanceTimersByTimeAsync(1001);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// Now let health checks pass
|
||||
shouldFailHealthCheck = false;
|
||||
|
||||
// Complete reconnect
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
|
||||
// Should be reconnected now
|
||||
expect(mockTransports.length).toBe(2);
|
||||
|
||||
// Heartbeat should resume with new transport
|
||||
const callsAfterReconnect = healthCheckCalls;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
// New health check should have been scheduled after reconnect
|
||||
expect(healthCheckCalls).toBeGreaterThan(callsAfterReconnect);
|
||||
|
||||
await wrapper.close();
|
||||
});
|
||||
});
|
||||
167
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/shared.test.ts
Normal file
167
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/shared.test.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Unit tests: shared.ts — discoverTools, createToolProxyServer, setupGracefulShutdown
|
||||
*
|
||||
* Uses mock MCP servers to test the shared helpers in isolation.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
import {
|
||||
ListToolsRequestSchema,
|
||||
CallToolRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { discoverTools, setupGracefulShutdown } from '../src/shared/index.js';
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
function makeTool(name: string): Tool {
|
||||
return {
|
||||
name,
|
||||
description: `Tool: ${name}`,
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a mock MCP server + connected client via in-memory transport */
|
||||
async function createMockServerClient(
|
||||
tools: Tool[],
|
||||
): Promise<{ server: Server; client: Client; close: () => Promise<void> }> {
|
||||
const server = new Server(
|
||||
{ name: 'mock', version: '1.0.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name } = request.params;
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `result:${name}` }],
|
||||
};
|
||||
});
|
||||
|
||||
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
return {
|
||||
server,
|
||||
client,
|
||||
close: async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========== discoverTools ==========
|
||||
|
||||
describe('discoverTools', () => {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const fn of cleanups) {
|
||||
await fn();
|
||||
}
|
||||
cleanups.length = 0;
|
||||
});
|
||||
|
||||
it('discovers tools from a server with no tools', async () => {
|
||||
const { client, close } = await createMockServerClient([]);
|
||||
cleanups.push(close);
|
||||
|
||||
const tools = await discoverTools(client);
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('discovers all tools from a server', async () => {
|
||||
const mockTools = [makeTool('alpha'), makeTool('beta'), makeTool('gamma')];
|
||||
const { client, close } = await createMockServerClient(mockTools);
|
||||
cleanups.push(close);
|
||||
|
||||
const tools = await discoverTools(client);
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map((t) => t.name)).toEqual(['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('returns tool objects with correct properties', async () => {
|
||||
const mockTools = [makeTool('test-tool')];
|
||||
const { client, close } = await createMockServerClient(mockTools);
|
||||
cleanups.push(close);
|
||||
|
||||
const tools = await discoverTools(client);
|
||||
expect(tools[0]).toEqual({
|
||||
name: 'test-tool',
|
||||
description: 'Tool: test-tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========== setupGracefulShutdown ==========
|
||||
|
||||
describe('setupGracefulShutdown', () => {
|
||||
let listeners: Map<string, ((...args: unknown[]) => void)[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
listeners = new Map();
|
||||
vi.spyOn(process, 'on').mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
|
||||
if (!listeners.has(event)) listeners.set(event, []);
|
||||
listeners.get(event)!.push(handler);
|
||||
return process;
|
||||
});
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers SIGINT and SIGTERM handlers', () => {
|
||||
setupGracefulShutdown(async () => {});
|
||||
expect(listeners.has('SIGINT')).toBe(true);
|
||||
expect(listeners.has('SIGTERM')).toBe(true);
|
||||
});
|
||||
|
||||
it('calls cleanup function on signal', async () => {
|
||||
const cleanupFn = vi.fn().mockResolvedValue(undefined);
|
||||
setupGracefulShutdown(cleanupFn);
|
||||
|
||||
// Trigger SIGTERM handler
|
||||
const handler = listeners.get('SIGTERM')![0];
|
||||
await handler();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledOnce();
|
||||
expect(process.exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('only runs cleanup once even if signaled twice', async () => {
|
||||
const cleanupFn = vi.fn().mockResolvedValue(undefined);
|
||||
setupGracefulShutdown(cleanupFn);
|
||||
|
||||
const handler = listeners.get('SIGINT')![0];
|
||||
await handler();
|
||||
await handler();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('still exits when cleanup throws', async () => {
|
||||
const cleanupFn = vi.fn().mockRejectedValue(new Error('cleanup failed'));
|
||||
setupGracefulShutdown(cleanupFn);
|
||||
|
||||
const handler = listeners.get('SIGTERM')![0];
|
||||
await handler();
|
||||
|
||||
expect(cleanupFn).toHaveBeenCalledOnce();
|
||||
expect(process.exit).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Unit tests: transport.ts — buildRequestHeaders
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildRequestHeaders } from '../src/transport/index.js';
|
||||
import type { StreamableServerEntry, SseServerEntry } from '../src/types.js';
|
||||
|
||||
describe('buildRequestHeaders', () => {
|
||||
it('returns undefined when no headers or authToken', () => {
|
||||
const entry: StreamableServerEntry = { url: 'http://example.com' };
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes custom headers', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
headers: { 'X-Custom': 'value', 'X-Other': 'other' },
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({ 'X-Custom': 'value', 'X-Other': 'other' });
|
||||
});
|
||||
|
||||
it('adds Bearer authorization from authToken', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
authToken: 'my-secret-token',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({ Authorization: 'Bearer my-secret-token' });
|
||||
});
|
||||
|
||||
it('merges headers and authToken', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
authToken: 'token123',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({
|
||||
'X-Custom': 'value',
|
||||
Authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('authToken overrides Authorization in custom headers', () => {
|
||||
const entry: StreamableServerEntry = {
|
||||
url: 'http://example.com',
|
||||
headers: { Authorization: 'Basic abc' },
|
||||
authToken: 'bearer-wins',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result!.Authorization).toBe('Bearer bearer-wins');
|
||||
});
|
||||
|
||||
it('works with SseServerEntry', () => {
|
||||
const entry: SseServerEntry = {
|
||||
url: 'http://example.com/sse',
|
||||
transport: 'sse',
|
||||
headers: { 'X-SSE': 'yes' },
|
||||
authToken: 'sse-token',
|
||||
};
|
||||
const result = buildRequestHeaders(entry);
|
||||
expect(result).toEqual({
|
||||
'X-SSE': 'yes',
|
||||
Authorization: 'Bearer sse-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
116
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/usage.test.ts
Normal file
116
qimingclaw/crates/qiming-mcp-stdio-proxy/tests/usage.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Usage Examples & Integration Tests
|
||||
*
|
||||
* This test suite demonstrates how a downstream client (like an Electron app
|
||||
* or Agent OS manager) configuration translates to qiming-mcp-stdio-proxy execution.
|
||||
* It is adapted from real usage patterns in agent-electron-client.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
const PROXY_SCRIPT = path.resolve('dist/index.js');
|
||||
|
||||
/**
|
||||
* Helper to test CLI arg parsing and proxy behavior
|
||||
* without actually completing a full connection (we just check its stderr rejection to see if it understood args).
|
||||
*/
|
||||
function spawnProxy(
|
||||
args: string[],
|
||||
options?: { env?: Record<string, string>; timeoutMs?: number },
|
||||
): Promise<{ code: number | null; stderr: string; stdout: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('node', [PROXY_SCRIPT, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...options?.env }, // inherit to keep vitest happy
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
let stdout = '';
|
||||
|
||||
proc.stderr?.on('data', (d) => (stderr += d.toString()));
|
||||
proc.stdout?.on('data', (d) => (stdout += d.toString()));
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
resolve({ code, stderr, stdout });
|
||||
});
|
||||
|
||||
setTimeout(() => proc.kill(), options?.timeoutMs ?? 15000);
|
||||
});
|
||||
}
|
||||
|
||||
describe('Proxy Usage Examples (from agent-electron-client)', () => {
|
||||
it('Example 1: Downstream client creates temporary stdio proxy configuration', async () => {
|
||||
// In agent-electron-client, mcpProxyManager builds a config for the proxy
|
||||
// to aggregate multiple temporary MCP servers.
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'test-mcp': {
|
||||
command: 'node',
|
||||
args: ['-e', 'console.log("dummy-server")'],
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const configString = JSON.stringify(config);
|
||||
|
||||
// The proxy is then invoked with this config via standard stdio
|
||||
const { code, stderr } = await spawnProxy(['--config', configString]);
|
||||
|
||||
// We expect the proxy to start and immediately fail because our dummy server isn't a REAL MCP server
|
||||
// (it just prints 'dummy-server' and exits), but we can verify the proxy accepted the config
|
||||
// and attempted to spawn "test-mcp".
|
||||
expect(code).toBe(1); // Exits 1 because child server dies immediately
|
||||
expect(stderr).toContain('test-mcp');
|
||||
expect(stderr).toContain('Failed to connect to any MCP server');
|
||||
});
|
||||
|
||||
it('Example 2: Downstream client routes to a persistent bridge', async () => {
|
||||
// When the PersistentMcpBridge is running in agent-electron-client,
|
||||
// it maps standard subprocess configs to HTTP URLs.
|
||||
const proxyConfig = {
|
||||
mcpServers: {
|
||||
'chrome-devtools': {
|
||||
url: 'http://127.0.0.1:12345/mcp/chrome-devtools',
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const configString = JSON.stringify(proxyConfig);
|
||||
|
||||
// The downstream client spawns the proxy expecting it to convert that HTTP URL into stdio
|
||||
const { code, stderr } = await spawnProxy(['--config', configString]);
|
||||
|
||||
// It should fail to connect since port 12345 has no real server.
|
||||
// Protocol detection defaults to SSE, then SSE connection fails with ECONNREFUSED.
|
||||
expect(stderr).toContain('chrome-devtools');
|
||||
expect(stderr).toContain('Failed to connect');
|
||||
}, 20000);
|
||||
|
||||
it('Example 3: Downstream client mixed temporary and bridged configs', async () => {
|
||||
// agent-electron-client frequently mixes "temporary" (stdio) tools
|
||||
// with "persistent" (bridged) tools in the same proxy call.
|
||||
const mixedConfig = {
|
||||
mcpServers: {
|
||||
'local-tool': {
|
||||
command: 'node',
|
||||
args: ['-e', 'process.exit(1)'],
|
||||
connectionTimeoutMs: 5000,
|
||||
},
|
||||
'remote-bridge': {
|
||||
url: 'http://127.0.0.1:12346/mcp/remote-bridge',
|
||||
connectionTimeoutMs: 5000,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { code, stderr } = await spawnProxy(['--config', JSON.stringify(mixedConfig)]);
|
||||
|
||||
// Proxy should try to aggregate both
|
||||
expect(stderr).toContain('local-tool');
|
||||
expect(stderr).toContain('Failed to connect');
|
||||
}, 20000);
|
||||
});
|
||||
16
qimingclaw/crates/qiming-mcp-stdio-proxy/tsconfig.json
Normal file
16
qimingclaw/crates/qiming-mcp-stdio-proxy/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"sourceMap": false,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
17
qimingclaw/crates/qiming-mcp-stdio-proxy/vitest.config.ts
Normal file
17
qimingclaw/crates/qiming-mcp-stdio-proxy/vitest.config.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.{test,spec}.ts'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
testTimeout: 15000,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['tests/**', 'dist/**', 'scripts/**', '**/*.test.ts'],
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user