nuwax-mcp-stdio-proxy 1.4.7 → 1.4.9

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/logger.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /**
2
- * Logging utilities — stderr only, stdout is the MCP JSON-RPC channel
2
+ * Logging utilities — stderr only, stdout is the MCP JSON-RPC channel.
3
+ *
4
+ * When the environment variable MCP_PROXY_LOG_FILE is set, log lines are
5
+ * also appended to that file so the Electron host can tail them into main.log.
6
+ *
7
+ * Log rotation: file is named by date (e.g. mcp-proxy-2026-03-09.log).
8
+ * A new file is created each day. Old files beyond MAX_LOG_FILES are deleted.
3
9
  */
4
10
  export declare function log(level: string, msg: string): void;
5
11
  export declare const logInfo: (msg: string) => void;
package/dist/logger.js CHANGED
@@ -1,8 +1,103 @@
1
1
  /**
2
- * Logging utilities — stderr only, stdout is the MCP JSON-RPC channel
2
+ * Logging utilities — stderr only, stdout is the MCP JSON-RPC channel.
3
+ *
4
+ * When the environment variable MCP_PROXY_LOG_FILE is set, log lines are
5
+ * also appended to that file so the Electron host can tail them into main.log.
6
+ *
7
+ * Log rotation: file is named by date (e.g. mcp-proxy-2026-03-09.log).
8
+ * A new file is created each day. Old files beyond MAX_LOG_FILES are deleted.
3
9
  */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ const logFilePath = process.env.MCP_PROXY_LOG_FILE;
13
+ const MAX_LOG_FILES = 7;
14
+ let logStream = null;
15
+ let logDir = '';
16
+ let logBaseName = '';
17
+ let logExt = '';
18
+ let currentDateStr = '';
19
+ function dateStr() {
20
+ const d = new Date();
21
+ const pad2 = (n) => String(n).padStart(2, '0');
22
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
23
+ }
24
+ function openLogFile() {
25
+ if (!logFilePath)
26
+ return;
27
+ const today = dateStr();
28
+ if (today === currentDateStr && logStream)
29
+ return;
30
+ // Close previous stream
31
+ if (logStream) {
32
+ try {
33
+ logStream.end();
34
+ }
35
+ catch { /* ignore */ }
36
+ }
37
+ currentDateStr = today;
38
+ const dated = path.join(logDir, `${logBaseName}-${today}${logExt}`);
39
+ try {
40
+ logStream = fs.createWriteStream(dated, { flags: 'a' });
41
+ }
42
+ catch {
43
+ logStream = null;
44
+ }
45
+ // Cleanup old log files
46
+ cleanupOldLogs();
47
+ }
48
+ function cleanupOldLogs() {
49
+ if (!logDir || !logBaseName)
50
+ return;
51
+ try {
52
+ const prefix = `${logBaseName}-`;
53
+ const files = fs.readdirSync(logDir)
54
+ .filter(f => f.startsWith(prefix) && f.endsWith(logExt))
55
+ .sort()
56
+ .reverse();
57
+ for (let i = MAX_LOG_FILES; i < files.length; i++) {
58
+ try {
59
+ fs.unlinkSync(path.join(logDir, files[i]));
60
+ }
61
+ catch { /* ignore */ }
62
+ }
63
+ }
64
+ catch { /* ignore */ }
65
+ }
66
+ if (logFilePath) {
67
+ try {
68
+ logDir = path.dirname(logFilePath);
69
+ if (logDir && !fs.existsSync(logDir)) {
70
+ fs.mkdirSync(logDir, { recursive: true });
71
+ }
72
+ const fullName = path.basename(logFilePath);
73
+ const dotIdx = fullName.lastIndexOf('.');
74
+ if (dotIdx > 0) {
75
+ logBaseName = fullName.substring(0, dotIdx);
76
+ logExt = fullName.substring(dotIdx);
77
+ }
78
+ else {
79
+ logBaseName = fullName;
80
+ logExt = '.log';
81
+ }
82
+ openLogFile();
83
+ }
84
+ catch {
85
+ // If file creation fails, continue without file logging
86
+ }
87
+ }
88
+ function timestamp() {
89
+ const d = new Date();
90
+ const pad2 = (n) => String(n).padStart(2, '0');
91
+ const pad3 = (n) => String(n).padStart(3, '0');
92
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
93
+ }
4
94
  export function log(level, msg) {
5
- process.stderr.write(`[nuwax-mcp-proxy] ${level}: ${msg}\n`);
95
+ const line = `[${timestamp()}] [${level.toLowerCase()}] [nuwax-mcp-proxy] ${msg}\n`;
96
+ process.stderr.write(line);
97
+ if (logFilePath) {
98
+ openLogFile(); // Rotate if date changed
99
+ logStream?.write(line);
100
+ }
6
101
  }
7
102
  export const logInfo = (msg) => log('INFO', msg);
8
103
  export const logWarn = (msg) => log('WARN', msg);
@@ -24,7 +24,16 @@ export async function runStdio(config, allowTools, denyTools) {
24
24
  const toolToClient = new Map();
25
25
  const toolToServer = new Map();
26
26
  const toolsByName = new Map();
27
- for (const [id, entry] of entries) {
27
+ // Filter out persistent entries (handled by PersistentMcpBridge, not this proxy)
28
+ const connectableEntries = entries.filter(([id, entry]) => {
29
+ if (entry.persistent) {
30
+ logWarn(`Skipping persistent server "${id}" (handled by PersistentMcpBridge)`);
31
+ return false;
32
+ }
33
+ return true;
34
+ });
35
+ // Connect to all servers in parallel
36
+ const results = await Promise.allSettled(connectableEntries.map(async ([id, entry]) => {
28
37
  try {
29
38
  let connected;
30
39
  if (isSseEntry(entry)) {
@@ -46,36 +55,43 @@ export async function runStdio(config, allowTools, denyTools) {
46
55
  else {
47
56
  connected = await connectStdio(id, entry, baseEnv);
48
57
  }
49
- const { client, cleanup } = connected;
50
- clients.set(id, client);
51
- cleanups.set(id, cleanup);
52
- let serverTools = await discoverTools(client);
53
- // Per-server tool filtering (allowTools/denyTools in config entry)
54
- if (entry.allowTools || entry.denyTools) {
55
- const perFilter = {};
56
- if (entry.allowTools)
57
- perFilter.allowTools = new Set(entry.allowTools);
58
- if (entry.denyTools)
59
- perFilter.denyTools = new Set(entry.denyTools);
60
- const before = serverTools.length;
61
- serverTools = filterTools(serverTools, perFilter);
62
- if (serverTools.length !== before) {
63
- logInfo(`Server "${id}": filtered ${before} → ${serverTools.length} tool(s)`);
64
- }
65
- }
66
- logInfo(`Server "${id}": ${serverTools.length} tool(s)${serverTools.length > 0 ? ' — ' + serverTools.map((t) => t.name).join(', ') : ''}`);
67
- for (const tool of serverTools) {
68
- if (toolToClient.has(tool.name)) {
69
- logWarn(`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`);
70
- }
71
- toolToClient.set(tool.name, client);
72
- toolToServer.set(tool.name, id);
73
- toolsByName.set(tool.name, tool);
74
- }
58
+ return { id, entry, connected };
75
59
  }
76
60
  catch (e) {
77
- logError(`Failed to connect to server "${id}": ${e}`);
78
- // Continue with remaining servers — partial startup is acceptable
61
+ throw new Error(`Server "${id}": ${e}`);
62
+ }
63
+ }));
64
+ for (const result of results) {
65
+ if (result.status === 'rejected') {
66
+ logError(`Failed to connect: ${result.reason}`);
67
+ continue;
68
+ }
69
+ const { id, entry, connected } = result.value;
70
+ const { client, cleanup } = connected;
71
+ clients.set(id, client);
72
+ cleanups.set(id, cleanup);
73
+ let serverTools = await discoverTools(client);
74
+ // Per-server tool filtering (allowTools/denyTools in config entry)
75
+ if (entry.allowTools || entry.denyTools) {
76
+ const perFilter = {};
77
+ if (entry.allowTools)
78
+ perFilter.allowTools = new Set(entry.allowTools);
79
+ if (entry.denyTools)
80
+ perFilter.denyTools = new Set(entry.denyTools);
81
+ const before = serverTools.length;
82
+ serverTools = filterTools(serverTools, perFilter);
83
+ if (serverTools.length !== before) {
84
+ logInfo(`Server "${id}": filtered ${before} → ${serverTools.length} tool(s)`);
85
+ }
86
+ }
87
+ logInfo(`Server "${id}": ${serverTools.length} tool(s)${serverTools.length > 0 ? ' — ' + serverTools.map((t) => t.name).join(', ') : ''}`);
88
+ for (const tool of serverTools) {
89
+ if (toolToClient.has(tool.name)) {
90
+ logWarn(`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`);
91
+ }
92
+ toolToClient.set(tool.name, client);
93
+ toolToServer.set(tool.name, id);
94
+ toolsByName.set(tool.name, tool);
79
95
  }
80
96
  }
81
97
  if (clients.size === 0) {
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Provides heartbeat monitoring, automatic reconnection, and request queueing
5
5
  * for MCP transports (HTTP, SSE, Stdio).
6
+ *
7
+ * Retry strategy: exponential backoff 1s → 2s → 4s → ... → 60s (capped),
8
+ * unlimited retries. Matches the Rust mcp-proxy CappedExponentialBackoff.
6
9
  */
7
10
  import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
8
11
  import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
@@ -21,8 +24,10 @@ export interface ResilientTransportOptions {
21
24
  maxConsecutiveFailures?: number;
22
25
  /** Timeout for checking ping or listTools (ms). Default: 5000 */
23
26
  pingTimeoutMs?: number;
24
- /** Backoff delay before reconnect attempt (ms). Default: 3000 */
27
+ /** Base backoff delay for reconnect (ms). Default: 1000 */
25
28
  reconnectDelayMs?: number;
29
+ /** Max backoff delay cap (ms). Default: 60000 */
30
+ maxReconnectDelayMs?: number;
26
31
  /** Max queued requests during reconnect. Default: 100 */
27
32
  maxQueueSize?: number;
28
33
  /** Server name/ID for logging */
@@ -43,12 +48,19 @@ export declare class ResilientTransportWrapper implements Transport {
43
48
  private heartbeatTimer;
44
49
  private consecutiveFailures;
45
50
  private state;
51
+ /** Current retry attempt count (reset on successful connect) */
52
+ private retryAttempt;
46
53
  onclose?: () => void;
47
54
  onerror?: (error: Error) => void;
48
55
  onmessage?: (message: JSONRPCMessage) => void;
49
56
  private messageQueue;
50
57
  private pendingPings;
51
58
  constructor(options: ResilientTransportOptions);
59
+ /**
60
+ * Calculate backoff delay using capped exponential backoff.
61
+ * 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
62
+ */
63
+ private getBackoffDelay;
52
64
  /**
53
65
  * Initializes the transport and connects to the backend
54
66
  *
@@ -61,7 +73,7 @@ export declare class ResilientTransportWrapper implements Transport {
61
73
  /**
62
74
  * Enable heartbeat monitoring. Call this AFTER the MCP client has
63
75
  * completed its initialize handshake (client.connect()), otherwise
64
- * the server will reject ping requests with "Server not initialized".
76
+ * the server will reject requests with "Server not initialized".
65
77
  */
66
78
  enableHeartbeat(): void;
67
79
  private performConnect;
@@ -70,9 +82,12 @@ export declare class ResilientTransportWrapper implements Transport {
70
82
  private stopHeartbeat;
71
83
  /** Track consecutive ping timeouts (no response at all, not even an error) */
72
84
  private consecutivePingTimeouts;
73
- /** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
74
- private pingDisabled;
85
+ /** Successful heartbeat counter (for reducing log volume) */
86
+ private heartbeatOkCount;
75
87
  private checkHealth;
88
+ /**
89
+ * Close the current transport and schedule a reconnect with exponential backoff.
90
+ */
76
91
  private triggerReconnect;
77
92
  private flushQueue;
78
93
  send(message: JSONRPCMessage): Promise<void>;
package/dist/resilient.js CHANGED
@@ -3,13 +3,17 @@
3
3
  *
4
4
  * Provides heartbeat monitoring, automatic reconnection, and request queueing
5
5
  * for MCP transports (HTTP, SSE, Stdio).
6
+ *
7
+ * Retry strategy: exponential backoff 1s → 2s → 4s → ... → 60s (capped),
8
+ * unlimited retries. Matches the Rust mcp-proxy CappedExponentialBackoff.
6
9
  */
7
10
  import { logInfo, logWarn, logError } from './logger.js';
8
11
  const DEFAULT_OPTIONS = {
9
12
  pingIntervalMs: 20000,
10
13
  maxConsecutiveFailures: 3,
11
14
  pingTimeoutMs: 5000,
12
- reconnectDelayMs: 3000,
15
+ reconnectDelayMs: 1000,
16
+ maxReconnectDelayMs: 60000,
13
17
  maxQueueSize: 100,
14
18
  name: 'remote',
15
19
  };
@@ -26,7 +30,9 @@ export class ResilientTransportWrapper {
26
30
  mcpClient = null;
27
31
  heartbeatTimer = null;
28
32
  consecutiveFailures = 0;
29
- state = 'connecting';
33
+ state = 'idle';
34
+ /** Current retry attempt count (reset on successful connect) */
35
+ retryAttempt = 0;
30
36
  // Handlers required by the Transport interface
31
37
  onclose;
32
38
  onerror;
@@ -37,7 +43,30 @@ export class ResilientTransportWrapper {
37
43
  pendingPings = new Map();
38
44
  constructor(options) {
39
45
  this.log = options.logger ?? defaultLogger;
40
- this.options = { ...DEFAULT_OPTIONS, logger: this.log, ...options };
46
+ // Build options by starting from defaults and only overriding with
47
+ // explicitly provided values. Spreading { pingIntervalMs: undefined }
48
+ // would clobber the default 20000, causing setInterval(fn, undefined)
49
+ // → interval ~0ms (fires every tick).
50
+ this.options = {
51
+ ...DEFAULT_OPTIONS,
52
+ logger: this.log,
53
+ connectParams: options.connectParams,
54
+ ...(options.name !== undefined && { name: options.name }),
55
+ ...(options.pingIntervalMs !== undefined && { pingIntervalMs: options.pingIntervalMs }),
56
+ ...(options.pingTimeoutMs !== undefined && { pingTimeoutMs: options.pingTimeoutMs }),
57
+ ...(options.maxConsecutiveFailures !== undefined && { maxConsecutiveFailures: options.maxConsecutiveFailures }),
58
+ ...(options.reconnectDelayMs !== undefined && { reconnectDelayMs: options.reconnectDelayMs }),
59
+ ...(options.maxReconnectDelayMs !== undefined && { maxReconnectDelayMs: options.maxReconnectDelayMs }),
60
+ ...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
61
+ };
62
+ }
63
+ /**
64
+ * Calculate backoff delay using capped exponential backoff.
65
+ * 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
66
+ */
67
+ getBackoffDelay() {
68
+ const delay = this.options.reconnectDelayMs * Math.pow(2, this.retryAttempt);
69
+ return Math.min(delay, this.options.maxReconnectDelayMs);
41
70
  }
42
71
  /**
43
72
  * Initializes the transport and connects to the backend
@@ -48,8 +77,8 @@ export class ResilientTransportWrapper {
48
77
  * internally calls transport.start(), and we also call it explicitly in bridge.ts.
49
78
  */
50
79
  async start() {
51
- // Idempotent check: if already connected or connecting, return early
52
- if (this.state === 'connected' || this.state === 'connecting') {
80
+ // Idempotent check: if already connected, connecting, or retrying, return early
81
+ if (this.state === 'connected' || this.state === 'connecting' || this.state === 'reconnecting') {
53
82
  return;
54
83
  }
55
84
  this.state = 'connecting';
@@ -58,12 +87,14 @@ export class ResilientTransportWrapper {
58
87
  /**
59
88
  * Enable heartbeat monitoring. Call this AFTER the MCP client has
60
89
  * completed its initialize handshake (client.connect()), otherwise
61
- * the server will reject ping requests with "Server not initialized".
90
+ * the server will reject requests with "Server not initialized".
62
91
  */
63
92
  enableHeartbeat() {
64
93
  this.startHeartbeat();
65
94
  }
66
95
  async performConnect(initial = false) {
96
+ if (this.state === 'closed')
97
+ return;
67
98
  try {
68
99
  this.activeTransport = await this.options.connectParams();
69
100
  // Inherit the handlers from this wrapper
@@ -71,7 +102,10 @@ export class ResilientTransportWrapper {
71
102
  await this.activeTransport.start();
72
103
  this.state = 'connected';
73
104
  this.consecutiveFailures = 0;
74
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
105
+ this.consecutivePingTimeouts = 0;
106
+ this.retryAttempt = 0;
107
+ this.heartbeatOkCount = 0;
108
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ Connected via ${this.activeTransport.constructor.name}`);
75
109
  // Flush any queued messages
76
110
  this.flushQueue();
77
111
  // Only start heartbeat on reconnects — initial connections need
@@ -83,12 +117,14 @@ export class ResilientTransportWrapper {
83
117
  }
84
118
  }
85
119
  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
120
+ const delay = this.getBackoffDelay();
121
+ this.retryAttempt++;
122
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] ❌ Connect failed (attempt ${this.retryAttempt}): ${err}`);
123
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Retrying in ${delay}ms...`);
124
+ this.state = 'reconnecting';
125
+ setTimeout(() => {
126
+ this.performConnect();
127
+ }, delay);
92
128
  }
93
129
  }
94
130
  bindInnerTransport(transport) {
@@ -112,7 +148,7 @@ export class ResilientTransportWrapper {
112
148
  }
113
149
  };
114
150
  transport.onmessage = (message) => {
115
- // Intercept our own ping responses here (both success and error responses)
151
+ // Intercept our own heartbeat responses (tools/list used as health check)
116
152
  if ('id' in message && typeof message.id === 'string' && message.id.startsWith('respl-ping-')) {
117
153
  const resolve = this.pendingPings.get(message.id);
118
154
  if (resolve) {
@@ -143,74 +179,71 @@ export class ResilientTransportWrapper {
143
179
  }
144
180
  /** Track consecutive ping timeouts (no response at all, not even an error) */
145
181
  consecutivePingTimeouts = 0;
146
- /** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
147
- pingDisabled = false;
182
+ /** Successful heartbeat counter (for reducing log volume) */
183
+ heartbeatOkCount = 0;
148
184
  async checkHealth() {
149
185
  if (this.state !== 'connected' || !this.activeTransport)
150
186
  return;
151
- if (this.pingDisabled)
152
- return;
153
187
  try {
154
- // Send raw ping JSON-RPC
155
- const pingId = `respl-ping-${Date.now()}`;
188
+ // Use tools/list as health check (all MCP servers must support it).
189
+ // Unlike ping, which is optional and many servers ignore, tools/list
190
+ // is a required MCP method — matching Rust mcp-proxy behavior.
191
+ const healthId = `respl-ping-${Date.now()}`;
156
192
  const responsePromise = new Promise((resolve) => {
157
- this.pendingPings.set(pingId, resolve);
193
+ this.pendingPings.set(healthId, resolve);
158
194
  setTimeout(() => {
159
- if (this.pendingPings.has(pingId)) {
160
- this.pendingPings.delete(pingId);
195
+ if (this.pendingPings.has(healthId)) {
196
+ this.pendingPings.delete(healthId);
161
197
  resolve(false); // Timeout
162
198
  }
163
199
  }, this.options.pingTimeoutMs);
164
200
  });
165
- // Try to send ping — if send() throws, the transport itself is broken
166
- let sendFailed = false;
201
+ // Try to send tools/list — if send() throws, the transport itself is broken
167
202
  try {
168
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat ping (id: ${pingId})`);
169
203
  await this.activeTransport.send({
170
204
  jsonrpc: '2.0',
171
- id: pingId,
172
- method: 'ping',
205
+ id: healthId,
206
+ method: 'tools/list',
207
+ params: {},
173
208
  });
174
209
  }
175
210
  catch (sendErr) {
176
- sendFailed = true;
177
211
  throw sendErr; // Transport broken, treat as real failure
178
212
  }
179
213
  const success = await responsePromise;
180
214
  if (!success) {
181
- // Ping was sent successfully but timed out — server might not support ping
182
215
  this.consecutivePingTimeouts++;
216
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] ⏱️ Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
183
217
  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;
218
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Closing and retrying...`);
219
+ this.triggerReconnect();
191
220
  }
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
221
  return;
195
222
  }
196
223
  // Got a response — server is alive
197
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat successful (id: ${pingId})`);
224
+ this.heartbeatOkCount++;
225
+ // Only log every 5th success to reduce log volume (~100s interval)
226
+ if (this.heartbeatOkCount % 5 === 1 || this.consecutiveFailures > 0 || this.consecutivePingTimeouts > 0) {
227
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat OK (count: ${this.heartbeatOkCount})`);
228
+ }
198
229
  this.consecutiveFailures = 0;
199
230
  this.consecutivePingTimeouts = 0;
200
231
  }
201
232
  catch (err) {
202
233
  this.consecutiveFailures++;
203
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (attempt ${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
234
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
204
235
  if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
205
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
236
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Closing and retrying...`);
206
237
  this.triggerReconnect();
207
238
  }
208
239
  }
209
240
  }
241
+ /**
242
+ * Close the current transport and schedule a reconnect with exponential backoff.
243
+ */
210
244
  triggerReconnect() {
211
245
  if (this.state === 'reconnecting' || this.state === 'closed')
212
246
  return;
213
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Triggering reconnect (previous state: ${this.state})`);
214
247
  this.state = 'reconnecting';
215
248
  this.stopHeartbeat();
216
249
  // Clean up old transport
@@ -224,9 +257,12 @@ export class ResilientTransportWrapper {
224
257
  catch { /* ignore */ }
225
258
  this.activeTransport = null;
226
259
  }
260
+ const delay = this.getBackoffDelay();
261
+ this.retryAttempt++;
262
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Closed. Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
227
263
  setTimeout(() => {
228
264
  this.performConnect();
229
- }, this.options.reconnectDelayMs);
265
+ }, delay);
230
266
  }
231
267
  async flushQueue() {
232
268
  if (!this.activeTransport || this.state !== 'connected')
package/dist/transport.js CHANGED
@@ -7,6 +7,8 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
7
7
  import { CustomStdioClientTransport } from './customStdio.js';
8
8
  import { logInfo } from './logger.js';
9
9
  import { ResilientTransportWrapper } from './resilient.js';
10
+ const DEFAULT_STDIO_CONNECTION_TIMEOUT_MS = 60_000;
11
+ const DEFAULT_HTTP_CONNECTION_TIMEOUT_MS = 30_000;
10
12
  /**
11
13
  * Helper to wrap a promise with a timeout
12
14
  */
@@ -73,16 +75,16 @@ export async function connectStdio(id, entry, baseEnv) {
73
75
  }
74
76
  return t;
75
77
  },
76
- pingIntervalMs: entry.pingIntervalMs,
77
- pingTimeoutMs: entry.pingTimeoutMs,
78
+ // No heartbeat for stdio — child process close/error events handle detection
79
+ pingIntervalMs: 0,
78
80
  });
79
81
  const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
82
+ const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_STDIO_CONNECTION_TIMEOUT_MS;
80
83
  try {
81
84
  await withTimeout((async () => {
82
85
  await wrapper.start();
83
86
  await client.connect(wrapper);
84
- wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
85
- })(), 5000, `Connection initialization timed out after 5s`);
87
+ })(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s`);
86
88
  }
87
89
  catch (err) {
88
90
  try {
@@ -121,12 +123,13 @@ export async function connectStreamable(id, entry) {
121
123
  pingTimeoutMs: entry.pingTimeoutMs,
122
124
  });
123
125
  const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
126
+ const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
124
127
  try {
125
128
  await withTimeout((async () => {
126
129
  await wrapper.start();
127
130
  await client.connect(wrapper);
128
131
  wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
129
- })(), 5000, `Connection initialization timed out after 5s`);
132
+ })(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s`);
130
133
  }
131
134
  catch (err) {
132
135
  try {
@@ -165,12 +168,13 @@ export async function connectSse(id, entry) {
165
168
  pingTimeoutMs: entry.pingTimeoutMs,
166
169
  });
167
170
  const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
171
+ const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
168
172
  try {
169
173
  await withTimeout((async () => {
170
174
  await wrapper.start();
171
175
  await client.connect(wrapper);
172
176
  wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
173
- })(), 5000, `Connection initialization timed out after 5s`);
177
+ })(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s`);
174
178
  }
175
179
  catch (err) {
176
180
  try {
package/dist/types.d.ts CHANGED
@@ -10,10 +10,8 @@ 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
+ /** Connection initialization timeout (ms). Defaults to 60000 for stdio. */
14
+ connectionTimeoutMs?: number;
17
15
  }
18
16
  /**
19
17
  * Streamable HTTP 类型: 连接远程 MCP server (Streamable HTTP)
@@ -34,6 +32,8 @@ export interface StreamableServerEntry {
34
32
  pingIntervalMs?: number;
35
33
  /** Heartbeat ping timeout configuration (ms) */
36
34
  pingTimeoutMs?: number;
35
+ /** Connection initialization timeout (ms). Defaults to 30000. */
36
+ connectionTimeoutMs?: number;
37
37
  }
38
38
  /**
39
39
  * SSE 类型: 连接远程 MCP server (Server-Sent Events)
@@ -53,6 +53,8 @@ export interface SseServerEntry {
53
53
  pingIntervalMs?: number;
54
54
  /** Heartbeat ping timeout configuration (ms) */
55
55
  pingTimeoutMs?: number;
56
+ /** Connection initialization timeout (ms). Defaults to 30000. */
57
+ connectionTimeoutMs?: number;
56
58
  }
57
59
  export type McpServerEntry = StdioServerEntry | StreamableServerEntry | SseServerEntry;
58
60
  export declare function isSseEntry(entry: McpServerEntry): entry is SseServerEntry;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "nuwax-mcp-stdio-proxy",
3
- "version": "1.4.7",
3
+ "version": "1.4.9",
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": {
7
- "nuwax-mcp-stdio-proxy": "./dist/index.js"
7
+ "nuwax-mcp-stdio-proxy": "dist/index.js"
8
8
  },
9
9
  "main": "./dist/lib.js",
10
10
  "types": "./dist/lib.d.ts",
@@ -18,7 +18,8 @@
18
18
  "test:run": "npm run build:tsc && vitest run",
19
19
  "test:coverage": "npm run build:tsc && vitest run --coverage",
20
20
  "prepublishOnly": "npm run build",
21
- "publish:non-latest": "npm publish --tag beta"
21
+ "publish:non-latest": "npm publish --tag beta",
22
+ "publish:prerelease": "npm publish --tag prerelease"
22
23
  },
23
24
  "dependencies": {
24
25
  "@modelcontextprotocol/sdk": "^1.27.1"