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 +54 -31
- package/dist/bridge.js +66 -35
- package/dist/index.js +441 -63
- package/dist/modes/convert.d.ts +2 -0
- package/dist/modes/convert.js +2 -2
- package/dist/resilient.d.ts +80 -0
- package/dist/resilient.js +297 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +101 -22
- package/dist/types.d.ts +12 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,37 +1,64 @@
|
|
|
1
1
|
# nuwax-mcp-stdio-proxy
|
|
2
2
|
|
|
3
|
-
TypeScript
|
|
3
|
+
一个纯 TypeScript 编写的 MCP (Model Context Protocol) 代理工具,为 MCP Server 提供高级聚合、协议转换以及生命周期管理功能。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
它的设计初衷,是为了解决将 MCP Server 集成到大型应用或 Agent OS 平台时遇到的“启动时序竞争”及“应用退出后产生僵尸进程”等痛点问题。
|
|
6
|
+
|
|
7
|
+
## 环境要求
|
|
6
8
|
|
|
7
9
|
- **Node.js** >= 22.0.0
|
|
8
10
|
|
|
9
|
-
##
|
|
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
|
-
|
|
23
|
+
配置中可以混合配置 `stdio` (子进程) 和 `bridge` (HTTP 连接) 类型的上游服务器。代理会统一将它们聚合并向客户端暴露唯一的一个 `stdio` MCP 交互接口。
|
|
16
24
|
|
|
17
|
-
|
|
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
|
-
|
|
27
|
+
将单个远程的 SSE 或 Streamable HTTP MCP Server 代理转化为本地的 `stdio` MCP Server。适用于仅支持 `stdio` 接入的客户端应用。
|
|
21
28
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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": [
|
|
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
|
-
|
|
44
|
-
- `chrome-devtools` → bridge (HTTP to a persistent bridge).
|
|
45
|
-
|
|
46
|
-
## Architecture
|
|
70
|
+
## 架构简图
|
|
47
71
|
|
|
48
72
|
```
|
|
49
|
-
Agent / ACP
|
|
73
|
+
Agent / ACP 引擎客户端 (stdin/stdout)
|
|
50
74
|
↕
|
|
51
|
-
nuwax-mcp-stdio-proxy (
|
|
52
|
-
├→ stdio
|
|
53
|
-
└→ bridge
|
|
75
|
+
nuwax-mcp-stdio-proxy (Stdio 模式)
|
|
76
|
+
├→ [stdio 上游] → Spawn 启动子进程 (StdioClientTransport)
|
|
77
|
+
└→ [bridge 上游] → StreamableHTTPClientTransport → 连接 PersistentMcpBridge HTTP
|
|
54
78
|
```
|
|
55
79
|
|
|
56
|
-
|
|
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
|
-
##
|
|
82
|
+
## 开发指令
|
|
60
83
|
|
|
61
|
-
|
|
|
62
|
-
|
|
63
|
-
| `npm run build`
|
|
64
|
-
| `npm run test`
|
|
65
|
-
| `npm run test:run`
|
|
66
|
-
| `npm run test: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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
76
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
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 +
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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;
|