nuwax-mcp-stdio-proxy 1.4.8 → 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/index.js CHANGED
@@ -3222,8 +3222,8 @@ var require_utils = __commonJS({
3222
3222
  }
3223
3223
  return ind;
3224
3224
  }
3225
- function removeDotSegments(path2) {
3226
- let input = path2;
3225
+ function removeDotSegments(path3) {
3226
+ let input = path3;
3227
3227
  const output = [];
3228
3228
  let nextSlash = -1;
3229
3229
  let len = 0;
@@ -3422,8 +3422,8 @@ var require_schemes = __commonJS({
3422
3422
  wsComponent.secure = void 0;
3423
3423
  }
3424
3424
  if (wsComponent.resourceName) {
3425
- const [path2, query] = wsComponent.resourceName.split("?");
3426
- wsComponent.path = path2 && path2 !== "/" ? path2 : void 0;
3425
+ const [path3, query] = wsComponent.resourceName.split("?");
3426
+ wsComponent.path = path3 && path3 !== "/" ? path3 : void 0;
3427
3427
  wsComponent.query = query;
3428
3428
  wsComponent.resourceName = void 0;
3429
3429
  }
@@ -6785,12 +6785,12 @@ var require_dist = __commonJS({
6785
6785
  throw new Error(`Unknown format "${name}"`);
6786
6786
  return f;
6787
6787
  };
6788
- function addFormats(ajv, list, fs3, exportName) {
6788
+ function addFormats(ajv, list, fs4, exportName) {
6789
6789
  var _a2;
6790
6790
  var _b;
6791
6791
  (_a2 = (_b = ajv.opts.code).formats) !== null && _a2 !== void 0 ? _a2 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
6792
6792
  for (const f of list)
6793
- ajv.addFormat(f, fs3[f]);
6793
+ ajv.addFormat(f, fs4[f]);
6794
6794
  }
6795
6795
  module.exports = exports = formatsPlugin;
6796
6796
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -6799,12 +6799,89 @@ var require_dist = __commonJS({
6799
6799
  });
6800
6800
 
6801
6801
  // src/index.ts
6802
- import * as fs2 from "fs";
6802
+ import * as fs3 from "fs";
6803
6803
 
6804
6804
  // src/logger.ts
6805
+ import * as fs from "fs";
6806
+ import * as path from "path";
6807
+ var logFilePath = process.env.MCP_PROXY_LOG_FILE;
6808
+ var MAX_LOG_FILES = 7;
6809
+ var logStream = null;
6810
+ var logDir = "";
6811
+ var logBaseName = "";
6812
+ var logExt = "";
6813
+ var currentDateStr = "";
6814
+ function dateStr() {
6815
+ const d = /* @__PURE__ */ new Date();
6816
+ const pad2 = (n) => String(n).padStart(2, "0");
6817
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
6818
+ }
6819
+ function openLogFile() {
6820
+ if (!logFilePath) return;
6821
+ const today = dateStr();
6822
+ if (today === currentDateStr && logStream) return;
6823
+ if (logStream) {
6824
+ try {
6825
+ logStream.end();
6826
+ } catch {
6827
+ }
6828
+ }
6829
+ currentDateStr = today;
6830
+ const dated = path.join(logDir, `${logBaseName}-${today}${logExt}`);
6831
+ try {
6832
+ logStream = fs.createWriteStream(dated, { flags: "a" });
6833
+ } catch {
6834
+ logStream = null;
6835
+ }
6836
+ cleanupOldLogs();
6837
+ }
6838
+ function cleanupOldLogs() {
6839
+ if (!logDir || !logBaseName) return;
6840
+ try {
6841
+ const prefix = `${logBaseName}-`;
6842
+ const files = fs.readdirSync(logDir).filter((f) => f.startsWith(prefix) && f.endsWith(logExt)).sort().reverse();
6843
+ for (let i = MAX_LOG_FILES; i < files.length; i++) {
6844
+ try {
6845
+ fs.unlinkSync(path.join(logDir, files[i]));
6846
+ } catch {
6847
+ }
6848
+ }
6849
+ } catch {
6850
+ }
6851
+ }
6852
+ if (logFilePath) {
6853
+ try {
6854
+ logDir = path.dirname(logFilePath);
6855
+ if (logDir && !fs.existsSync(logDir)) {
6856
+ fs.mkdirSync(logDir, { recursive: true });
6857
+ }
6858
+ const fullName = path.basename(logFilePath);
6859
+ const dotIdx = fullName.lastIndexOf(".");
6860
+ if (dotIdx > 0) {
6861
+ logBaseName = fullName.substring(0, dotIdx);
6862
+ logExt = fullName.substring(dotIdx);
6863
+ } else {
6864
+ logBaseName = fullName;
6865
+ logExt = ".log";
6866
+ }
6867
+ openLogFile();
6868
+ } catch {
6869
+ }
6870
+ }
6871
+ function timestamp() {
6872
+ const d = /* @__PURE__ */ new Date();
6873
+ const pad2 = (n) => String(n).padStart(2, "0");
6874
+ const pad3 = (n) => String(n).padStart(3, "0");
6875
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`;
6876
+ }
6805
6877
  function log(level, msg) {
6806
- process.stderr.write(`[nuwax-mcp-proxy] ${level}: ${msg}
6807
- `);
6878
+ const line = `[${timestamp()}] [${level.toLowerCase()}] [nuwax-mcp-proxy] ${msg}
6879
+ `;
6880
+ process.stderr.write(line);
6881
+ if (logFilePath) {
6882
+ openLogFile();
6883
+ logStream?.write(line);
6884
+ }
6808
6885
  }
6809
6886
  var logInfo = (msg) => log("INFO", msg);
6810
6887
  var logWarn = (msg) => log("WARN", msg);
@@ -7180,8 +7257,8 @@ function getErrorMap() {
7180
7257
 
7181
7258
  // node_modules/zod/v3/helpers/parseUtil.js
7182
7259
  var makeIssue = (params) => {
7183
- const { data, path: path2, errorMaps, issueData } = params;
7184
- const fullPath = [...path2, ...issueData.path || []];
7260
+ const { data, path: path3, errorMaps, issueData } = params;
7261
+ const fullPath = [...path3, ...issueData.path || []];
7185
7262
  const fullIssue = {
7186
7263
  ...issueData,
7187
7264
  path: fullPath
@@ -7296,11 +7373,11 @@ var errorUtil;
7296
7373
 
7297
7374
  // node_modules/zod/v3/types.js
7298
7375
  var ParseInputLazyPath = class {
7299
- constructor(parent, value, path2, key) {
7376
+ constructor(parent, value, path3, key) {
7300
7377
  this._cachedPath = [];
7301
7378
  this.parent = parent;
7302
7379
  this.data = value;
7303
- this._path = path2;
7380
+ this._path = path3;
7304
7381
  this._key = key;
7305
7382
  }
7306
7383
  get path() {
@@ -10944,10 +11021,10 @@ function mergeDefs(...defs) {
10944
11021
  function cloneDef(schema) {
10945
11022
  return mergeDefs(schema._zod.def);
10946
11023
  }
10947
- function getElementAtPath(obj, path2) {
10948
- if (!path2)
11024
+ function getElementAtPath(obj, path3) {
11025
+ if (!path3)
10949
11026
  return obj;
10950
- return path2.reduce((acc, key) => acc?.[key], obj);
11027
+ return path3.reduce((acc, key) => acc?.[key], obj);
10951
11028
  }
10952
11029
  function promiseAllObject(promisesObj) {
10953
11030
  const keys = Object.keys(promisesObj);
@@ -11330,11 +11407,11 @@ function aborted(x, startIndex = 0) {
11330
11407
  }
11331
11408
  return false;
11332
11409
  }
11333
- function prefixIssues(path2, issues) {
11410
+ function prefixIssues(path3, issues) {
11334
11411
  return issues.map((iss) => {
11335
11412
  var _a2;
11336
11413
  (_a2 = iss).path ?? (_a2.path = []);
11337
- iss.path.unshift(path2);
11414
+ iss.path.unshift(path3);
11338
11415
  return iss;
11339
11416
  });
11340
11417
  }
@@ -22810,8 +22887,8 @@ function serializeMessage(message) {
22810
22887
  }
22811
22888
 
22812
22889
  // src/customStdio.ts
22813
- import * as fs from "fs";
22814
- import * as path from "path";
22890
+ import * as fs2 from "fs";
22891
+ import * as path2 from "path";
22815
22892
  function logDebug(msg) {
22816
22893
  process.stderr.write(`[customStdio] ${msg}
22817
22894
  `);
@@ -22848,8 +22925,8 @@ var CustomStdioClientTransport = class {
22848
22925
  const pathDirs = (mergedEnv.PATH || "").split(";");
22849
22926
  for (const dir of pathDirs) {
22850
22927
  for (const ext of cmdExtensions) {
22851
- const fullPath = path.join(dir, command + ext);
22852
- if (fs.existsSync(fullPath)) {
22928
+ const fullPath = path2.join(dir, command + ext);
22929
+ if (fs2.existsSync(fullPath)) {
22853
22930
  command = fullPath;
22854
22931
  logDebug(`Resolved "${this._serverParams.command}" to "${command}"`);
22855
22932
  break;
@@ -23024,7 +23101,8 @@ var DEFAULT_OPTIONS = {
23024
23101
  pingIntervalMs: 2e4,
23025
23102
  maxConsecutiveFailures: 3,
23026
23103
  pingTimeoutMs: 5e3,
23027
- reconnectDelayMs: 3e3,
23104
+ reconnectDelayMs: 1e3,
23105
+ maxReconnectDelayMs: 6e4,
23028
23106
  maxQueueSize: 100,
23029
23107
  name: "remote"
23030
23108
  };
@@ -23041,6 +23119,8 @@ var ResilientTransportWrapper = class {
23041
23119
  heartbeatTimer = null;
23042
23120
  consecutiveFailures = 0;
23043
23121
  state = "idle";
23122
+ /** Current retry attempt count (reset on successful connect) */
23123
+ retryAttempt = 0;
23044
23124
  // Handlers required by the Transport interface
23045
23125
  onclose;
23046
23126
  onerror;
@@ -23060,9 +23140,18 @@ var ResilientTransportWrapper = class {
23060
23140
  ...options.pingTimeoutMs !== void 0 && { pingTimeoutMs: options.pingTimeoutMs },
23061
23141
  ...options.maxConsecutiveFailures !== void 0 && { maxConsecutiveFailures: options.maxConsecutiveFailures },
23062
23142
  ...options.reconnectDelayMs !== void 0 && { reconnectDelayMs: options.reconnectDelayMs },
23143
+ ...options.maxReconnectDelayMs !== void 0 && { maxReconnectDelayMs: options.maxReconnectDelayMs },
23063
23144
  ...options.maxQueueSize !== void 0 && { maxQueueSize: options.maxQueueSize }
23064
23145
  };
23065
23146
  }
23147
+ /**
23148
+ * Calculate backoff delay using capped exponential backoff.
23149
+ * 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
23150
+ */
23151
+ getBackoffDelay() {
23152
+ const delay = this.options.reconnectDelayMs * Math.pow(2, this.retryAttempt);
23153
+ return Math.min(delay, this.options.maxReconnectDelayMs);
23154
+ }
23066
23155
  /**
23067
23156
  * Initializes the transport and connects to the backend
23068
23157
  *
@@ -23072,7 +23161,7 @@ var ResilientTransportWrapper = class {
23072
23161
  * internally calls transport.start(), and we also call it explicitly in bridge.ts.
23073
23162
  */
23074
23163
  async start() {
23075
- if (this.state === "connected" || this.state === "connecting") {
23164
+ if (this.state === "connected" || this.state === "connecting" || this.state === "reconnecting") {
23076
23165
  return;
23077
23166
  }
23078
23167
  this.state = "connecting";
@@ -23087,6 +23176,7 @@ var ResilientTransportWrapper = class {
23087
23176
  this.startHeartbeat();
23088
23177
  }
23089
23178
  async performConnect(initial = false) {
23179
+ if (this.state === "closed") return;
23090
23180
  try {
23091
23181
  this.activeTransport = await this.options.connectParams();
23092
23182
  this.bindInnerTransport(this.activeTransport);
@@ -23094,18 +23184,22 @@ var ResilientTransportWrapper = class {
23094
23184
  this.state = "connected";
23095
23185
  this.consecutiveFailures = 0;
23096
23186
  this.consecutivePingTimeouts = 0;
23097
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
23187
+ this.retryAttempt = 0;
23188
+ this.heartbeatOkCount = 0;
23189
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u2705 Connected via ${this.activeTransport.constructor.name}`);
23098
23190
  this.flushQueue();
23099
23191
  if (!initial) {
23100
23192
  this.startHeartbeat();
23101
23193
  }
23102
23194
  } catch (err) {
23103
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Connect failed: ${err}`);
23104
- if (initial) {
23105
- this.state = "closed";
23106
- throw err;
23107
- }
23108
- this.triggerReconnect();
23195
+ const delay = this.getBackoffDelay();
23196
+ this.retryAttempt++;
23197
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] \u274C Connect failed (attempt ${this.retryAttempt}): ${err}`);
23198
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Retrying in ${delay}ms...`);
23199
+ this.state = "reconnecting";
23200
+ setTimeout(() => {
23201
+ this.performConnect();
23202
+ }, delay);
23109
23203
  }
23110
23204
  }
23111
23205
  bindInnerTransport(transport) {
@@ -23154,6 +23248,8 @@ var ResilientTransportWrapper = class {
23154
23248
  }
23155
23249
  /** Track consecutive ping timeouts (no response at all, not even an error) */
23156
23250
  consecutivePingTimeouts = 0;
23251
+ /** Successful heartbeat counter (for reducing log volume) */
23252
+ heartbeatOkCount = 0;
23157
23253
  async checkHealth() {
23158
23254
  if (this.state !== "connected" || !this.activeTransport) return;
23159
23255
  try {
@@ -23168,7 +23264,6 @@ var ResilientTransportWrapper = class {
23168
23264
  }, this.options.pingTimeoutMs);
23169
23265
  });
23170
23266
  try {
23171
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F493} Sending heartbeat (id: ${healthId})`);
23172
23267
  await this.activeTransport.send({
23173
23268
  jsonrpc: "2.0",
23174
23269
  id: healthId,
@@ -23181,28 +23276,33 @@ var ResilientTransportWrapper = class {
23181
23276
  const success2 = await responsePromise;
23182
23277
  if (!success2) {
23183
23278
  this.consecutivePingTimeouts++;
23184
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
23279
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u23F1\uFE0F Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
23185
23280
  if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
23186
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Force reconnecting.`);
23281
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Closing and retrying...`);
23187
23282
  this.triggerReconnect();
23188
23283
  }
23189
23284
  return;
23190
23285
  }
23191
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F496} Heartbeat OK (id: ${healthId})`);
23286
+ this.heartbeatOkCount++;
23287
+ if (this.heartbeatOkCount % 5 === 1 || this.consecutiveFailures > 0 || this.consecutivePingTimeouts > 0) {
23288
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F496} Heartbeat OK (count: ${this.heartbeatOkCount})`);
23289
+ }
23192
23290
  this.consecutiveFailures = 0;
23193
23291
  this.consecutivePingTimeouts = 0;
23194
23292
  } catch (err) {
23195
23293
  this.consecutiveFailures++;
23196
23294
  this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F494} Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
23197
23295
  if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
23198
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
23296
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Closing and retrying...`);
23199
23297
  this.triggerReconnect();
23200
23298
  }
23201
23299
  }
23202
23300
  }
23301
+ /**
23302
+ * Close the current transport and schedule a reconnect with exponential backoff.
23303
+ */
23203
23304
  triggerReconnect() {
23204
23305
  if (this.state === "reconnecting" || this.state === "closed") return;
23205
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Triggering reconnect (previous state: ${this.state})`);
23206
23306
  this.state = "reconnecting";
23207
23307
  this.stopHeartbeat();
23208
23308
  if (this.activeTransport) {
@@ -23214,9 +23314,12 @@ var ResilientTransportWrapper = class {
23214
23314
  }
23215
23315
  this.activeTransport = null;
23216
23316
  }
23317
+ const delay = this.getBackoffDelay();
23318
+ this.retryAttempt++;
23319
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Closed. Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
23217
23320
  setTimeout(() => {
23218
23321
  this.performConnect();
23219
- }, this.options.reconnectDelayMs);
23322
+ }, delay);
23220
23323
  }
23221
23324
  async flushQueue() {
23222
23325
  if (!this.activeTransport || this.state !== "connected") return;
@@ -24114,7 +24217,7 @@ var StdioServerTransport = class {
24114
24217
 
24115
24218
  // src/constants.ts
24116
24219
  var PKG_NAME = "nuwax-mcp-stdio-proxy";
24117
- var PKG_VERSION = "1.4.8";
24220
+ var PKG_VERSION = "1.4.9";
24118
24221
 
24119
24222
  // src/shared.ts
24120
24223
  async function discoverTools(client) {
@@ -26324,7 +26427,7 @@ function parseConfigJson(json2) {
26324
26427
  }
26325
26428
  function parseConfigFile(filePath) {
26326
26429
  try {
26327
- const content = fs2.readFileSync(filePath, "utf-8");
26430
+ const content = fs3.readFileSync(filePath, "utf-8");
26328
26431
  const config2 = JSON.parse(content);
26329
26432
  if (!config2.mcpServers || typeof config2.mcpServers !== "object") {
26330
26433
  throw new Error('config must contain a "mcpServers" object');
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);
@@ -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
  *
@@ -70,7 +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;
85
+ /** Successful heartbeat counter (for reducing log volume) */
86
+ private heartbeatOkCount;
73
87
  private checkHealth;
88
+ /**
89
+ * Close the current transport and schedule a reconnect with exponential backoff.
90
+ */
74
91
  private triggerReconnect;
75
92
  private flushQueue;
76
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
  };
@@ -27,6 +31,8 @@ export class ResilientTransportWrapper {
27
31
  heartbeatTimer = null;
28
32
  consecutiveFailures = 0;
29
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;
@@ -50,9 +56,18 @@ export class ResilientTransportWrapper {
50
56
  ...(options.pingTimeoutMs !== undefined && { pingTimeoutMs: options.pingTimeoutMs }),
51
57
  ...(options.maxConsecutiveFailures !== undefined && { maxConsecutiveFailures: options.maxConsecutiveFailures }),
52
58
  ...(options.reconnectDelayMs !== undefined && { reconnectDelayMs: options.reconnectDelayMs }),
59
+ ...(options.maxReconnectDelayMs !== undefined && { maxReconnectDelayMs: options.maxReconnectDelayMs }),
53
60
  ...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
54
61
  };
55
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);
70
+ }
56
71
  /**
57
72
  * Initializes the transport and connects to the backend
58
73
  *
@@ -62,8 +77,8 @@ export class ResilientTransportWrapper {
62
77
  * internally calls transport.start(), and we also call it explicitly in bridge.ts.
63
78
  */
64
79
  async start() {
65
- // Idempotent check: if already connected or connecting, return early
66
- 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') {
67
82
  return;
68
83
  }
69
84
  this.state = 'connecting';
@@ -78,6 +93,8 @@ export class ResilientTransportWrapper {
78
93
  this.startHeartbeat();
79
94
  }
80
95
  async performConnect(initial = false) {
96
+ if (this.state === 'closed')
97
+ return;
81
98
  try {
82
99
  this.activeTransport = await this.options.connectParams();
83
100
  // Inherit the handlers from this wrapper
@@ -86,7 +103,9 @@ export class ResilientTransportWrapper {
86
103
  this.state = 'connected';
87
104
  this.consecutiveFailures = 0;
88
105
  this.consecutivePingTimeouts = 0;
89
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
106
+ this.retryAttempt = 0;
107
+ this.heartbeatOkCount = 0;
108
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ Connected via ${this.activeTransport.constructor.name}`);
90
109
  // Flush any queued messages
91
110
  this.flushQueue();
92
111
  // Only start heartbeat on reconnects — initial connections need
@@ -98,12 +117,14 @@ export class ResilientTransportWrapper {
98
117
  }
99
118
  }
100
119
  catch (err) {
101
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Connect failed: ${err}`);
102
- if (initial) {
103
- this.state = 'closed';
104
- throw err;
105
- }
106
- 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);
107
128
  }
108
129
  }
109
130
  bindInnerTransport(transport) {
@@ -158,6 +179,8 @@ export class ResilientTransportWrapper {
158
179
  }
159
180
  /** Track consecutive ping timeouts (no response at all, not even an error) */
160
181
  consecutivePingTimeouts = 0;
182
+ /** Successful heartbeat counter (for reducing log volume) */
183
+ heartbeatOkCount = 0;
161
184
  async checkHealth() {
162
185
  if (this.state !== 'connected' || !this.activeTransport)
163
186
  return;
@@ -177,7 +200,6 @@ export class ResilientTransportWrapper {
177
200
  });
178
201
  // Try to send tools/list — if send() throws, the transport itself is broken
179
202
  try {
180
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat (id: ${healthId})`);
181
203
  await this.activeTransport.send({
182
204
  jsonrpc: '2.0',
183
205
  id: healthId,
@@ -191,15 +213,19 @@ export class ResilientTransportWrapper {
191
213
  const success = await responsePromise;
192
214
  if (!success) {
193
215
  this.consecutivePingTimeouts++;
194
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
216
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] ⏱️ Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
195
217
  if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
196
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Force reconnecting.`);
218
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Closing and retrying...`);
197
219
  this.triggerReconnect();
198
220
  }
199
221
  return;
200
222
  }
201
223
  // Got a response — server is alive
202
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat OK (id: ${healthId})`);
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
+ }
203
229
  this.consecutiveFailures = 0;
204
230
  this.consecutivePingTimeouts = 0;
205
231
  }
@@ -207,15 +233,17 @@ export class ResilientTransportWrapper {
207
233
  this.consecutiveFailures++;
208
234
  this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
209
235
  if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
210
- 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...`);
211
237
  this.triggerReconnect();
212
238
  }
213
239
  }
214
240
  }
241
+ /**
242
+ * Close the current transport and schedule a reconnect with exponential backoff.
243
+ */
215
244
  triggerReconnect() {
216
245
  if (this.state === 'reconnecting' || this.state === 'closed')
217
246
  return;
218
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Triggering reconnect (previous state: ${this.state})`);
219
247
  this.state = 'reconnecting';
220
248
  this.stopHeartbeat();
221
249
  // Clean up old transport
@@ -229,9 +257,12 @@ export class ResilientTransportWrapper {
229
257
  catch { /* ignore */ }
230
258
  this.activeTransport = null;
231
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})...`);
232
263
  setTimeout(() => {
233
264
  this.performConnect();
234
- }, this.options.reconnectDelayMs);
265
+ }, delay);
235
266
  }
236
267
  async flushQueue() {
237
268
  if (!this.activeTransport || this.state !== 'connected')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuwax-mcp-stdio-proxy",
3
- "version": "1.4.8",
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": {
@@ -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"