nuwax-mcp-stdio-proxy 1.4.8 → 1.4.10

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
  };
@@ -23040,7 +23118,15 @@ var ResilientTransportWrapper = class {
23040
23118
  mcpClient = null;
23041
23119
  heartbeatTimer = null;
23042
23120
  consecutiveFailures = 0;
23121
+ /** Captured MCP initialize request for replay on reconnect */
23122
+ initializeMessage = null;
23123
+ /** Captured MCP notifications/initialized for replay on reconnect */
23124
+ initializedNotification = null;
23125
+ /** Pending re-initialize promise resolver */
23126
+ pendingReInit = null;
23043
23127
  state = "idle";
23128
+ /** Current retry attempt count (reset on successful connect) */
23129
+ retryAttempt = 0;
23044
23130
  // Handlers required by the Transport interface
23045
23131
  onclose;
23046
23132
  onerror;
@@ -23060,9 +23146,18 @@ var ResilientTransportWrapper = class {
23060
23146
  ...options.pingTimeoutMs !== void 0 && { pingTimeoutMs: options.pingTimeoutMs },
23061
23147
  ...options.maxConsecutiveFailures !== void 0 && { maxConsecutiveFailures: options.maxConsecutiveFailures },
23062
23148
  ...options.reconnectDelayMs !== void 0 && { reconnectDelayMs: options.reconnectDelayMs },
23149
+ ...options.maxReconnectDelayMs !== void 0 && { maxReconnectDelayMs: options.maxReconnectDelayMs },
23063
23150
  ...options.maxQueueSize !== void 0 && { maxQueueSize: options.maxQueueSize }
23064
23151
  };
23065
23152
  }
23153
+ /**
23154
+ * Calculate backoff delay using capped exponential backoff.
23155
+ * 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
23156
+ */
23157
+ getBackoffDelay() {
23158
+ const delay = this.options.reconnectDelayMs * Math.pow(2, this.retryAttempt);
23159
+ return Math.min(delay, this.options.maxReconnectDelayMs);
23160
+ }
23066
23161
  /**
23067
23162
  * Initializes the transport and connects to the backend
23068
23163
  *
@@ -23072,7 +23167,7 @@ var ResilientTransportWrapper = class {
23072
23167
  * internally calls transport.start(), and we also call it explicitly in bridge.ts.
23073
23168
  */
23074
23169
  async start() {
23075
- if (this.state === "connected" || this.state === "connecting") {
23170
+ if (this.state === "connected" || this.state === "connecting" || this.state === "reconnecting") {
23076
23171
  return;
23077
23172
  }
23078
23173
  this.state = "connecting";
@@ -23087,6 +23182,7 @@ var ResilientTransportWrapper = class {
23087
23182
  this.startHeartbeat();
23088
23183
  }
23089
23184
  async performConnect(initial = false) {
23185
+ if (this.state === "closed") return;
23090
23186
  try {
23091
23187
  this.activeTransport = await this.options.connectParams();
23092
23188
  this.bindInnerTransport(this.activeTransport);
@@ -23094,18 +23190,77 @@ var ResilientTransportWrapper = class {
23094
23190
  this.state = "connected";
23095
23191
  this.consecutiveFailures = 0;
23096
23192
  this.consecutivePingTimeouts = 0;
23097
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
23193
+ this.heartbeatOkCount = 0;
23194
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u2705 Connected via ${this.activeTransport.constructor.name}`);
23195
+ if (!initial && this.initializeMessage) {
23196
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Re-initializing MCP session...`);
23197
+ try {
23198
+ await this.performReInitialize();
23199
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u2705 MCP session re-initialized`);
23200
+ } catch (err) {
23201
+ this.retryAttempt++;
23202
+ const delay = this.getBackoffDelay();
23203
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] \u274C Re-initialize failed: ${err}`);
23204
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
23205
+ this.state = "reconnecting";
23206
+ this.cleanupTransport();
23207
+ setTimeout(() => {
23208
+ this.performConnect();
23209
+ }, delay);
23210
+ return;
23211
+ }
23212
+ }
23213
+ this.retryAttempt = 0;
23098
23214
  this.flushQueue();
23099
23215
  if (!initial) {
23100
23216
  this.startHeartbeat();
23101
23217
  }
23102
23218
  } 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();
23219
+ const delay = this.getBackoffDelay();
23220
+ this.retryAttempt++;
23221
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] \u274C Connect failed (attempt ${this.retryAttempt}): ${err}`);
23222
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Retrying in ${delay}ms...`);
23223
+ this.state = "reconnecting";
23224
+ setTimeout(() => {
23225
+ this.performConnect();
23226
+ }, delay);
23227
+ }
23228
+ }
23229
+ /**
23230
+ * Replay the MCP initialize handshake on a reconnected transport.
23231
+ * Sends the captured `initialize` request with a unique internal ID,
23232
+ * waits for the response, then sends `notifications/initialized`.
23233
+ */
23234
+ async performReInitialize() {
23235
+ if (!this.activeTransport || !this.initializeMessage) {
23236
+ throw new Error("No transport or no captured initialize message");
23237
+ }
23238
+ const initId = `respl-init-${Date.now()}`;
23239
+ const initRequest = {
23240
+ ...this.initializeMessage,
23241
+ id: initId
23242
+ };
23243
+ const initPromise = new Promise((resolve) => {
23244
+ this.pendingReInit = resolve;
23245
+ setTimeout(() => {
23246
+ if (this.pendingReInit) {
23247
+ this.pendingReInit = null;
23248
+ resolve(false);
23249
+ }
23250
+ }, this.options.pingTimeoutMs);
23251
+ });
23252
+ try {
23253
+ await this.activeTransport.send(initRequest);
23254
+ } catch (err) {
23255
+ this.pendingReInit = null;
23256
+ throw err;
23257
+ }
23258
+ const success2 = await initPromise;
23259
+ if (!success2) {
23260
+ throw new Error("Re-initialize timed out");
23261
+ }
23262
+ if (this.initializedNotification) {
23263
+ await this.activeTransport.send(this.initializedNotification);
23109
23264
  }
23110
23265
  }
23111
23266
  bindInnerTransport(transport) {
@@ -23126,6 +23281,13 @@ var ResilientTransportWrapper = class {
23126
23281
  }
23127
23282
  };
23128
23283
  transport.onmessage = (message) => {
23284
+ if ("id" in message && typeof message.id === "string" && message.id.startsWith("respl-init-")) {
23285
+ if (this.pendingReInit) {
23286
+ this.pendingReInit(true);
23287
+ this.pendingReInit = null;
23288
+ }
23289
+ return;
23290
+ }
23129
23291
  if ("id" in message && typeof message.id === "string" && message.id.startsWith("respl-ping-")) {
23130
23292
  const resolve = this.pendingPings.get(message.id);
23131
23293
  if (resolve) {
@@ -23154,6 +23316,8 @@ var ResilientTransportWrapper = class {
23154
23316
  }
23155
23317
  /** Track consecutive ping timeouts (no response at all, not even an error) */
23156
23318
  consecutivePingTimeouts = 0;
23319
+ /** Successful heartbeat counter (for reducing log volume) */
23320
+ heartbeatOkCount = 0;
23157
23321
  async checkHealth() {
23158
23322
  if (this.state !== "connected" || !this.activeTransport) return;
23159
23323
  try {
@@ -23168,7 +23332,6 @@ var ResilientTransportWrapper = class {
23168
23332
  }, this.options.pingTimeoutMs);
23169
23333
  });
23170
23334
  try {
23171
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F493} Sending heartbeat (id: ${healthId})`);
23172
23335
  await this.activeTransport.send({
23173
23336
  jsonrpc: "2.0",
23174
23337
  id: healthId,
@@ -23181,30 +23344,32 @@ var ResilientTransportWrapper = class {
23181
23344
  const success2 = await responsePromise;
23182
23345
  if (!success2) {
23183
23346
  this.consecutivePingTimeouts++;
23184
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
23347
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u23F1\uFE0F Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
23185
23348
  if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
23186
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Force reconnecting.`);
23349
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Closing and retrying...`);
23187
23350
  this.triggerReconnect();
23188
23351
  }
23189
23352
  return;
23190
23353
  }
23191
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F496} Heartbeat OK (id: ${healthId})`);
23354
+ this.heartbeatOkCount++;
23355
+ if (this.heartbeatOkCount % 5 === 1 || this.consecutiveFailures > 0 || this.consecutivePingTimeouts > 0) {
23356
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F496} Heartbeat OK (count: ${this.heartbeatOkCount})`);
23357
+ }
23192
23358
  this.consecutiveFailures = 0;
23193
23359
  this.consecutivePingTimeouts = 0;
23194
23360
  } catch (err) {
23195
23361
  this.consecutiveFailures++;
23196
23362
  this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F494} Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
23197
23363
  if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
23198
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
23364
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Closing and retrying...`);
23199
23365
  this.triggerReconnect();
23200
23366
  }
23201
23367
  }
23202
23368
  }
23203
- triggerReconnect() {
23204
- 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
- this.state = "reconnecting";
23207
- this.stopHeartbeat();
23369
+ /**
23370
+ * Detach handlers from and close the current active transport.
23371
+ */
23372
+ cleanupTransport() {
23208
23373
  if (this.activeTransport) {
23209
23374
  this.activeTransport.onclose = void 0;
23210
23375
  this.activeTransport.onerror = void 0;
@@ -23214,9 +23379,21 @@ var ResilientTransportWrapper = class {
23214
23379
  }
23215
23380
  this.activeTransport = null;
23216
23381
  }
23382
+ }
23383
+ /**
23384
+ * Close the current transport and schedule a reconnect with exponential backoff.
23385
+ */
23386
+ triggerReconnect() {
23387
+ if (this.state === "reconnecting" || this.state === "closed") return;
23388
+ this.state = "reconnecting";
23389
+ this.stopHeartbeat();
23390
+ this.cleanupTransport();
23391
+ const delay = this.getBackoffDelay();
23392
+ this.retryAttempt++;
23393
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Closed. Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
23217
23394
  setTimeout(() => {
23218
23395
  this.performConnect();
23219
- }, this.options.reconnectDelayMs);
23396
+ }, delay);
23220
23397
  }
23221
23398
  async flushQueue() {
23222
23399
  if (!this.activeTransport || this.state !== "connected") return;
@@ -23251,6 +23428,10 @@ var ResilientTransportWrapper = class {
23251
23428
  this.messageQueue.push(message);
23252
23429
  return;
23253
23430
  }
23431
+ if ("method" in message) {
23432
+ if (message.method === "initialize") this.initializeMessage = message;
23433
+ else if (message.method === "notifications/initialized") this.initializedNotification = message;
23434
+ }
23254
23435
  if (!this.activeTransport) {
23255
23436
  throw new Error("No active transport to send message");
23256
23437
  }
@@ -24114,7 +24295,7 @@ var StdioServerTransport = class {
24114
24295
 
24115
24296
  // src/constants.ts
24116
24297
  var PKG_NAME = "nuwax-mcp-stdio-proxy";
24117
- var PKG_VERSION = "1.4.8";
24298
+ var PKG_VERSION = "1.4.10";
24118
24299
 
24119
24300
  // src/shared.ts
24120
24301
  async function discoverTools(client) {
@@ -26324,7 +26505,7 @@ function parseConfigJson(json2) {
26324
26505
  }
26325
26506
  function parseConfigFile(filePath) {
26326
26507
  try {
26327
- const content = fs2.readFileSync(filePath, "utf-8");
26508
+ const content = fs3.readFileSync(filePath, "utf-8");
26328
26509
  const config2 = JSON.parse(content);
26329
26510
  if (!config2.mcpServers || typeof config2.mcpServers !== "object") {
26330
26511
  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 */
@@ -42,13 +47,26 @@ export declare class ResilientTransportWrapper implements Transport {
42
47
  private mcpClient;
43
48
  private heartbeatTimer;
44
49
  private consecutiveFailures;
50
+ /** Captured MCP initialize request for replay on reconnect */
51
+ private initializeMessage;
52
+ /** Captured MCP notifications/initialized for replay on reconnect */
53
+ private initializedNotification;
54
+ /** Pending re-initialize promise resolver */
55
+ private pendingReInit;
45
56
  private state;
57
+ /** Current retry attempt count (reset on successful connect) */
58
+ private retryAttempt;
46
59
  onclose?: () => void;
47
60
  onerror?: (error: Error) => void;
48
61
  onmessage?: (message: JSONRPCMessage) => void;
49
62
  private messageQueue;
50
63
  private pendingPings;
51
64
  constructor(options: ResilientTransportOptions);
65
+ /**
66
+ * Calculate backoff delay using capped exponential backoff.
67
+ * 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
68
+ */
69
+ private getBackoffDelay;
52
70
  /**
53
71
  * Initializes the transport and connects to the backend
54
72
  *
@@ -65,12 +83,27 @@ export declare class ResilientTransportWrapper implements Transport {
65
83
  */
66
84
  enableHeartbeat(): void;
67
85
  private performConnect;
86
+ /**
87
+ * Replay the MCP initialize handshake on a reconnected transport.
88
+ * Sends the captured `initialize` request with a unique internal ID,
89
+ * waits for the response, then sends `notifications/initialized`.
90
+ */
91
+ private performReInitialize;
68
92
  private bindInnerTransport;
69
93
  private startHeartbeat;
70
94
  private stopHeartbeat;
71
95
  /** Track consecutive ping timeouts (no response at all, not even an error) */
72
96
  private consecutivePingTimeouts;
97
+ /** Successful heartbeat counter (for reducing log volume) */
98
+ private heartbeatOkCount;
73
99
  private checkHealth;
100
+ /**
101
+ * Detach handlers from and close the current active transport.
102
+ */
103
+ private cleanupTransport;
104
+ /**
105
+ * Close the current transport and schedule a reconnect with exponential backoff.
106
+ */
74
107
  private triggerReconnect;
75
108
  private flushQueue;
76
109
  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,15 @@ export class ResilientTransportWrapper {
26
30
  mcpClient = null;
27
31
  heartbeatTimer = null;
28
32
  consecutiveFailures = 0;
33
+ /** Captured MCP initialize request for replay on reconnect */
34
+ initializeMessage = null;
35
+ /** Captured MCP notifications/initialized for replay on reconnect */
36
+ initializedNotification = null;
37
+ /** Pending re-initialize promise resolver */
38
+ pendingReInit = null;
29
39
  state = 'idle';
40
+ /** Current retry attempt count (reset on successful connect) */
41
+ retryAttempt = 0;
30
42
  // Handlers required by the Transport interface
31
43
  onclose;
32
44
  onerror;
@@ -50,9 +62,18 @@ export class ResilientTransportWrapper {
50
62
  ...(options.pingTimeoutMs !== undefined && { pingTimeoutMs: options.pingTimeoutMs }),
51
63
  ...(options.maxConsecutiveFailures !== undefined && { maxConsecutiveFailures: options.maxConsecutiveFailures }),
52
64
  ...(options.reconnectDelayMs !== undefined && { reconnectDelayMs: options.reconnectDelayMs }),
65
+ ...(options.maxReconnectDelayMs !== undefined && { maxReconnectDelayMs: options.maxReconnectDelayMs }),
53
66
  ...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
54
67
  };
55
68
  }
69
+ /**
70
+ * Calculate backoff delay using capped exponential backoff.
71
+ * 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
72
+ */
73
+ getBackoffDelay() {
74
+ const delay = this.options.reconnectDelayMs * Math.pow(2, this.retryAttempt);
75
+ return Math.min(delay, this.options.maxReconnectDelayMs);
76
+ }
56
77
  /**
57
78
  * Initializes the transport and connects to the backend
58
79
  *
@@ -62,8 +83,8 @@ export class ResilientTransportWrapper {
62
83
  * internally calls transport.start(), and we also call it explicitly in bridge.ts.
63
84
  */
64
85
  async start() {
65
- // Idempotent check: if already connected or connecting, return early
66
- if (this.state === 'connected' || this.state === 'connecting') {
86
+ // Idempotent check: if already connected, connecting, or retrying, return early
87
+ if (this.state === 'connected' || this.state === 'connecting' || this.state === 'reconnecting') {
67
88
  return;
68
89
  }
69
90
  this.state = 'connecting';
@@ -78,6 +99,8 @@ export class ResilientTransportWrapper {
78
99
  this.startHeartbeat();
79
100
  }
80
101
  async performConnect(initial = false) {
102
+ if (this.state === 'closed')
103
+ return;
81
104
  try {
82
105
  this.activeTransport = await this.options.connectParams();
83
106
  // Inherit the handlers from this wrapper
@@ -86,7 +109,33 @@ export class ResilientTransportWrapper {
86
109
  this.state = 'connected';
87
110
  this.consecutiveFailures = 0;
88
111
  this.consecutivePingTimeouts = 0;
89
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
112
+ this.heartbeatOkCount = 0;
113
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ Connected via ${this.activeTransport.constructor.name}`);
114
+ // On reconnect, replay MCP initialize handshake before doing anything else.
115
+ // The server requires initialize + notifications/initialized before accepting
116
+ // any other requests (tools/list, etc).
117
+ if (!initial && this.initializeMessage) {
118
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Re-initializing MCP session...`);
119
+ try {
120
+ await this.performReInitialize();
121
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ MCP session re-initialized`);
122
+ }
123
+ catch (err) {
124
+ // Re-initialize failed — treat as a connect failure, preserve backoff
125
+ this.retryAttempt++;
126
+ const delay = this.getBackoffDelay();
127
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] ❌ Re-initialize failed: ${err}`);
128
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
129
+ this.state = 'reconnecting';
130
+ this.cleanupTransport();
131
+ setTimeout(() => {
132
+ this.performConnect();
133
+ }, delay);
134
+ return;
135
+ }
136
+ }
137
+ // Reset retry count only after successful connect + re-initialize
138
+ this.retryAttempt = 0;
90
139
  // Flush any queued messages
91
140
  this.flushQueue();
92
141
  // Only start heartbeat on reconnects — initial connections need
@@ -98,12 +147,54 @@ export class ResilientTransportWrapper {
98
147
  }
99
148
  }
100
149
  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
150
+ const delay = this.getBackoffDelay();
151
+ this.retryAttempt++;
152
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] ❌ Connect failed (attempt ${this.retryAttempt}): ${err}`);
153
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Retrying in ${delay}ms...`);
154
+ this.state = 'reconnecting';
155
+ setTimeout(() => {
156
+ this.performConnect();
157
+ }, delay);
158
+ }
159
+ }
160
+ /**
161
+ * Replay the MCP initialize handshake on a reconnected transport.
162
+ * Sends the captured `initialize` request with a unique internal ID,
163
+ * waits for the response, then sends `notifications/initialized`.
164
+ */
165
+ async performReInitialize() {
166
+ if (!this.activeTransport || !this.initializeMessage) {
167
+ throw new Error('No transport or no captured initialize message');
168
+ }
169
+ const initId = `respl-init-${Date.now()}`;
170
+ // Build the re-initialize request using the original params but with our internal ID
171
+ const initRequest = {
172
+ ...this.initializeMessage,
173
+ id: initId,
174
+ };
175
+ const initPromise = new Promise((resolve) => {
176
+ this.pendingReInit = resolve;
177
+ setTimeout(() => {
178
+ if (this.pendingReInit) {
179
+ this.pendingReInit = null;
180
+ resolve(false); // Timeout
181
+ }
182
+ }, this.options.pingTimeoutMs);
183
+ });
184
+ try {
185
+ await this.activeTransport.send(initRequest);
186
+ }
187
+ catch (err) {
188
+ this.pendingReInit = null;
189
+ throw err;
190
+ }
191
+ const success = await initPromise;
192
+ if (!success) {
193
+ throw new Error('Re-initialize timed out');
194
+ }
195
+ // Send notifications/initialized if we captured it
196
+ if (this.initializedNotification) {
197
+ await this.activeTransport.send(this.initializedNotification);
107
198
  }
108
199
  }
109
200
  bindInnerTransport(transport) {
@@ -127,6 +218,14 @@ export class ResilientTransportWrapper {
127
218
  }
128
219
  };
129
220
  transport.onmessage = (message) => {
221
+ // Intercept re-initialize responses (don't forward to Client)
222
+ if ('id' in message && typeof message.id === 'string' && message.id.startsWith('respl-init-')) {
223
+ if (this.pendingReInit) {
224
+ this.pendingReInit(true);
225
+ this.pendingReInit = null;
226
+ }
227
+ return;
228
+ }
130
229
  // Intercept our own heartbeat responses (tools/list used as health check)
131
230
  if ('id' in message && typeof message.id === 'string' && message.id.startsWith('respl-ping-')) {
132
231
  const resolve = this.pendingPings.get(message.id);
@@ -158,6 +257,8 @@ export class ResilientTransportWrapper {
158
257
  }
159
258
  /** Track consecutive ping timeouts (no response at all, not even an error) */
160
259
  consecutivePingTimeouts = 0;
260
+ /** Successful heartbeat counter (for reducing log volume) */
261
+ heartbeatOkCount = 0;
161
262
  async checkHealth() {
162
263
  if (this.state !== 'connected' || !this.activeTransport)
163
264
  return;
@@ -177,7 +278,6 @@ export class ResilientTransportWrapper {
177
278
  });
178
279
  // Try to send tools/list — if send() throws, the transport itself is broken
179
280
  try {
180
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat (id: ${healthId})`);
181
281
  await this.activeTransport.send({
182
282
  jsonrpc: '2.0',
183
283
  id: healthId,
@@ -191,15 +291,19 @@ export class ResilientTransportWrapper {
191
291
  const success = await responsePromise;
192
292
  if (!success) {
193
293
  this.consecutivePingTimeouts++;
194
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
294
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] ⏱️ Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
195
295
  if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
196
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Force reconnecting.`);
296
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Closing and retrying...`);
197
297
  this.triggerReconnect();
198
298
  }
199
299
  return;
200
300
  }
201
301
  // Got a response — server is alive
202
- this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat OK (id: ${healthId})`);
302
+ this.heartbeatOkCount++;
303
+ // Only log every 5th success to reduce log volume (~100s interval)
304
+ if (this.heartbeatOkCount % 5 === 1 || this.consecutiveFailures > 0 || this.consecutivePingTimeouts > 0) {
305
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat OK (count: ${this.heartbeatOkCount})`);
306
+ }
203
307
  this.consecutiveFailures = 0;
204
308
  this.consecutivePingTimeouts = 0;
205
309
  }
@@ -207,20 +311,16 @@ export class ResilientTransportWrapper {
207
311
  this.consecutiveFailures++;
208
312
  this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
209
313
  if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
210
- this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
314
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Closing and retrying...`);
211
315
  this.triggerReconnect();
212
316
  }
213
317
  }
214
318
  }
215
- triggerReconnect() {
216
- if (this.state === 'reconnecting' || this.state === 'closed')
217
- return;
218
- this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Triggering reconnect (previous state: ${this.state})`);
219
- this.state = 'reconnecting';
220
- this.stopHeartbeat();
221
- // Clean up old transport
319
+ /**
320
+ * Detach handlers from and close the current active transport.
321
+ */
322
+ cleanupTransport() {
222
323
  if (this.activeTransport) {
223
- // Avoid triggering our own onclose
224
324
  this.activeTransport.onclose = undefined;
225
325
  this.activeTransport.onerror = undefined;
226
326
  try {
@@ -229,9 +329,22 @@ export class ResilientTransportWrapper {
229
329
  catch { /* ignore */ }
230
330
  this.activeTransport = null;
231
331
  }
332
+ }
333
+ /**
334
+ * Close the current transport and schedule a reconnect with exponential backoff.
335
+ */
336
+ triggerReconnect() {
337
+ if (this.state === 'reconnecting' || this.state === 'closed')
338
+ return;
339
+ this.state = 'reconnecting';
340
+ this.stopHeartbeat();
341
+ this.cleanupTransport();
342
+ const delay = this.getBackoffDelay();
343
+ this.retryAttempt++;
344
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Closed. Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
232
345
  setTimeout(() => {
233
346
  this.performConnect();
234
- }, this.options.reconnectDelayMs);
347
+ }, delay);
235
348
  }
236
349
  async flushQueue() {
237
350
  if (!this.activeTransport || this.state !== 'connected')
@@ -270,6 +383,13 @@ export class ResilientTransportWrapper {
270
383
  this.messageQueue.push(message);
271
384
  return;
272
385
  }
386
+ // Capture initialize handshake messages for replay on reconnect
387
+ if ('method' in message) {
388
+ if (message.method === 'initialize')
389
+ this.initializeMessage = message;
390
+ else if (message.method === 'notifications/initialized')
391
+ this.initializedNotification = message;
392
+ }
273
393
  if (!this.activeTransport) {
274
394
  throw new Error('No active transport to send message');
275
395
  }
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.10",
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"