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.
- package/dist/bridge.d.ts +65 -0
- package/dist/bridge.js +441 -0
- package/dist/customStdio.js +5 -0
- package/dist/index.js +1 -1
- package/dist/lib.d.ts +13 -0
- package/dist/lib.js +9 -0
- package/package.json +4 -2
package/dist/bridge.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/customStdio.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|