nuwax-mcp-stdio-proxy 1.4.6 → 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.
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Resilient Transport Wrapper for MCP
3
+ *
4
+ * Provides heartbeat monitoring, automatic reconnection, and request queueing
5
+ * for MCP transports (HTTP, SSE, Stdio).
6
+ */
7
+ import { logInfo, logWarn, logError } from './logger.js';
8
+ const DEFAULT_OPTIONS = {
9
+ pingIntervalMs: 20000,
10
+ maxConsecutiveFailures: 3,
11
+ pingTimeoutMs: 5000,
12
+ reconnectDelayMs: 3000,
13
+ maxQueueSize: 100,
14
+ name: 'remote',
15
+ };
16
+ /** Default logger that delegates to logger.js (stderr) */
17
+ const defaultLogger = {
18
+ info: (...args) => logInfo(args.map(String).join(' ')),
19
+ warn: (...args) => logWarn(args.map(String).join(' ')),
20
+ error: (...args) => logError(args.map(String).join(' ')),
21
+ };
22
+ export class ResilientTransportWrapper {
23
+ options;
24
+ log;
25
+ activeTransport = null;
26
+ mcpClient = null;
27
+ heartbeatTimer = null;
28
+ consecutiveFailures = 0;
29
+ state = 'connecting';
30
+ // Handlers required by the Transport interface
31
+ onclose;
32
+ onerror;
33
+ onmessage;
34
+ // Queue for messages sent while reconnecting
35
+ messageQueue = [];
36
+ // Pending ping requests awaiting a response
37
+ pendingPings = new Map();
38
+ constructor(options) {
39
+ this.log = options.logger ?? defaultLogger;
40
+ this.options = { ...DEFAULT_OPTIONS, logger: this.log, ...options };
41
+ }
42
+ /**
43
+ * Initializes the transport and connects to the backend
44
+ *
45
+ * This method is idempotent - if the transport is already connected or
46
+ * connecting, subsequent calls will return immediately without creating
47
+ * duplicate connections. This is important because MCP SDK's client.connect()
48
+ * internally calls transport.start(), and we also call it explicitly in bridge.ts.
49
+ */
50
+ async start() {
51
+ // Idempotent check: if already connected or connecting, return early
52
+ if (this.state === 'connected' || this.state === 'connecting') {
53
+ return;
54
+ }
55
+ this.state = 'connecting';
56
+ await this.performConnect(true);
57
+ }
58
+ /**
59
+ * Enable heartbeat monitoring. Call this AFTER the MCP client has
60
+ * completed its initialize handshake (client.connect()), otherwise
61
+ * the server will reject ping requests with "Server not initialized".
62
+ */
63
+ enableHeartbeat() {
64
+ this.startHeartbeat();
65
+ }
66
+ async performConnect(initial = false) {
67
+ try {
68
+ this.activeTransport = await this.options.connectParams();
69
+ // Inherit the handlers from this wrapper
70
+ this.bindInnerTransport(this.activeTransport);
71
+ await this.activeTransport.start();
72
+ this.state = 'connected';
73
+ this.consecutiveFailures = 0;
74
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
75
+ // Flush any queued messages
76
+ this.flushQueue();
77
+ // Only start heartbeat on reconnects — initial connections need
78
+ // the caller to invoke enableHeartbeat() after client.connect()
79
+ // completes the MCP initialize handshake. Sending pings before
80
+ // initialize causes "Server not initialized" errors.
81
+ if (!initial) {
82
+ this.startHeartbeat();
83
+ }
84
+ }
85
+ catch (err) {
86
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Connect failed: ${err}`);
87
+ if (initial) {
88
+ this.state = 'closed';
89
+ throw err;
90
+ }
91
+ this.triggerReconnect(); // Retry connection
92
+ }
93
+ }
94
+ bindInnerTransport(transport) {
95
+ transport.onclose = () => {
96
+ // If the inner transport closes but we are not intentionally closed
97
+ if (this.state !== 'closed') {
98
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Inner transport closed unexpectedly. Reconnecting...`);
99
+ this.triggerReconnect();
100
+ }
101
+ };
102
+ transport.onerror = (error) => {
103
+ // If it throws ENOENT or similar before we even catch it, we might be here.
104
+ if (this.state !== 'closed') {
105
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Inner transport error: ${error.message}`);
106
+ if (this.state === 'connecting') {
107
+ if (this.onerror)
108
+ this.onerror(error);
109
+ return;
110
+ }
111
+ this.triggerReconnect();
112
+ }
113
+ };
114
+ transport.onmessage = (message) => {
115
+ // Intercept our own ping responses here (both success and error responses)
116
+ if ('id' in message && typeof message.id === 'string' && message.id.startsWith('respl-ping-')) {
117
+ const resolve = this.pendingPings.get(message.id);
118
+ if (resolve) {
119
+ // Any response (even error like "Method not found") means server is alive
120
+ resolve(true);
121
+ this.pendingPings.delete(message.id);
122
+ }
123
+ return; // Don't forward ping responses to downstream
124
+ }
125
+ if (this.onmessage) {
126
+ this.onmessage(message);
127
+ }
128
+ };
129
+ }
130
+ startHeartbeat() {
131
+ this.stopHeartbeat();
132
+ if (this.options.pingIntervalMs <= 0)
133
+ return;
134
+ this.heartbeatTimer = setInterval(() => {
135
+ this.checkHealth();
136
+ }, this.options.pingIntervalMs);
137
+ }
138
+ stopHeartbeat() {
139
+ if (this.heartbeatTimer) {
140
+ clearInterval(this.heartbeatTimer);
141
+ this.heartbeatTimer = null;
142
+ }
143
+ }
144
+ /** Track consecutive ping timeouts (no response at all, not even an error) */
145
+ consecutivePingTimeouts = 0;
146
+ /** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
147
+ pingDisabled = false;
148
+ async checkHealth() {
149
+ if (this.state !== 'connected' || !this.activeTransport)
150
+ return;
151
+ if (this.pingDisabled)
152
+ return;
153
+ try {
154
+ // Send raw ping JSON-RPC
155
+ const pingId = `respl-ping-${Date.now()}`;
156
+ const responsePromise = new Promise((resolve) => {
157
+ this.pendingPings.set(pingId, resolve);
158
+ setTimeout(() => {
159
+ if (this.pendingPings.has(pingId)) {
160
+ this.pendingPings.delete(pingId);
161
+ resolve(false); // Timeout
162
+ }
163
+ }, this.options.pingTimeoutMs);
164
+ });
165
+ // Try to send ping — if send() throws, the transport itself is broken
166
+ let sendFailed = false;
167
+ try {
168
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat ping (id: ${pingId})`);
169
+ await this.activeTransport.send({
170
+ jsonrpc: '2.0',
171
+ id: pingId,
172
+ method: 'ping',
173
+ });
174
+ }
175
+ catch (sendErr) {
176
+ sendFailed = true;
177
+ throw sendErr; // Transport broken, treat as real failure
178
+ }
179
+ const success = await responsePromise;
180
+ if (!success) {
181
+ // Ping was sent successfully but timed out — server might not support ping
182
+ this.consecutivePingTimeouts++;
183
+ if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
184
+ // Server consistently never responds to ping — it probably doesn't support
185
+ // the ping method. Auto-disable heartbeat instead of reconnecting, since
186
+ // the transport is likely healthy (sends succeed, no errors from transport).
187
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Server does not respond to ping. Disabling heartbeat monitoring.`);
188
+ this.pingDisabled = true;
189
+ this.stopHeartbeat();
190
+ return;
191
+ }
192
+ // Still count as a failure for logging, but don't reconnect yet
193
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Ping timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
194
+ return;
195
+ }
196
+ // Got a response — server is alive
197
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat successful (id: ${pingId})`);
198
+ this.consecutiveFailures = 0;
199
+ this.consecutivePingTimeouts = 0;
200
+ }
201
+ catch (err) {
202
+ this.consecutiveFailures++;
203
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (attempt ${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
204
+ if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
205
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
206
+ this.triggerReconnect();
207
+ }
208
+ }
209
+ }
210
+ triggerReconnect() {
211
+ if (this.state === 'reconnecting' || this.state === 'closed')
212
+ return;
213
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Triggering reconnect (previous state: ${this.state})`);
214
+ this.state = 'reconnecting';
215
+ this.stopHeartbeat();
216
+ // Clean up old transport
217
+ if (this.activeTransport) {
218
+ // Avoid triggering our own onclose
219
+ this.activeTransport.onclose = undefined;
220
+ this.activeTransport.onerror = undefined;
221
+ try {
222
+ this.activeTransport.close();
223
+ }
224
+ catch { /* ignore */ }
225
+ this.activeTransport = null;
226
+ }
227
+ setTimeout(() => {
228
+ this.performConnect();
229
+ }, this.options.reconnectDelayMs);
230
+ }
231
+ async flushQueue() {
232
+ if (!this.activeTransport || this.state !== 'connected')
233
+ return;
234
+ if (this.messageQueue.length > 0) {
235
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Flushing ${this.messageQueue.length} queued requests...`);
236
+ }
237
+ const queueToFlush = [...this.messageQueue];
238
+ this.messageQueue = [];
239
+ for (let i = 0; i < queueToFlush.length; i++) {
240
+ const msg = queueToFlush[i];
241
+ try {
242
+ await this.activeTransport.send(msg);
243
+ }
244
+ catch (e) {
245
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Error flushing queue: ${e}`);
246
+ // If sending fails, put this message and remaining ones back at the front of the queue
247
+ const failedAndRemaining = queueToFlush.slice(i);
248
+ this.messageQueue = [...failedAndRemaining, ...this.messageQueue];
249
+ this.triggerReconnect();
250
+ break; // Stop flushing until reconnected
251
+ }
252
+ }
253
+ }
254
+ // --- Transport Interface Methods ---
255
+ async send(message) {
256
+ if (this.state === 'closed') {
257
+ throw new Error('Transport is closed');
258
+ }
259
+ if (this.state === 'reconnecting' || this.state === 'connecting') {
260
+ // Queue the message
261
+ if (this.messageQueue.length >= this.options.maxQueueSize) {
262
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Message queue full, dropping oldest request.`);
263
+ this.messageQueue.shift(); // Drop oldest
264
+ }
265
+ this.messageQueue.push(message);
266
+ return;
267
+ }
268
+ if (!this.activeTransport) {
269
+ throw new Error('No active transport to send message');
270
+ }
271
+ try {
272
+ await this.activeTransport.send(message);
273
+ }
274
+ catch (err) {
275
+ // Queue it and reconnect
276
+ this.messageQueue.push(message);
277
+ this.triggerReconnect();
278
+ }
279
+ }
280
+ async close() {
281
+ if (this.state === 'closed')
282
+ return;
283
+ this.state = 'closed';
284
+ this.stopHeartbeat();
285
+ this.messageQueue = [];
286
+ if (this.activeTransport) {
287
+ // Prevent bubble up closure
288
+ this.activeTransport.onclose = undefined;
289
+ this.activeTransport.onerror = undefined;
290
+ await this.activeTransport.close();
291
+ this.activeTransport = null;
292
+ }
293
+ if (this.onclose) {
294
+ this.onclose();
295
+ }
296
+ }
297
+ }
@@ -2,10 +2,12 @@
2
2
  * Transport layer — connect to upstream MCP servers via stdio, Streamable HTTP, or SSE
3
3
  */
4
4
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
5
+ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
5
6
  import type { StdioServerEntry, StreamableServerEntry, SseServerEntry } from './types.js';
6
7
  export interface ConnectedClient {
7
8
  client: Client;
8
9
  cleanup: () => Promise<void>;
10
+ transport: Transport;
9
11
  }
10
12
  /**
11
13
  * Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
package/dist/transport.js CHANGED
@@ -6,6 +6,22 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
6
6
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
7
7
  import { CustomStdioClientTransport } from './customStdio.js';
8
8
  import { logInfo } from './logger.js';
9
+ import { ResilientTransportWrapper } from './resilient.js';
10
+ /**
11
+ * Helper to wrap a promise with a timeout
12
+ */
13
+ async function withTimeout(promise, ms, message) {
14
+ let timeoutId;
15
+ const timeoutPromise = new Promise((_, reject) => {
16
+ timeoutId = setTimeout(() => reject(new Error(message)), ms);
17
+ });
18
+ try {
19
+ return await Promise.race([promise, timeoutPromise]);
20
+ }
21
+ finally {
22
+ clearTimeout(timeoutId);
23
+ }
24
+ }
9
25
  /**
10
26
  * Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
11
27
  */
@@ -38,28 +54,49 @@ export function buildRequestHeaders(entry) {
38
54
  */
39
55
  export async function connectStdio(id, entry, baseEnv) {
40
56
  logInfo(`Connecting to "${id}" (stdio): ${entry.command} ${(entry.args || []).join(' ')}`);
41
- const transport = new CustomStdioClientTransport({
42
- command: entry.command,
43
- args: entry.args || [],
44
- env: { ...baseEnv, ...(entry.env || {}) },
45
- stderr: 'pipe',
46
- });
47
- // Attach stderr listener BEFORE connect to catch early child errors
48
- if (transport.stderr) {
49
- transport.stderr.on('data', (chunk) => {
50
- const text = chunk.toString().trim();
51
- if (text) {
52
- process.stderr.write(`[child:${id}] ${text}\n`);
57
+ const wrapper = new ResilientTransportWrapper({
58
+ name: id,
59
+ connectParams: async () => {
60
+ const t = new CustomStdioClientTransport({
61
+ command: entry.command,
62
+ args: entry.args || [],
63
+ env: { ...baseEnv, ...(entry.env || {}) },
64
+ stderr: 'pipe',
65
+ });
66
+ if (t.stderr) {
67
+ t.stderr.on('data', (chunk) => {
68
+ const text = chunk.toString().trim();
69
+ if (text) {
70
+ process.stderr.write(`[child:${id}] ${text}\n`);
71
+ }
72
+ });
53
73
  }
54
- });
55
- }
74
+ return t;
75
+ },
76
+ pingIntervalMs: entry.pingIntervalMs,
77
+ pingTimeoutMs: entry.pingTimeoutMs,
78
+ });
56
79
  const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
57
- await client.connect(transport);
80
+ try {
81
+ await withTimeout((async () => {
82
+ await wrapper.start();
83
+ await client.connect(wrapper);
84
+ wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
85
+ })(), 5000, `Connection initialization timed out after 5s`);
86
+ }
87
+ catch (err) {
88
+ try {
89
+ await wrapper.close();
90
+ }
91
+ catch { /* ignore */ }
92
+ throw err;
93
+ }
58
94
  return {
59
95
  client,
96
+ transport: wrapper,
60
97
  cleanup: async () => {
61
98
  try {
62
- await transport.close();
99
+ await wrapper.close();
63
100
  }
64
101
  catch { /* ignore */ }
65
102
  },
@@ -75,14 +112,35 @@ export async function connectStreamable(id, entry) {
75
112
  logInfo(`Connecting to "${id}" (streamable-http): ${entry.url}`);
76
113
  const headers = buildRequestHeaders(entry);
77
114
  const url = new URL(entry.url);
78
- const transport = new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined);
115
+ const wrapper = new ResilientTransportWrapper({
116
+ name: id,
117
+ connectParams: async () => {
118
+ return new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined);
119
+ },
120
+ pingIntervalMs: entry.pingIntervalMs,
121
+ pingTimeoutMs: entry.pingTimeoutMs,
122
+ });
79
123
  const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
80
- await client.connect(transport);
124
+ try {
125
+ await withTimeout((async () => {
126
+ await wrapper.start();
127
+ await client.connect(wrapper);
128
+ wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
129
+ })(), 5000, `Connection initialization timed out after 5s`);
130
+ }
131
+ catch (err) {
132
+ try {
133
+ await wrapper.close();
134
+ }
135
+ catch { /* ignore */ }
136
+ throw err;
137
+ }
81
138
  return {
82
139
  client,
140
+ transport: wrapper,
83
141
  cleanup: async () => {
84
142
  try {
85
- await transport.close();
143
+ await wrapper.close();
86
144
  }
87
145
  catch { /* ignore */ }
88
146
  },
@@ -98,14 +156,35 @@ export async function connectSse(id, entry) {
98
156
  logInfo(`Connecting to "${id}" (sse): ${entry.url}`);
99
157
  const headers = buildRequestHeaders(entry);
100
158
  const url = new URL(entry.url);
101
- const transport = new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined);
159
+ const wrapper = new ResilientTransportWrapper({
160
+ name: id,
161
+ connectParams: async () => {
162
+ return new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined);
163
+ },
164
+ pingIntervalMs: entry.pingIntervalMs,
165
+ pingTimeoutMs: entry.pingTimeoutMs,
166
+ });
102
167
  const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
103
- await client.connect(transport);
168
+ try {
169
+ await withTimeout((async () => {
170
+ await wrapper.start();
171
+ await client.connect(wrapper);
172
+ wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
173
+ })(), 5000, `Connection initialization timed out after 5s`);
174
+ }
175
+ catch (err) {
176
+ try {
177
+ await wrapper.close();
178
+ }
179
+ catch { /* ignore */ }
180
+ throw err;
181
+ }
104
182
  return {
105
183
  client,
184
+ transport: wrapper,
106
185
  cleanup: async () => {
107
186
  try {
108
- await transport.close();
187
+ await wrapper.close();
109
188
  }
110
189
  catch { /* ignore */ }
111
190
  },
package/dist/types.d.ts CHANGED
@@ -10,6 +10,10 @@ export interface StdioServerEntry {
10
10
  allowTools?: string[];
11
11
  /** 工具黑名单(排除指定工具) */
12
12
  denyTools?: string[];
13
+ /** Heartbeat ping interval configuration (ms) */
14
+ pingIntervalMs?: number;
15
+ /** Heartbeat ping timeout configuration (ms) */
16
+ pingTimeoutMs?: number;
13
17
  }
14
18
  /**
15
19
  * Streamable HTTP 类型: 连接远程 MCP server (Streamable HTTP)
@@ -26,6 +30,10 @@ export interface StreamableServerEntry {
26
30
  allowTools?: string[];
27
31
  /** 工具黑名单(排除指定工具) */
28
32
  denyTools?: string[];
33
+ /** Heartbeat ping interval configuration (ms) */
34
+ pingIntervalMs?: number;
35
+ /** Heartbeat ping timeout configuration (ms) */
36
+ pingTimeoutMs?: number;
29
37
  }
30
38
  /**
31
39
  * SSE 类型: 连接远程 MCP server (Server-Sent Events)
@@ -41,6 +49,10 @@ export interface SseServerEntry {
41
49
  allowTools?: string[];
42
50
  /** 工具黑名单(排除指定工具) */
43
51
  denyTools?: string[];
52
+ /** Heartbeat ping interval configuration (ms) */
53
+ pingIntervalMs?: number;
54
+ /** Heartbeat ping timeout configuration (ms) */
55
+ pingTimeoutMs?: number;
44
56
  }
45
57
  export type McpServerEntry = StdioServerEntry | StreamableServerEntry | SseServerEntry;
46
58
  export declare function isSseEntry(entry: McpServerEntry): entry is SseServerEntry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuwax-mcp-stdio-proxy",
3
- "version": "1.4.6",
3
+ "version": "1.4.7",
4
4
  "description": "TypeScript MCP proxy — aggregates multiple MCP servers (stdio + streamable-http + SSE) with convert & proxy modes",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,18 +12,20 @@
12
12
  "dist"
13
13
  ],
14
14
  "scripts": {
15
- "build": "tsc && node build.mjs",
15
+ "build": "tsc && node scripts/build.mjs",
16
16
  "build:tsc": "tsc",
17
17
  "test": "npm run build:tsc && vitest",
18
18
  "test:run": "npm run build:tsc && vitest run",
19
19
  "test:coverage": "npm run build:tsc && vitest run --coverage",
20
- "prepublishOnly": "npm run build"
20
+ "prepublishOnly": "npm run build",
21
+ "publish:non-latest": "npm publish --tag beta"
21
22
  },
22
23
  "dependencies": {
23
24
  "@modelcontextprotocol/sdk": "^1.27.1"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@types/node": "^22.0.0",
28
+ "@vitest/coverage-v8": "^2.1.9",
27
29
  "esbuild": "^0.27.3",
28
30
  "typescript": "^5.7.0",
29
31
  "vitest": "^2.1.8"