nuwax-mcp-stdio-proxy 1.2.1 → 1.3.1

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.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * PersistentMcpBridge — long-lived MCP server bridge
3
+ *
4
+ * Manages persistent MCP servers (e.g. chrome-devtools-mcp) that outlive
5
+ * individual ACP sessions. Spawns child processes via CustomStdioClientTransport
6
+ * (with Windows fixes) and exposes them over HTTP for downstream consumers.
7
+ *
8
+ * Architecture:
9
+ * persistent child process (stdio)
10
+ * ↕ CustomStdioClientTransport
11
+ * MCP Client (cached tools, proxies callTool)
12
+ * ↕
13
+ * HTTP Server (single port, path routing /mcp/<serverId>)
14
+ * ↕ StreamableHTTPServerTransport (per HTTP session)
15
+ * MCP Server (tool handlers → Client.callTool)
16
+ */
17
+ import type { StdioServerEntry } from './types.js';
18
+ export interface BridgeLogger {
19
+ info: (...args: unknown[]) => void;
20
+ warn: (...args: unknown[]) => void;
21
+ error: (...args: unknown[]) => void;
22
+ }
23
+ export declare class PersistentMcpBridge {
24
+ private log;
25
+ private httpServer;
26
+ private port;
27
+ private servers;
28
+ /** "serverId:sessionId" → HttpSession */
29
+ private httpSessions;
30
+ private running;
31
+ private sessionCleanupTimer;
32
+ constructor(logger?: BridgeLogger);
33
+ /**
34
+ * Start bridge: spawn child processes for each persistent server and create HTTP server
35
+ */
36
+ start(servers: Record<string, StdioServerEntry>): Promise<void>;
37
+ /**
38
+ * Stop bridge: close HTTP, kill all child processes
39
+ */
40
+ stop(): Promise<void>;
41
+ /**
42
+ * Get bridge URL for a server (for bridge clients to connect)
43
+ */
44
+ getBridgeUrl(serverId: string): string | null;
45
+ /**
46
+ * Whether the bridge is running
47
+ */
48
+ isRunning(): boolean;
49
+ /**
50
+ * Health check for a specific server
51
+ */
52
+ isServerHealthy(serverId: string): boolean;
53
+ private startServer;
54
+ private spawnAndConnect;
55
+ private scheduleRestart;
56
+ private stopServer;
57
+ private startHttpServer;
58
+ private handleHttpRequest;
59
+ private createHttpSession;
60
+ /**
61
+ * Remove sessions whose transport has been closed but not cleaned up
62
+ */
63
+ private cleanupStaleSessions;
64
+ private readBody;
65
+ }
package/dist/bridge.js ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * PersistentMcpBridge — long-lived MCP server bridge
3
+ *
4
+ * Manages persistent MCP servers (e.g. chrome-devtools-mcp) that outlive
5
+ * individual ACP sessions. Spawns child processes via CustomStdioClientTransport
6
+ * (with Windows fixes) and exposes them over HTTP for downstream consumers.
7
+ *
8
+ * Architecture:
9
+ * persistent child process (stdio)
10
+ * ↕ CustomStdioClientTransport
11
+ * MCP Client (cached tools, proxies callTool)
12
+ * ↕
13
+ * HTTP Server (single port, path routing /mcp/<serverId>)
14
+ * ↕ StreamableHTTPServerTransport (per HTTP session)
15
+ * MCP Server (tool handlers → Client.callTool)
16
+ */
17
+ import * as http from 'http';
18
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
19
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
20
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
21
+ import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
22
+ import { CustomStdioClientTransport } from './customStdio.js';
23
+ const LOG_TAG = '[PersistentMcpBridge]';
24
+ const BASE_RESTART_COOLDOWN_MS = 5_000;
25
+ const MAX_RESTART_ATTEMPTS = 5;
26
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10 MB
27
+ const SESSION_CLEANUP_INTERVAL_MS = 60_000; // 1 minute
28
+ // ========== PersistentMcpBridge ==========
29
+ export class PersistentMcpBridge {
30
+ log;
31
+ httpServer = null;
32
+ port = 0;
33
+ servers = new Map();
34
+ /** "serverId:sessionId" → HttpSession */
35
+ httpSessions = new Map();
36
+ running = false;
37
+ sessionCleanupTimer = null;
38
+ constructor(logger) {
39
+ this.log = logger ?? console;
40
+ }
41
+ /**
42
+ * Start bridge: spawn child processes for each persistent server and create HTTP server
43
+ */
44
+ async start(servers) {
45
+ if (this.running) {
46
+ this.log.warn(`${LOG_TAG} Already running, stopping first`);
47
+ await this.stop();
48
+ }
49
+ this.log.info(`${LOG_TAG} Starting with ${Object.keys(servers).length} persistent servers`);
50
+ // 1. Spawn each persistent server + create MCP Client
51
+ for (const [id, config] of Object.entries(servers)) {
52
+ await this.startServer(id, config);
53
+ }
54
+ // 2. Start HTTP server
55
+ await this.startHttpServer();
56
+ this.running = true;
57
+ // 3. Start periodic session cleanup
58
+ this.sessionCleanupTimer = setInterval(() => this.cleanupStaleSessions(), SESSION_CLEANUP_INTERVAL_MS);
59
+ this.log.info(`${LOG_TAG} Bridge ready on port ${this.port}`);
60
+ }
61
+ /**
62
+ * Stop bridge: close HTTP, kill all child processes
63
+ */
64
+ async stop() {
65
+ this.log.info(`${LOG_TAG} Stopping...`);
66
+ this.running = false;
67
+ // Stop session cleanup timer
68
+ if (this.sessionCleanupTimer) {
69
+ clearInterval(this.sessionCleanupTimer);
70
+ this.sessionCleanupTimer = null;
71
+ }
72
+ // Close all HTTP sessions
73
+ for (const [key, session] of this.httpSessions) {
74
+ try {
75
+ await session.transport.close();
76
+ }
77
+ catch (e) {
78
+ this.log.warn(`${LOG_TAG} Error closing HTTP session ${key}:`, e);
79
+ }
80
+ }
81
+ this.httpSessions.clear();
82
+ // Close HTTP server
83
+ if (this.httpServer) {
84
+ await new Promise((resolve) => {
85
+ this.httpServer.close(() => resolve());
86
+ });
87
+ this.httpServer = null;
88
+ this.port = 0;
89
+ }
90
+ // Stop all persistent servers
91
+ for (const [id] of this.servers) {
92
+ await this.stopServer(id);
93
+ }
94
+ this.servers.clear();
95
+ this.log.info(`${LOG_TAG} Stopped`);
96
+ }
97
+ /**
98
+ * Get bridge URL for a server (for bridge clients to connect)
99
+ */
100
+ getBridgeUrl(serverId) {
101
+ if (!this.running || !this.port)
102
+ return null;
103
+ const entry = this.servers.get(serverId);
104
+ if (!entry || !entry.healthy)
105
+ return null;
106
+ return `http://127.0.0.1:${this.port}/mcp/${serverId}`;
107
+ }
108
+ /**
109
+ * Whether the bridge is running
110
+ */
111
+ isRunning() {
112
+ return this.running && this.port > 0;
113
+ }
114
+ /**
115
+ * Health check for a specific server
116
+ */
117
+ isServerHealthy(serverId) {
118
+ return this.servers.get(serverId)?.healthy ?? false;
119
+ }
120
+ // ==================== Internal: Server Lifecycle ====================
121
+ async startServer(id, config) {
122
+ const entry = {
123
+ config,
124
+ client: null,
125
+ transport: null,
126
+ tools: [],
127
+ healthy: false,
128
+ restarting: false,
129
+ restartTimer: null,
130
+ restartCount: 0,
131
+ };
132
+ this.servers.set(id, entry);
133
+ await this.spawnAndConnect(id, entry);
134
+ }
135
+ async spawnAndConnect(id, entry) {
136
+ try {
137
+ this.log.info(`${LOG_TAG} Spawning server "${id}": ${entry.config.command} ${(entry.config.args || []).join(' ')}`);
138
+ // Create MCP Client + CustomStdioClientTransport (handles spawn internally)
139
+ const transport = new CustomStdioClientTransport({
140
+ command: entry.config.command,
141
+ args: entry.config.args || [],
142
+ env: entry.config.env,
143
+ stderr: 'pipe',
144
+ });
145
+ const client = new Client({ name: 'nuwax-persistent-bridge', version: '1.0.0' }, { capabilities: {} });
146
+ // Handle transport close → auto restart
147
+ transport.onclose = () => {
148
+ this.log.warn(`${LOG_TAG} Server "${id}" transport closed`);
149
+ entry.healthy = false;
150
+ if (this.running && !entry.restarting) {
151
+ this.scheduleRestart(id, entry);
152
+ }
153
+ };
154
+ transport.onerror = (err) => {
155
+ this.log.error(`${LOG_TAG} Server "${id}" transport error:`, err.message);
156
+ };
157
+ // Connect client to transport (this starts the subprocess)
158
+ await client.connect(transport);
159
+ entry.client = client;
160
+ entry.transport = transport;
161
+ // Pipe stderr for debugging
162
+ const stderrStream = transport.stderr;
163
+ if (stderrStream) {
164
+ stderrStream.on('data', (chunk) => {
165
+ const text = chunk.toString().trim();
166
+ if (text)
167
+ this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
168
+ });
169
+ }
170
+ // List tools (cached)
171
+ const result = await client.listTools();
172
+ entry.tools = result.tools;
173
+ entry.healthy = true;
174
+ entry.restartCount = 0; // reset on success
175
+ this.log.info(`${LOG_TAG} Server "${id}" ready with ${entry.tools.length} tools: ${entry.tools.map((t) => t.name).join(', ')}`);
176
+ }
177
+ catch (e) {
178
+ this.log.error(`${LOG_TAG} Failed to start server "${id}":`, e);
179
+ entry.healthy = false;
180
+ if (this.running && !entry.restarting) {
181
+ this.scheduleRestart(id, entry);
182
+ }
183
+ }
184
+ }
185
+ scheduleRestart(id, entry) {
186
+ if (entry.restartTimer)
187
+ return;
188
+ entry.restartCount++;
189
+ if (entry.restartCount > MAX_RESTART_ATTEMPTS) {
190
+ this.log.warn(`${LOG_TAG} Server "${id}" failed ${entry.restartCount - 1} times, giving up (max ${MAX_RESTART_ATTEMPTS})`);
191
+ entry.restarting = false;
192
+ return;
193
+ }
194
+ entry.restarting = true;
195
+ // Exponential backoff: 5s, 10s, 20s, 40s, 80s
196
+ const delay = BASE_RESTART_COOLDOWN_MS * Math.pow(2, entry.restartCount - 1);
197
+ this.log.info(`${LOG_TAG} Scheduling restart for "${id}" in ${delay}ms (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS})`);
198
+ entry.restartTimer = setTimeout(async () => {
199
+ entry.restartTimer = null;
200
+ entry.restarting = false;
201
+ if (!this.running)
202
+ return;
203
+ // Clean up old resources
204
+ try {
205
+ if (entry.client)
206
+ await entry.client.close();
207
+ }
208
+ catch { /* ignore */ }
209
+ try {
210
+ if (entry.transport)
211
+ await entry.transport.close();
212
+ }
213
+ catch { /* ignore */ }
214
+ entry.client = null;
215
+ entry.transport = null;
216
+ this.log.info(`${LOG_TAG} Restarting server "${id}"... (attempt ${entry.restartCount}/${MAX_RESTART_ATTEMPTS})`);
217
+ await this.spawnAndConnect(id, entry);
218
+ }, delay);
219
+ }
220
+ async stopServer(id) {
221
+ const entry = this.servers.get(id);
222
+ if (!entry)
223
+ return;
224
+ if (entry.restartTimer) {
225
+ clearTimeout(entry.restartTimer);
226
+ entry.restartTimer = null;
227
+ }
228
+ entry.restarting = false;
229
+ entry.healthy = false;
230
+ try {
231
+ if (entry.client)
232
+ await entry.client.close();
233
+ }
234
+ catch (e) {
235
+ this.log.warn(`${LOG_TAG} Error closing client for "${id}":`, e);
236
+ }
237
+ // transport.close() handles graceful shutdown: stdin.end → SIGTERM → SIGKILL
238
+ try {
239
+ if (entry.transport)
240
+ await entry.transport.close();
241
+ }
242
+ catch (e) {
243
+ this.log.warn(`${LOG_TAG} Error closing transport for "${id}":`, e);
244
+ }
245
+ entry.client = null;
246
+ entry.transport = null;
247
+ }
248
+ // ==================== Internal: HTTP Server ====================
249
+ async startHttpServer() {
250
+ return new Promise((resolve, reject) => {
251
+ const server = http.createServer((req, res) => {
252
+ this.handleHttpRequest(req, res).catch((e) => {
253
+ this.log.error(`${LOG_TAG} HTTP request error:`, e);
254
+ if (!res.headersSent) {
255
+ res.writeHead(500, { 'Content-Type': 'application/json' });
256
+ res.end(JSON.stringify({ error: 'Internal server error' }));
257
+ }
258
+ });
259
+ });
260
+ // Listen on random port
261
+ server.listen(0, '127.0.0.1', () => {
262
+ const addr = server.address();
263
+ if (addr && typeof addr === 'object') {
264
+ this.port = addr.port;
265
+ }
266
+ this.httpServer = server;
267
+ this.log.info(`${LOG_TAG} HTTP server listening on 127.0.0.1:${this.port}`);
268
+ resolve();
269
+ });
270
+ server.on('error', (err) => {
271
+ this.log.error(`${LOG_TAG} HTTP server error:`, err);
272
+ reject(err);
273
+ });
274
+ });
275
+ }
276
+ async handleHttpRequest(req, res) {
277
+ const url = new URL(req.url || '/', `http://127.0.0.1:${this.port}`);
278
+ const pathParts = url.pathname.split('/').filter(Boolean);
279
+ // Route: /mcp/<serverId>
280
+ if (pathParts.length !== 2 || pathParts[0] !== 'mcp') {
281
+ res.writeHead(404, { 'Content-Type': 'application/json' });
282
+ res.end(JSON.stringify({ error: 'Not found. Use /mcp/<serverId>' }));
283
+ return;
284
+ }
285
+ const serverId = pathParts[1];
286
+ const serverEntry = this.servers.get(serverId);
287
+ if (!serverEntry || !serverEntry.healthy || !serverEntry.client) {
288
+ res.writeHead(503, { 'Content-Type': 'application/json' });
289
+ res.end(JSON.stringify({ error: `Server "${serverId}" not available` }));
290
+ return;
291
+ }
292
+ // Handle DELETE → terminate session
293
+ if (req.method === 'DELETE') {
294
+ const sessionId = req.headers['mcp-session-id'];
295
+ if (sessionId) {
296
+ const key = `${serverId}:${sessionId}`;
297
+ const session = this.httpSessions.get(key);
298
+ if (session) {
299
+ try {
300
+ await session.transport.close();
301
+ }
302
+ catch { /* ignore */ }
303
+ this.httpSessions.delete(key);
304
+ }
305
+ }
306
+ res.writeHead(200);
307
+ res.end();
308
+ return;
309
+ }
310
+ // POST or GET → route to session
311
+ if (req.method === 'POST' || req.method === 'GET') {
312
+ const sessionId = req.headers['mcp-session-id'];
313
+ // Try to find existing session
314
+ if (sessionId) {
315
+ const key = `${serverId}:${sessionId}`;
316
+ const session = this.httpSessions.get(key);
317
+ if (session) {
318
+ if (req.method === 'POST') {
319
+ const body = await this.readBody(req);
320
+ await session.transport.handleRequest(req, res, body);
321
+ }
322
+ else {
323
+ await session.transport.handleRequest(req, res);
324
+ }
325
+ return;
326
+ }
327
+ }
328
+ // New session: only for POST (initialize request)
329
+ if (req.method === 'POST') {
330
+ const body = await this.readBody(req);
331
+ const session = this.createHttpSession(serverId, serverEntry);
332
+ await session.transport.handleRequest(req, res, body);
333
+ // Register session immediately after first handleRequest (sessionId is now set)
334
+ const sid = session.transport.sessionId;
335
+ if (sid) {
336
+ const key = `${serverId}:${sid}`;
337
+ this.httpSessions.set(key, session);
338
+ this.log.info(`${LOG_TAG} New HTTP session: ${key}`);
339
+ }
340
+ return;
341
+ }
342
+ // GET without session → 400
343
+ res.writeHead(400, { 'Content-Type': 'application/json' });
344
+ res.end(JSON.stringify({ error: 'Missing mcp-session-id header for GET' }));
345
+ return;
346
+ }
347
+ res.writeHead(405);
348
+ res.end();
349
+ }
350
+ createHttpSession(serverId, serverEntry) {
351
+ const transport = new StreamableHTTPServerTransport({
352
+ sessionIdGenerator: () => `${serverId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
353
+ });
354
+ const mcpServer = new Server({ name: `nuwax-bridge-${serverId}`, version: '1.0.0' }, {
355
+ capabilities: {
356
+ tools: {},
357
+ },
358
+ });
359
+ // Register tool handlers that proxy to the persistent Client
360
+ mcpServer.setRequestHandler(ListToolsRequestSchema, async () => {
361
+ return { tools: serverEntry.tools };
362
+ });
363
+ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
364
+ if (!serverEntry.client || !serverEntry.healthy) {
365
+ return {
366
+ content: [{ type: 'text', text: `Server "${serverId}" is not available` }],
367
+ isError: true,
368
+ };
369
+ }
370
+ try {
371
+ const result = await serverEntry.client.callTool({
372
+ name: request.params.name,
373
+ arguments: request.params.arguments,
374
+ });
375
+ return result;
376
+ }
377
+ catch (e) {
378
+ const msg = e instanceof Error ? e.message : String(e);
379
+ return {
380
+ content: [{ type: 'text', text: `Tool call failed: ${msg}` }],
381
+ isError: true,
382
+ };
383
+ }
384
+ });
385
+ // Clean up on close
386
+ transport.onclose = () => {
387
+ const sid = transport.sessionId;
388
+ if (sid) {
389
+ const key = `${serverId}:${sid}`;
390
+ this.httpSessions.delete(key);
391
+ this.log.info(`${LOG_TAG} HTTP session closed: ${key}`);
392
+ }
393
+ };
394
+ // Connect server to transport
395
+ mcpServer.connect(transport).catch((e) => {
396
+ this.log.error(`${LOG_TAG} Failed to connect HTTP session server:`, e);
397
+ });
398
+ return { server: mcpServer, transport };
399
+ }
400
+ /**
401
+ * Remove sessions whose transport has been closed but not cleaned up
402
+ */
403
+ cleanupStaleSessions() {
404
+ let cleaned = 0;
405
+ for (const [key, session] of this.httpSessions) {
406
+ // sessionId becomes undefined after transport.close()
407
+ if (!session.transport.sessionId) {
408
+ this.httpSessions.delete(key);
409
+ cleaned++;
410
+ }
411
+ }
412
+ if (cleaned > 0) {
413
+ this.log.info(`${LOG_TAG} Cleaned up ${cleaned} stale HTTP session(s)`);
414
+ }
415
+ }
416
+ readBody(req) {
417
+ return new Promise((resolve, reject) => {
418
+ const chunks = [];
419
+ let totalSize = 0;
420
+ req.on('data', (chunk) => {
421
+ totalSize += chunk.length;
422
+ if (totalSize > MAX_BODY_SIZE) {
423
+ req.destroy();
424
+ reject(new Error(`Request body exceeds ${MAX_BODY_SIZE} bytes`));
425
+ return;
426
+ }
427
+ chunks.push(chunk);
428
+ });
429
+ req.on('end', () => {
430
+ try {
431
+ const raw = Buffer.concat(chunks).toString('utf-8');
432
+ resolve(JSON.parse(raw));
433
+ }
434
+ catch (e) {
435
+ reject(e);
436
+ }
437
+ });
438
+ req.on('error', reject);
439
+ });
440
+ }
441
+ }
@@ -65,6 +65,11 @@ export class CustomStdioClientTransport {
65
65
  // For .cmd/.bat files on Windows, we need shell: true
66
66
  if (isWindows && (command.toLowerCase().endsWith('.cmd') || command.toLowerCase().endsWith('.bat'))) {
67
67
  useShell = true;
68
+ // Quote the command if it contains spaces, otherwise cmd.exe
69
+ // misparses paths like "D:\Program Files\...\npx.cmd"
70
+ if (command.includes(' ')) {
71
+ command = `"${command}"`;
72
+ }
68
73
  logDebug(`Using shell: true for ${command}`);
69
74
  }
70
75
  this._process = spawn(command, this._serverParams.args ?? [], {
package/dist/index.js CHANGED
@@ -23234,7 +23234,7 @@ async function connectBridge(id, entry) {
23234
23234
 
23235
23235
  // src/index.ts
23236
23236
  var PKG_NAME = "nuwax-mcp-stdio-proxy";
23237
- var PKG_VERSION = "1.2.1";
23237
+ var PKG_VERSION = "1.3.1";
23238
23238
  function parseConfig() {
23239
23239
  const args = process.argv.slice(2);
23240
23240
  const idx = args.indexOf("--config");
package/dist/lib.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Library entry point — re-exports for consumers
3
+ *
4
+ * CLI entry point remains at index.ts (bundled by esbuild).
5
+ * This file provides typed exports for library consumers (e.g. Electron client).
6
+ */
7
+ export { PersistentMcpBridge } from './bridge.js';
8
+ export type { BridgeLogger } from './bridge.js';
9
+ export { CustomStdioClientTransport } from './customStdio.js';
10
+ export type { CustomStdioServerParameters } from './customStdio.js';
11
+ export { buildBaseEnv, connectStdio, connectBridge } from './transport.js';
12
+ export type { ConnectedClient } from './transport.js';
13
+ export type { StdioServerEntry, BridgeServerEntry, McpServerEntry, McpServersConfig } from './types.js';
package/dist/lib.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Library entry point — re-exports for consumers
3
+ *
4
+ * CLI entry point remains at index.ts (bundled by esbuild).
5
+ * This file provides typed exports for library consumers (e.g. Electron client).
6
+ */
7
+ export { PersistentMcpBridge } from './bridge.js';
8
+ export { CustomStdioClientTransport } from './customStdio.js';
9
+ export { buildBaseEnv, connectStdio, connectBridge } from './transport.js';
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "nuwax-mcp-stdio-proxy",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "TypeScript stdio MCP proxy — aggregates multiple MCP servers (stdio + bridge) into one",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nuwax-mcp-stdio-proxy": "./dist/index.js"
8
8
  },
9
+ "main": "./dist/lib.js",
10
+ "types": "./dist/lib.d.ts",
9
11
  "files": [
10
12
  "dist"
11
13
  ],
12
14
  "scripts": {
13
- "build": "node build.mjs",
15
+ "build": "tsc && node build.mjs",
14
16
  "build:tsc": "tsc",
15
17
  "test": "npm run build:tsc && vitest",
16
18
  "test:run": "npm run build:tsc && vitest run",