nuwax-mcp-stdio-proxy 1.4.6 → 1.4.8

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
@@ -156,38 +157,45 @@ export class PersistentMcpBridge {
156
157
  async spawnAndConnect(id, entry) {
157
158
  try {
158
159
  this.log.info(`${LOG_TAG} Spawning server "${id}": ${entry.config.command} ${(entry.config.args || []).join(' ')}`);
159
- // Create MCP Client + CustomStdioClientTransport (handles spawn internally)
160
- const transport = new CustomStdioClientTransport({
161
- command: entry.config.command,
162
- args: entry.config.args || [],
163
- env: entry.config.env,
164
- 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: 0, // No heartbeat for stdio — child process close/error events handle detection
165
181
  });
166
182
  const client = new Client({ name: 'nuwax-persistent-bridge', version: '1.0.0' }, { capabilities: {} });
167
183
  // Handle transport close → auto restart
168
- transport.onclose = () => {
184
+ wrapper.onclose = () => {
169
185
  this.log.warn(`${LOG_TAG} Server "${id}" transport closed`);
170
186
  entry.healthy = false;
171
187
  if (this.running && !entry.restarting) {
172
188
  this.scheduleRestart(id, entry);
173
189
  }
174
190
  };
175
- transport.onerror = (err) => {
191
+ wrapper.onerror = (err) => {
176
192
  this.log.error(`${LOG_TAG} Server "${id}" transport error:`, err.message);
177
193
  };
178
194
  // Connect client to transport (this starts the subprocess)
179
- await client.connect(transport);
195
+ await wrapper.start();
196
+ await client.connect(wrapper);
180
197
  entry.client = client;
181
- entry.transport = transport;
182
- // Pipe stderr for debugging
183
- const stderrStream = transport.stderr;
184
- if (stderrStream) {
185
- stderrStream.on('data', (chunk) => {
186
- const text = chunk.toString().trim();
187
- if (text)
188
- this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
189
- });
190
- }
198
+ entry.transport = wrapper;
191
199
  // List tools (cached)
192
200
  const result = await client.listTools();
193
201
  entry.tools = result.tools;
package/dist/detect.d.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  /**
2
2
  * Protocol auto-detection — determine whether a URL serves Streamable HTTP or SSE
3
+ *
4
+ * Aligned with workspace/mcp-proxy (Rust) detection logic:
5
+ * - Only probe Streamable HTTP (POST initialize)
6
+ * - Default to SSE if probe fails
3
7
  */
4
8
  /**
5
9
  * Detect the MCP transport protocol of a remote URL.
6
10
  *
7
- * Strategy:
8
- * 1. Try Streamable HTTP first: send a JSON-RPC initialize POST.
9
- * If the server responds with 200 and JSON, it's streamable-http.
10
- * Then clean up the orphan session via DELETE.
11
- * 2. If that fails, try SSE: send a GET and check for text/event-stream content-type.
12
- * 3. Default to 'stream' (Streamable HTTP) if both probes fail.
11
+ * Strategy (matches Rust mcp-proxy):
12
+ * 1. Send a JSON-RPC initialize POST to probe for Streamable HTTP.
13
+ * 2. Check 4 criteria any match means streamable-http:
14
+ * a. Response has `mcp-session-id` header
15
+ * b. Content-Type is `text/event-stream` with 2xx status
16
+ * c. Response body is valid JSON-RPC 2.0
17
+ * d. Status is 406 Not Acceptable
18
+ * 3. If probe fails or no criteria match → default to SSE.
13
19
  */
14
20
  export declare function detectProtocol(url: string, headers?: Record<string, string>): Promise<'sse' | 'stream'>;
package/dist/detect.js CHANGED
@@ -1,20 +1,25 @@
1
1
  /**
2
2
  * Protocol auto-detection — determine whether a URL serves Streamable HTTP or SSE
3
+ *
4
+ * Aligned with workspace/mcp-proxy (Rust) detection logic:
5
+ * - Only probe Streamable HTTP (POST initialize)
6
+ * - Default to SSE if probe fails
3
7
  */
4
8
  import { logInfo, logWarn } from './logger.js';
5
9
  /**
6
10
  * Detect the MCP transport protocol of a remote URL.
7
11
  *
8
- * Strategy:
9
- * 1. Try Streamable HTTP first: send a JSON-RPC initialize POST.
10
- * If the server responds with 200 and JSON, it's streamable-http.
11
- * Then clean up the orphan session via DELETE.
12
- * 2. If that fails, try SSE: send a GET and check for text/event-stream content-type.
13
- * 3. Default to 'stream' (Streamable HTTP) if both probes fail.
12
+ * Strategy (matches Rust mcp-proxy):
13
+ * 1. Send a JSON-RPC initialize POST to probe for Streamable HTTP.
14
+ * 2. Check 4 criteria any match means streamable-http:
15
+ * a. Response has `mcp-session-id` header
16
+ * b. Content-Type is `text/event-stream` with 2xx status
17
+ * c. Response body is valid JSON-RPC 2.0
18
+ * d. Status is 406 Not Acceptable
19
+ * 3. If probe fails or no criteria match → default to SSE.
14
20
  */
15
21
  export async function detectProtocol(url, headers) {
16
22
  logInfo(`Auto-detecting protocol for ${url}...`);
17
- // 1. Try Streamable HTTP — POST a JSON-RPC initialize request
18
23
  try {
19
24
  const reqHeaders = {
20
25
  'Content-Type': 'application/json',
@@ -22,7 +27,7 @@ export async function detectProtocol(url, headers) {
22
27
  ...headers,
23
28
  };
24
29
  const controller = new AbortController();
25
- const timeout = setTimeout(() => controller.abort(), 10_000);
30
+ const timeout = setTimeout(() => controller.abort(), 5_000);
26
31
  const res = await fetch(url, {
27
32
  method: 'POST',
28
33
  headers: reqHeaders,
@@ -39,57 +44,61 @@ export async function detectProtocol(url, headers) {
39
44
  signal: controller.signal,
40
45
  });
41
46
  clearTimeout(timeout);
42
- const ct = res.headers.get('content-type') || '';
43
- if (res.ok && (ct.includes('application/json') || ct.includes('text/event-stream'))) {
44
- logInfo(`Detected streamable-http protocol for ${url}`);
45
- // Consume body to avoid socket hang
47
+ // Check 1: mcp-session-id header (definitive Streamable HTTP marker)
48
+ if (res.headers.get('mcp-session-id')) {
49
+ logInfo(`Detected streamable-http protocol for ${url} (mcp-session-id header)`);
50
+ cleanupSession(url, res.headers.get('mcp-session-id'), headers);
46
51
  await res.text().catch(() => { });
47
- // Clean up orphan session — fire-and-forget DELETE so the server
48
- // can discard the half-initialized session we created during probing.
49
- // Not awaited: cleanup is best-effort and must not block detection.
50
- const sessionId = res.headers.get('mcp-session-id');
51
- if (sessionId) {
52
- fetch(url, {
53
- method: 'DELETE',
54
- headers: { 'mcp-session-id': sessionId, ...headers },
55
- signal: AbortSignal.timeout(5_000),
56
- }).catch(() => { });
57
- }
58
52
  return 'stream';
59
53
  }
60
- await res.text().catch(() => { });
61
- }
62
- catch {
63
- // Streamable HTTP probe failed, try SSE
64
- }
65
- // 2. Try SSE — GET and check for event-stream
66
- try {
67
- const reqHeaders = {
68
- Accept: 'text/event-stream',
69
- ...headers,
70
- };
71
- const controller = new AbortController();
72
- const timeout = setTimeout(() => controller.abort(), 10_000);
73
- const res = await fetch(url, {
74
- method: 'GET',
75
- headers: reqHeaders,
76
- signal: controller.signal,
77
- });
78
54
  const ct = res.headers.get('content-type') || '';
79
- if (ct.includes('text/event-stream')) {
80
- // Detected SSE — abort the stream before clearing the timeout
81
- clearTimeout(timeout);
82
- logInfo(`Detected SSE protocol for ${url}`);
55
+ // Check 2: text/event-stream content-type with success status
56
+ if (ct.includes('text/event-stream') && res.ok) {
57
+ logInfo(`Detected streamable-http protocol for ${url} (event-stream response)`);
58
+ cleanupSession(url, res.headers.get('mcp-session-id'), headers);
59
+ // Abort the stream to free the connection
83
60
  controller.abort();
84
- return 'sse';
61
+ return 'stream';
62
+ }
63
+ // Read body for JSON-RPC check
64
+ let bodyText = '';
65
+ try {
66
+ bodyText = await res.text();
67
+ }
68
+ catch { /* ignore */ }
69
+ // Check 3: valid JSON-RPC 2.0 response
70
+ try {
71
+ const json = JSON.parse(bodyText);
72
+ if (json && json.jsonrpc === '2.0') {
73
+ logInfo(`Detected streamable-http protocol for ${url} (JSON-RPC 2.0 response)`);
74
+ cleanupSession(url, res.headers.get('mcp-session-id'), headers);
75
+ return 'stream';
76
+ }
77
+ }
78
+ catch { /* not JSON */ }
79
+ // Check 4: 406 Not Acceptable (may indicate Streamable HTTP)
80
+ if (res.status === 406) {
81
+ logInfo(`Detected streamable-http protocol for ${url} (406 Not Acceptable)`);
82
+ return 'stream';
85
83
  }
86
- clearTimeout(timeout);
87
- await res.text().catch(() => { });
88
84
  }
89
85
  catch {
90
- // SSE probe failed
86
+ // Probe failed (timeout, connection refused, etc.)
91
87
  }
92
- // 3. Default to streamable-http
93
- logWarn(`Could not auto-detect protocol for ${url}, defaulting to streamable-http`);
94
- return 'stream';
88
+ // Default to SSE (matches Rust mcp-proxy behavior)
89
+ logWarn(`Could not detect streamable-http for ${url}, defaulting to SSE`);
90
+ return 'sse';
91
+ }
92
+ /**
93
+ * Clean up orphan session — fire-and-forget DELETE so the server
94
+ * can discard the half-initialized session we created during probing.
95
+ */
96
+ function cleanupSession(url, sessionId, headers) {
97
+ if (!sessionId)
98
+ return;
99
+ fetch(url, {
100
+ method: 'DELETE',
101
+ headers: { 'mcp-session-id': sessionId, ...headers },
102
+ signal: AbortSignal.timeout(5_000),
103
+ }).catch(() => { });
95
104
  }