nuwax-mcp-stdio-proxy 1.4.5 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,37 +1,64 @@
1
1
  # nuwax-mcp-stdio-proxy
2
2
 
3
- TypeScript stdio MCP proxy — aggregates multiple MCP servers into a single stdio endpoint. Supports **stdio** (spawn child processes) and **bridge** (Streamable HTTP to a persistent MCP bridge).
3
+ 一个纯 TypeScript 编写的 MCP (Model Context Protocol) 代理工具,为 MCP Server 提供高级聚合、协议转换以及生命周期管理功能。
4
4
 
5
- ## Requirements
5
+ 它的设计初衷,是为了解决将 MCP Server 集成到大型应用或 Agent OS 平台时遇到的“启动时序竞争”及“应用退出后产生僵尸进程”等痛点问题。
6
+
7
+ ## 环境要求
6
8
 
7
9
  - **Node.js** >= 22.0.0
8
10
 
9
- ## Usage
11
+ ## 运行模式
12
+
13
+ 该代理工具具备三种截然不同的工作模式:
14
+
15
+ ### 1. Stdio 聚合模式 (默认)
16
+
17
+ 将多个基于不同协议上游 MCP Server 聚合成单个面向下游的 `stdio` 节点。
10
18
 
11
19
  ```bash
12
20
  nuwax-mcp-stdio-proxy --config '{"mcpServers":{...}}'
13
21
  ```
14
22
 
15
- The proxy reads JSON from `--config` and runs as a stdio MCP server. Upstream servers can be:
23
+ 配置中可以混合配置 `stdio` (子进程) `bridge` (HTTP 连接) 类型的上游服务器。代理会统一将它们聚合并向客户端暴露唯一的一个 `stdio` MCP 交互接口。
16
24
 
17
- - **stdio**: `{ "command", "args?", "env?" }` — proxy spawns a child process and talks MCP over stdin/stdout.
18
- - **bridge**: `{ "url" }` — proxy connects via HTTP (Streamable HTTP) to a long-lived MCP bridge (e.g. Electron app’s PersistentMcpBridge).
25
+ ### 2. 协议转换模式 (`convert`)
19
26
 
20
- ## Config format
27
+ 将单个远程的 SSE 或 Streamable HTTP MCP Server 代理转化为本地的 `stdio` MCP Server。适用于仅支持 `stdio` 接入的客户端应用。
21
28
 
22
- | Entry type | Shape | Description |
23
- |------------|--------|-------------|
24
- | **stdio** | `{ "command": string, "args"?: string[], "env"?: Record<string, string> }` | Spawn subprocess; MCP over stdio. |
25
- | **bridge** | `{ "url": string }` | Connect to MCP over HTTP (e.g. `http://127.0.0.1:PORT/mcp/<serverId>`). |
29
+ ```bash
30
+ nuwax-mcp-stdio-proxy convert http://example.com/mcp/sse --protocol sse
31
+ ```
26
32
 
27
- Example:
33
+ ### 3. 持久化 HTTP 桥接模式 (`proxy`)
34
+
35
+ 作为 Streamable HTTP Server 启动 `PersistentMcpBridge`。该模式会预先构建并管理标准的 `stdio` 子进程,进而将它们通过高速、可秒连的 HTTP 接口暴露给下游使用。
36
+
37
+ ```bash
38
+ nuwax-mcp-stdio-proxy proxy --port 18099 --config '{"mcpServers":{...}}'
39
+ ```
40
+
41
+ ## 配置文件格式
42
+
43
+ 在使用默认聚合模式或 `proxy` 模式时,使用如下结构的 JSON 配置:
44
+
45
+ | 节点类型 | 配置结构 | 描述 |
46
+ | ---------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
47
+ | **stdio** | `{ "command": string, "args"?: string[], "env"?: Record<string, string> }` | 创建子进程;通过 stdio 进行 MCP 通信。 |
48
+ | **bridge** | `{ "url": string }` | 通过 HTTP 建立 MCP 连接 (例如 `http://127.0.0.1:PORT/mcp/<serverId>`)。 |
49
+
50
+ **配置示例:**
28
51
 
29
52
  ```json
30
53
  {
31
54
  "mcpServers": {
32
55
  "filesystem": {
33
56
  "command": "npx",
34
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed"]
57
+ "args": [
58
+ "-y",
59
+ "@modelcontextprotocol/server-filesystem",
60
+ "/path/to/allowed"
61
+ ]
35
62
  },
36
63
  "chrome-devtools": {
37
64
  "url": "http://127.0.0.1:57278/mcp/chrome-devtools"
@@ -40,31 +67,27 @@ Example:
40
67
  }
41
68
  ```
42
69
 
43
- - `filesystem` → stdio (child process).
44
- - `chrome-devtools` → bridge (HTTP to a persistent bridge).
45
-
46
- ## Architecture
70
+ ## 架构简图
47
71
 
48
72
  ```
49
- Agent / ACP engine (stdin/stdout)
73
+ Agent / ACP 引擎客户端 (stdin/stdout)
50
74
 
51
- nuwax-mcp-stdio-proxy (StdioServerTransport)
52
- ├→ stdio upstreamchild process (StdioClientTransport)
53
- └→ bridge upstream → StreamableHTTPClientTransport → PersistentMcpBridge HTTP
75
+ nuwax-mcp-stdio-proxy (Stdio 模式)
76
+ ├→ [stdio 上游]Spawn 启动子进程 (StdioClientTransport)
77
+ └→ [bridge 上游] → StreamableHTTPClientTransport → 连接 PersistentMcpBridge HTTP
54
78
  ```
55
79
 
56
- - **Downstream**: one stdio MCP server; the agent talks to the proxy over stdin/stdout.
57
- - **Upstream**: multiple backends — some are child processes (stdio), some are HTTP bridge endpoints. Tools from all upstreams are aggregated and exposed as one tool list.
80
+ 通过引入 `proxy` 模式开启 `PersistentMcpBridge`,上层的应用可以剥离 MCP Server 的原本子进程生命周期,从而避免启动初期的时序竞争条件,同时能够更加可靠、整洁地在退出时销毁无用进程。
58
81
 
59
- ## Scripts
82
+ ## 开发指令
60
83
 
61
- | Command | Description |
62
- |---------|-------------|
63
- | `npm run build` | Compile TypeScript to `dist/`. |
64
- | `npm run test` | Run tests (Vitest). |
65
- | `npm run test:run` | Run tests once (no watch). |
66
- | `npm run test:coverage` | Run tests with coverage. |
84
+ | 指令 | 描述 |
85
+ | ----------------------- | ------------------------------------------------------------ |
86
+ | `npm run build` | 编译 TypeScript 输出到 `dist/` 目录。 |
87
+ | `npm run test` | 运行测试 (Vitest)|
88
+ | `npm run test:run` | 单次运行所有的单元与集成测试 (无监控交互)|
89
+ | `npm run test:coverage` | 运行测试并生成详细的覆盖率统计报告 (存于 `coverage/` 目录)。 |
67
90
 
68
- ## License
91
+ ## 许可证
69
92
 
70
93
  MIT
package/dist/bridge.js CHANGED
@@ -20,7 +20,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
20
20
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
21
21
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
22
22
  import { CustomStdioClientTransport } from './customStdio.js';
23
- const LOG_TAG = '[PersistentMcpBridge]';
23
+ import { ResilientTransportWrapper } from './resilient.js';
24
+ const LOG_TAG = '[McpProxy] [PersistentMcpBridge]';
24
25
  const BASE_RESTART_COOLDOWN_MS = 5_000;
25
26
  const MAX_RESTART_ATTEMPTS = 5;
26
27
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
@@ -49,17 +50,36 @@ export class PersistentMcpBridge {
49
50
  this.log.warn(`${LOG_TAG} Already running, stopping first`);
50
51
  await this.stop();
51
52
  }
52
- this.log.info(`${LOG_TAG} Starting with ${Object.keys(servers).length} persistent servers`);
53
- // 1. Spawn each persistent server + create MCP Client
54
- for (const [id, config] of Object.entries(servers)) {
55
- await this.startServer(id, config);
53
+ const serverCount = Object.keys(servers).length;
54
+ this.log.info(`${LOG_TAG} Starting with ${serverCount} persistent servers (parallel)`);
55
+ // 1. 并行启动 HTTP server 和所有 MCP server 子进程。
56
+ // 两者完全独立:HTTP server 在请求时才查询 this.servers,
57
+ // startServer 会在异步 spawnAndConnect 前先同步写入 this.servers,
58
+ // 所以 HTTP server 收到第一个请求时所有 entry 都已注册。
59
+ //
60
+ // 提前保存 serverPromises 引用:若 startHttpServer 抛出,在 catch 中先
61
+ // await Promise.allSettled(serverPromises) 等所有 spawnAndConnect 跑完
62
+ // (保证 entry.client / entry.transport 已赋值),再调用 stopServer,
63
+ // 才能可靠关闭子进程,防止孤儿进程残留。
64
+ const serverPromises = Object.entries(servers).map(([id, config]) => this.startServer(id, config));
65
+ try {
66
+ await Promise.all([
67
+ this.startHttpServer(options?.port),
68
+ ...serverPromises,
69
+ ]);
70
+ }
71
+ catch (e) {
72
+ this.log.error(`${LOG_TAG} 启动失败,清理已 spawn 的子进程:`, e);
73
+ // 等待所有 spawnAndConnect 完成,确保 entry.client/transport 已赋值后再清理
74
+ await Promise.allSettled(serverPromises);
75
+ await Promise.all(Array.from(this.servers.keys()).map((id) => this.stopServer(id)));
76
+ this.servers.clear();
77
+ throw e;
56
78
  }
57
- // 2. Start HTTP server
58
- await this.startHttpServer(options?.port);
59
79
  this.running = true;
60
- // 3. Start periodic session cleanup
80
+ // 2. Start periodic session cleanup
61
81
  this.sessionCleanupTimer = setInterval(() => this.cleanupStaleSessions(), SESSION_CLEANUP_INTERVAL_MS);
62
- this.log.info(`${LOG_TAG} Bridge ready on port ${this.port}`);
82
+ this.log.info(`${LOG_TAG} Bridge ready on port ${this.port} (${serverCount} servers)`);
63
83
  }
64
84
  /**
65
85
  * Stop bridge: close HTTP, kill all child processes
@@ -72,15 +92,16 @@ export class PersistentMcpBridge {
72
92
  clearInterval(this.sessionCleanupTimer);
73
93
  this.sessionCleanupTimer = null;
74
94
  }
75
- // Close all HTTP sessions
76
- for (const [key, session] of this.httpSessions) {
95
+ // 并行关闭所有 HTTP sessions(各 session transport 独立,无顺序依赖)。
96
+ // 内层 async 函数自行 catch 错误后不再 throw,Promise.all 足够,不需要 allSettled。
97
+ await Promise.all(Array.from(this.httpSessions.entries()).map(async ([key, session]) => {
77
98
  try {
78
99
  await session.transport.close();
79
100
  }
80
101
  catch (e) {
81
102
  this.log.warn(`${LOG_TAG} Error closing HTTP session ${key}:`, e);
82
103
  }
83
- }
104
+ }));
84
105
  this.httpSessions.clear();
85
106
  // Close HTTP server
86
107
  if (this.httpServer) {
@@ -90,10 +111,8 @@ export class PersistentMcpBridge {
90
111
  this.httpServer = null;
91
112
  this.port = 0;
92
113
  }
93
- // Stop all persistent servers
94
- for (const [id] of this.servers) {
95
- await this.stopServer(id);
96
- }
114
+ // 并行关闭所有 MCP server 子进程(各自独立,无顺序依赖)
115
+ await Promise.all(Array.from(this.servers.keys()).map((id) => this.stopServer(id)));
97
116
  this.servers.clear();
98
117
  this.log.info(`${LOG_TAG} Stopped`);
99
118
  }
@@ -138,38 +157,50 @@ export class PersistentMcpBridge {
138
157
  async spawnAndConnect(id, entry) {
139
158
  try {
140
159
  this.log.info(`${LOG_TAG} Spawning server "${id}": ${entry.config.command} ${(entry.config.args || []).join(' ')}`);
141
- // Create MCP Client + CustomStdioClientTransport (handles spawn internally)
142
- const transport = new CustomStdioClientTransport({
143
- command: entry.config.command,
144
- args: entry.config.args || [],
145
- env: entry.config.env,
146
- stderr: 'pipe',
160
+ // Create MCP Client + ResilientTransportWrapper
161
+ const wrapper = new ResilientTransportWrapper({
162
+ name: id,
163
+ logger: this.log,
164
+ connectParams: async () => {
165
+ const t = new CustomStdioClientTransport({
166
+ command: entry.config.command,
167
+ args: entry.config.args || [],
168
+ env: entry.config.env,
169
+ stderr: 'pipe',
170
+ });
171
+ if (t.stderr) {
172
+ t.stderr.on('data', (chunk) => {
173
+ const text = chunk.toString().trim();
174
+ if (text)
175
+ this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
176
+ });
177
+ }
178
+ return t;
179
+ },
180
+ pingIntervalMs: entry.config.pingIntervalMs,
181
+ pingTimeoutMs: entry.config.pingTimeoutMs,
147
182
  });
148
183
  const client = new Client({ name: 'nuwax-persistent-bridge', version: '1.0.0' }, { capabilities: {} });
149
184
  // Handle transport close → auto restart
150
- transport.onclose = () => {
185
+ wrapper.onclose = () => {
151
186
  this.log.warn(`${LOG_TAG} Server "${id}" transport closed`);
152
187
  entry.healthy = false;
153
188
  if (this.running && !entry.restarting) {
154
189
  this.scheduleRestart(id, entry);
155
190
  }
156
191
  };
157
- transport.onerror = (err) => {
192
+ wrapper.onerror = (err) => {
158
193
  this.log.error(`${LOG_TAG} Server "${id}" transport error:`, err.message);
159
194
  };
160
195
  // Connect client to transport (this starts the subprocess)
161
- await client.connect(transport);
196
+ await wrapper.start();
197
+ await client.connect(wrapper);
198
+ // Enable heartbeat monitoring AFTER the MCP initialize handshake
199
+ // completes — sending pings before initialize causes "Server not
200
+ // initialized" errors from the MCP server.
201
+ wrapper.enableHeartbeat();
162
202
  entry.client = client;
163
- entry.transport = transport;
164
- // Pipe stderr for debugging
165
- const stderrStream = transport.stderr;
166
- if (stderrStream) {
167
- stderrStream.on('data', (chunk) => {
168
- const text = chunk.toString().trim();
169
- if (text)
170
- this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
171
- });
172
- }
203
+ entry.transport = wrapper;
173
204
  // List tools (cached)
174
205
  const result = await client.listTools();
175
206
  entry.tools = result.tools;