chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,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 的 stderrproxy 由 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 ServerSSE / 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/` 文件夹中。

View 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 边界失效导致无声无息断连,进而产生的僵尸请求和进程挂起问题。

View File

@@ -0,0 +1 @@
AGENTS.md

View 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` wrapperWindows 无弹窗
### 架构对比
| | 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` 接口。

View 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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,283 @@
/**
* MCP Resilient Transport 重连测试
*
* 自动测试 Streamable HTTP 和 SSE 两种传输协议的 重连 + re-initialize 流程。
*
* 用法:
* node demo/test-reconnect.mjs # 跑全部streamable-http + sse
* node demo/test-reconnect.mjs streamable-http # 只跑 streamable-http
* node demo/test-reconnect.mjs sse # 只跑 sse
*
* 流程(每种协议):
* 1. 启动 demo MCP server
* 2. 启动 mcp-proxy (convert 模式) 连接 server
* 3. 发送 initialize + initialized + tools/list 验证连接
* 4. 等心跳 OK
* 5. SIGKILL 杀掉 server模拟崩溃
* 6. 等 proxy 重试几轮
* 7. 重启 server
* 8. 验证 proxy 自动 re-initialize + 心跳恢复 + tools/list 正常
*/
import { fork, spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
// ---- 配置 ----
const MODES = {
'streamable-http': {
serverScript: join(__dirname, 'streamable-http-server.mjs'),
port: 18080,
url: 'http://127.0.0.1:18080/mcp',
protocol: 'stream',
},
'sse': {
serverScript: join(__dirname, 'sse-server.mjs'),
port: 18081,
url: 'http://127.0.0.1:18081/sse',
protocol: 'sse',
},
};
// ---- 工具函数 ----
function startServer(config) {
const p = fork(config.serverScript, [String(config.port)], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
cwd: ROOT,
});
p.stdout.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
console.log(` [SERVER] ${line}`);
}
});
p.stderr.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
console.log(` [SERVER:err] ${line}`);
}
});
return p;
}
function startProxy(config) {
const args = [
join(ROOT, 'dist/index.js'),
'convert',
config.url,
'--protocol', config.protocol,
'--ping-interval', '3000',
'--ping-timeout', '2000',
];
return spawn(process.execPath, args, {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: ROOT,
env: { ...process.env, MCP_PROXY_LOG_FILE: '' },
});
}
function sendJsonrpc(proxy, message) {
proxy.stdin.write(JSON.stringify(message) + '\n');
}
function ts() {
return new Date().toISOString().slice(11, 23);
}
async function killProc(p) {
if (!p || p.killed) return;
p.kill('SIGTERM');
await sleep(300);
try { p.kill('SIGKILL'); } catch {}
}
// ---- 单轮测试 ----
async function runTest(modeName, config) {
console.log(`\n ${'─'.repeat(56)}`);
console.log(` 测试: ${modeName} (${config.url})`);
console.log(` ${'─'.repeat(56)}`);
let serverProcess = null;
let proxyProcess = null;
const proxyLogs = [];
try {
// 1. 启动 server
console.log(` [${ts()}] STEP 1 启动 MCP Server`);
serverProcess = startServer(config);
await sleep(1500);
// 2. 启动 proxy
console.log(` [${ts()}] STEP 2 启动 mcp-proxy (convert)`);
proxyProcess = startProxy(config);
proxyProcess.stderr.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
proxyLogs.push(line);
if (/Connected|Heartbeat|Re-initializ|re-initialized|Closed|Retrying|Connect failed|error/i.test(line)) {
console.log(` [${ts()}] PROXY ${line.replace(/.*\[qiming-mcp-proxy\]\s*/, '')}`);
}
}
});
proxyProcess.stdout.on('data', d => {
for (const line of d.toString().trim().split('\n')) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.result?.tools) {
console.log(` [${ts()}] RESP tools/list -> ${msg.result.tools.length} tools: [${msg.result.tools.map(t => t.name).join(', ')}]`);
} else if (msg.result?.protocolVersion) {
console.log(` [${ts()}] RESP initialize OK -> protocol ${msg.result.protocolVersion}`);
} else if (msg.error) {
console.log(` [${ts()}] RESP ERROR: ${msg.error.message}`);
}
} catch {}
}
});
await sleep(2000);
// 3. initialize 握手
console.log(` [${ts()}] STEP 3 发送 initialize 握手`);
sendJsonrpc(proxyProcess, {
jsonrpc: '2.0', id: 'init-1', method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
await sleep(1000);
sendJsonrpc(proxyProcess, { jsonrpc: '2.0', method: 'notifications/initialized' });
await sleep(500);
// 4. tools/list
console.log(` [${ts()}] STEP 4 发送 tools/list`);
sendJsonrpc(proxyProcess, { jsonrpc: '2.0', id: 'list-1', method: 'tools/list', params: {} });
await sleep(1000);
// 5. 等心跳
console.log(` [${ts()}] STEP 5 等待首次心跳...`);
await sleep(5000);
// 6. 杀 server
console.log(` [${ts()}] STEP 6 SIGKILL 杀掉 server`);
serverProcess.kill('SIGKILL');
serverProcess = null;
// 7. 等重连重试
console.log(` [${ts()}] STEP 7 等待 proxy 重试...`);
await sleep(8000);
// 8. 重启 server
console.log(` [${ts()}] STEP 8 重启 server`);
serverProcess = startServer(config);
await sleep(1500);
// 9. 等 re-initialize + 心跳恢复
console.log(` [${ts()}] STEP 9 等待 re-initialize + 心跳恢复...`);
await sleep(12000);
// 10. 验证恢复
console.log(` [${ts()}] STEP 10 发送 tools/list 验证恢复`);
sendJsonrpc(proxyProcess, { jsonrpc: '2.0', id: 'list-2', method: 'tools/list', params: {} });
await sleep(2000);
// ---- 分析 ----
const checks = {
reconnect: proxyLogs.some(l => /Retrying|Closed/i.test(l)),
reInitSent: proxyLogs.some(l => l.includes('Invoking reconnect handler') || l.includes('Reconnect handler')),
reInitOk: proxyLogs.some(l => l.includes('Reconnect handler completed')) || proxyLogs.some(l => l.includes('Connected') && l.includes('StreamableHTTP')),
heartbeatOk: proxyLogs.filter(l => l.includes('Heartbeat OK') || l.includes('💖 Health check OK')).length >= 1,
};
return checks;
} finally {
await killProc(proxyProcess);
await killProc(serverProcess);
await sleep(500);
}
}
// ---- 主流程 ----
const arg = process.argv[2];
const modesToRun = arg ? [arg] : Object.keys(MODES);
for (const m of modesToRun) {
if (!MODES[m]) {
console.error(`\n 未知模式: ${m} (可选: ${Object.keys(MODES).join(', ')})`);
process.exit(1);
}
}
console.log(`\n${'='.repeat(60)}`);
console.log(` MCP Resilient Transport 重连测试`);
console.log(` 模式: ${modesToRun.join(', ')}`);
console.log(`${'='.repeat(60)}`);
const results = {};
for (const m of modesToRun) {
results[m] = await runTest(m, MODES[m]);
}
// ---- 汇总表格 ----
const labels = {
reconnect: '重连触发',
reInitSent: 'Re-init 发送',
reInitOk: 'Re-init 成功',
heartbeatOk: '心跳恢复',
};
const allPass = modesToRun.every(m => Object.values(results[m]).every(Boolean));
// 计算列宽: 第一列=检查项, 后续列=每个 mode
const col0W = 14; // "Re-init 发送" 占 12 中文宽度,留余量
const colW = modesToRun.map(m => Math.max(m.length, 7)); // 至少 "✅ PASS".length
const totalW = col0W + colW.reduce((a, b) => a + b + 3, 0) + 1; // +3 " | ", +1 边框
const pad = (s, w) => {
// 中文字符算 2 宽度
const vis = [...s].reduce((n, c) => n + (c.charCodeAt(0) > 0x7f ? 2 : 1), 0);
return s + ' '.repeat(Math.max(0, w - vis));
};
const hline = (ch = '─', joint = '┼') => {
const segs = [ch.repeat(col0W + 2), ...colW.map(w => ch.repeat(w + 2))];
return `${segs.join(joint)}`;
};
const topline = () => {
const segs = [' '.repeat(col0W + 2), ...colW.map(w => '─'.repeat(w + 2))];
return `${segs.join('┬')}`;
};
const botline = () => {
const segs = [' '.repeat(col0W + 2), ...colW.map(w => '─'.repeat(w + 2))];
return `${segs.join('┴')}`;
};
console.log(`\n 测试结果汇总\n`);
// 表头
console.log(` ${topline()}`);
const headerCols = modesToRun.map((m, i) => ` ${pad(m, colW[i])} `);
console.log(`${pad('检查项', col0W)}${headerCols.join('│')}`);
console.log(` ${hline()}`);
// 数据行
for (const [key, label] of Object.entries(labels)) {
const dataCols = modesToRun.map((m, i) => {
const v = results[m][key] ? '✅ PASS' : '❌ FAIL';
return ` ${pad(v, colW[i])} `;
});
console.log(`${pad(label, col0W)}${dataCols.join('│')}`);
}
console.log(` ${botline()}`);
console.log(`\n 总结: ${allPass ? '✅ 全部通过!' : '❌ 存在失败项'}\n`);
process.exit(allPass ? 0 : 1);

View File

@@ -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 秒)。

File diff suppressed because it is too large Load Diff

View 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"
}

View 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})`);

View 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 错误后不再 throwPromise.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);
});
}
}

View 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';

View 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;
}

View 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(() => {});
}

View 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';
}
}

View 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;
}

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

View 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';

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

View 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 */ }
});
}

View 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();
});
}

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

View 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();
}
}
}

View File

@@ -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;
}

View 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';

View File

@@ -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 };
}

View File

@@ -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'));
}

View File

@@ -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;
}

View File

@@ -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;
}

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

View File

@@ -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;
}

View 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>;
}

View 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`);
}
}

View File

@@ -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'; // 临时 tokenconst 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,
});
});
});

View 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
* 即使同时存在多个 sessionstop() 也能正确完成并清理全部 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();
}
});
});

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

View File

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

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

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

View File

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

View File

@@ -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();
});
});
});

View 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();
});
});

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

View File

@@ -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',
});
});
});

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

View 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/**/*"]
}

View 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'],
},
},
});