nuwax-mcp-stdio-proxy 1.4.6 → 1.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -23019,7 +23019,272 @@ function getDefaultEnvironment() {
23019
23019
  return env;
23020
23020
  }
23021
23021
 
23022
+ // src/resilient.ts
23023
+ var DEFAULT_OPTIONS = {
23024
+ pingIntervalMs: 2e4,
23025
+ maxConsecutiveFailures: 3,
23026
+ pingTimeoutMs: 5e3,
23027
+ reconnectDelayMs: 3e3,
23028
+ maxQueueSize: 100,
23029
+ name: "remote"
23030
+ };
23031
+ var defaultLogger = {
23032
+ info: (...args) => logInfo(args.map(String).join(" ")),
23033
+ warn: (...args) => logWarn(args.map(String).join(" ")),
23034
+ error: (...args) => logError(args.map(String).join(" "))
23035
+ };
23036
+ var ResilientTransportWrapper = class {
23037
+ options;
23038
+ log;
23039
+ activeTransport = null;
23040
+ mcpClient = null;
23041
+ heartbeatTimer = null;
23042
+ consecutiveFailures = 0;
23043
+ state = "connecting";
23044
+ // Handlers required by the Transport interface
23045
+ onclose;
23046
+ onerror;
23047
+ onmessage;
23048
+ // Queue for messages sent while reconnecting
23049
+ messageQueue = [];
23050
+ // Pending ping requests awaiting a response
23051
+ pendingPings = /* @__PURE__ */ new Map();
23052
+ constructor(options) {
23053
+ this.log = options.logger ?? defaultLogger;
23054
+ this.options = { ...DEFAULT_OPTIONS, logger: this.log, ...options };
23055
+ }
23056
+ /**
23057
+ * Initializes the transport and connects to the backend
23058
+ *
23059
+ * This method is idempotent - if the transport is already connected or
23060
+ * connecting, subsequent calls will return immediately without creating
23061
+ * duplicate connections. This is important because MCP SDK's client.connect()
23062
+ * internally calls transport.start(), and we also call it explicitly in bridge.ts.
23063
+ */
23064
+ async start() {
23065
+ if (this.state === "connected" || this.state === "connecting") {
23066
+ return;
23067
+ }
23068
+ this.state = "connecting";
23069
+ await this.performConnect(true);
23070
+ }
23071
+ /**
23072
+ * Enable heartbeat monitoring. Call this AFTER the MCP client has
23073
+ * completed its initialize handshake (client.connect()), otherwise
23074
+ * the server will reject ping requests with "Server not initialized".
23075
+ */
23076
+ enableHeartbeat() {
23077
+ this.startHeartbeat();
23078
+ }
23079
+ async performConnect(initial = false) {
23080
+ try {
23081
+ this.activeTransport = await this.options.connectParams();
23082
+ this.bindInnerTransport(this.activeTransport);
23083
+ await this.activeTransport.start();
23084
+ this.state = "connected";
23085
+ this.consecutiveFailures = 0;
23086
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
23087
+ this.flushQueue();
23088
+ if (!initial) {
23089
+ this.startHeartbeat();
23090
+ }
23091
+ } catch (err) {
23092
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Connect failed: ${err}`);
23093
+ if (initial) {
23094
+ this.state = "closed";
23095
+ throw err;
23096
+ }
23097
+ this.triggerReconnect();
23098
+ }
23099
+ }
23100
+ bindInnerTransport(transport) {
23101
+ transport.onclose = () => {
23102
+ if (this.state !== "closed") {
23103
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Inner transport closed unexpectedly. Reconnecting...`);
23104
+ this.triggerReconnect();
23105
+ }
23106
+ };
23107
+ transport.onerror = (error2) => {
23108
+ if (this.state !== "closed") {
23109
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Inner transport error: ${error2.message}`);
23110
+ if (this.state === "connecting") {
23111
+ if (this.onerror) this.onerror(error2);
23112
+ return;
23113
+ }
23114
+ this.triggerReconnect();
23115
+ }
23116
+ };
23117
+ transport.onmessage = (message) => {
23118
+ if ("id" in message && typeof message.id === "string" && message.id.startsWith("respl-ping-")) {
23119
+ const resolve = this.pendingPings.get(message.id);
23120
+ if (resolve) {
23121
+ resolve(true);
23122
+ this.pendingPings.delete(message.id);
23123
+ }
23124
+ return;
23125
+ }
23126
+ if (this.onmessage) {
23127
+ this.onmessage(message);
23128
+ }
23129
+ };
23130
+ }
23131
+ startHeartbeat() {
23132
+ this.stopHeartbeat();
23133
+ if (this.options.pingIntervalMs <= 0) return;
23134
+ this.heartbeatTimer = setInterval(() => {
23135
+ this.checkHealth();
23136
+ }, this.options.pingIntervalMs);
23137
+ }
23138
+ stopHeartbeat() {
23139
+ if (this.heartbeatTimer) {
23140
+ clearInterval(this.heartbeatTimer);
23141
+ this.heartbeatTimer = null;
23142
+ }
23143
+ }
23144
+ /** Track consecutive ping timeouts (no response at all, not even an error) */
23145
+ consecutivePingTimeouts = 0;
23146
+ /** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
23147
+ pingDisabled = false;
23148
+ async checkHealth() {
23149
+ if (this.state !== "connected" || !this.activeTransport) return;
23150
+ if (this.pingDisabled) return;
23151
+ try {
23152
+ const pingId = `respl-ping-${Date.now()}`;
23153
+ const responsePromise = new Promise((resolve) => {
23154
+ this.pendingPings.set(pingId, resolve);
23155
+ setTimeout(() => {
23156
+ if (this.pendingPings.has(pingId)) {
23157
+ this.pendingPings.delete(pingId);
23158
+ resolve(false);
23159
+ }
23160
+ }, this.options.pingTimeoutMs);
23161
+ });
23162
+ let sendFailed = false;
23163
+ try {
23164
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F493} Sending heartbeat ping (id: ${pingId})`);
23165
+ await this.activeTransport.send({
23166
+ jsonrpc: "2.0",
23167
+ id: pingId,
23168
+ method: "ping"
23169
+ });
23170
+ } catch (sendErr) {
23171
+ sendFailed = true;
23172
+ throw sendErr;
23173
+ }
23174
+ const success2 = await responsePromise;
23175
+ if (!success2) {
23176
+ this.consecutivePingTimeouts++;
23177
+ if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
23178
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Server does not respond to ping. Disabling heartbeat monitoring.`);
23179
+ this.pingDisabled = true;
23180
+ this.stopHeartbeat();
23181
+ return;
23182
+ }
23183
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Ping timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
23184
+ return;
23185
+ }
23186
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F496} Heartbeat successful (id: ${pingId})`);
23187
+ this.consecutiveFailures = 0;
23188
+ this.consecutivePingTimeouts = 0;
23189
+ } catch (err) {
23190
+ this.consecutiveFailures++;
23191
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F494} Heartbeat error (attempt ${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
23192
+ if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
23193
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
23194
+ this.triggerReconnect();
23195
+ }
23196
+ }
23197
+ }
23198
+ triggerReconnect() {
23199
+ if (this.state === "reconnecting" || this.state === "closed") return;
23200
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F504} Triggering reconnect (previous state: ${this.state})`);
23201
+ this.state = "reconnecting";
23202
+ this.stopHeartbeat();
23203
+ if (this.activeTransport) {
23204
+ this.activeTransport.onclose = void 0;
23205
+ this.activeTransport.onerror = void 0;
23206
+ try {
23207
+ this.activeTransport.close();
23208
+ } catch {
23209
+ }
23210
+ this.activeTransport = null;
23211
+ }
23212
+ setTimeout(() => {
23213
+ this.performConnect();
23214
+ }, this.options.reconnectDelayMs);
23215
+ }
23216
+ async flushQueue() {
23217
+ if (!this.activeTransport || this.state !== "connected") return;
23218
+ if (this.messageQueue.length > 0) {
23219
+ this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Flushing ${this.messageQueue.length} queued requests...`);
23220
+ }
23221
+ const queueToFlush = [...this.messageQueue];
23222
+ this.messageQueue = [];
23223
+ for (let i = 0; i < queueToFlush.length; i++) {
23224
+ const msg = queueToFlush[i];
23225
+ try {
23226
+ await this.activeTransport.send(msg);
23227
+ } catch (e) {
23228
+ this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Error flushing queue: ${e}`);
23229
+ const failedAndRemaining = queueToFlush.slice(i);
23230
+ this.messageQueue = [...failedAndRemaining, ...this.messageQueue];
23231
+ this.triggerReconnect();
23232
+ break;
23233
+ }
23234
+ }
23235
+ }
23236
+ // --- Transport Interface Methods ---
23237
+ async send(message) {
23238
+ if (this.state === "closed") {
23239
+ throw new Error("Transport is closed");
23240
+ }
23241
+ if (this.state === "reconnecting" || this.state === "connecting") {
23242
+ if (this.messageQueue.length >= this.options.maxQueueSize) {
23243
+ this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Message queue full, dropping oldest request.`);
23244
+ this.messageQueue.shift();
23245
+ }
23246
+ this.messageQueue.push(message);
23247
+ return;
23248
+ }
23249
+ if (!this.activeTransport) {
23250
+ throw new Error("No active transport to send message");
23251
+ }
23252
+ try {
23253
+ await this.activeTransport.send(message);
23254
+ } catch (err) {
23255
+ this.messageQueue.push(message);
23256
+ this.triggerReconnect();
23257
+ }
23258
+ }
23259
+ async close() {
23260
+ if (this.state === "closed") return;
23261
+ this.state = "closed";
23262
+ this.stopHeartbeat();
23263
+ this.messageQueue = [];
23264
+ if (this.activeTransport) {
23265
+ this.activeTransport.onclose = void 0;
23266
+ this.activeTransport.onerror = void 0;
23267
+ await this.activeTransport.close();
23268
+ this.activeTransport = null;
23269
+ }
23270
+ if (this.onclose) {
23271
+ this.onclose();
23272
+ }
23273
+ }
23274
+ };
23275
+
23022
23276
  // src/transport.ts
23277
+ async function withTimeout(promise2, ms, message) {
23278
+ let timeoutId;
23279
+ const timeoutPromise = new Promise((_, reject) => {
23280
+ timeoutId = setTimeout(() => reject(new Error(message)), ms);
23281
+ });
23282
+ try {
23283
+ return await Promise.race([promise2, timeoutPromise]);
23284
+ } finally {
23285
+ clearTimeout(timeoutId);
23286
+ }
23287
+ }
23023
23288
  function buildBaseEnv() {
23024
23289
  const env = {};
23025
23290
  for (const [key, value] of Object.entries(process.env)) {
@@ -23042,28 +23307,53 @@ function buildRequestHeaders(entry) {
23042
23307
  }
23043
23308
  async function connectStdio(id, entry, baseEnv) {
23044
23309
  logInfo(`Connecting to "${id}" (stdio): ${entry.command} ${(entry.args || []).join(" ")}`);
23045
- const transport = new CustomStdioClientTransport({
23046
- command: entry.command,
23047
- args: entry.args || [],
23048
- env: { ...baseEnv, ...entry.env || {} },
23049
- stderr: "pipe"
23050
- });
23051
- if (transport.stderr) {
23052
- transport.stderr.on("data", (chunk) => {
23053
- const text = chunk.toString().trim();
23054
- if (text) {
23055
- process.stderr.write(`[child:${id}] ${text}
23310
+ const wrapper = new ResilientTransportWrapper({
23311
+ name: id,
23312
+ connectParams: async () => {
23313
+ const t = new CustomStdioClientTransport({
23314
+ command: entry.command,
23315
+ args: entry.args || [],
23316
+ env: { ...baseEnv, ...entry.env || {} },
23317
+ stderr: "pipe"
23318
+ });
23319
+ if (t.stderr) {
23320
+ t.stderr.on("data", (chunk) => {
23321
+ const text = chunk.toString().trim();
23322
+ if (text) {
23323
+ process.stderr.write(`[child:${id}] ${text}
23056
23324
  `);
23325
+ }
23326
+ });
23057
23327
  }
23058
- });
23059
- }
23328
+ return t;
23329
+ },
23330
+ pingIntervalMs: entry.pingIntervalMs,
23331
+ pingTimeoutMs: entry.pingTimeoutMs
23332
+ });
23060
23333
  const client = new Client({ name: `proxy-${id}`, version: "1.0.0" });
23061
- await client.connect(transport);
23334
+ try {
23335
+ await withTimeout(
23336
+ (async () => {
23337
+ await wrapper.start();
23338
+ await client.connect(wrapper);
23339
+ wrapper.enableHeartbeat();
23340
+ })(),
23341
+ 5e3,
23342
+ `Connection initialization timed out after 5s`
23343
+ );
23344
+ } catch (err) {
23345
+ try {
23346
+ await wrapper.close();
23347
+ } catch {
23348
+ }
23349
+ throw err;
23350
+ }
23062
23351
  return {
23063
23352
  client,
23353
+ transport: wrapper,
23064
23354
  cleanup: async () => {
23065
23355
  try {
23066
- await transport.close();
23356
+ await wrapper.close();
23067
23357
  } catch {
23068
23358
  }
23069
23359
  }
@@ -23073,17 +23363,41 @@ async function connectStreamable(id, entry) {
23073
23363
  logInfo(`Connecting to "${id}" (streamable-http): ${entry.url}`);
23074
23364
  const headers = buildRequestHeaders(entry);
23075
23365
  const url2 = new URL(entry.url);
23076
- const transport = new StreamableHTTPClientTransport(
23077
- url2,
23078
- headers ? { requestInit: { headers } } : void 0
23079
- );
23366
+ const wrapper = new ResilientTransportWrapper({
23367
+ name: id,
23368
+ connectParams: async () => {
23369
+ return new StreamableHTTPClientTransport(
23370
+ url2,
23371
+ headers ? { requestInit: { headers } } : void 0
23372
+ );
23373
+ },
23374
+ pingIntervalMs: entry.pingIntervalMs,
23375
+ pingTimeoutMs: entry.pingTimeoutMs
23376
+ });
23080
23377
  const client = new Client({ name: `proxy-${id}`, version: "1.0.0" });
23081
- await client.connect(transport);
23378
+ try {
23379
+ await withTimeout(
23380
+ (async () => {
23381
+ await wrapper.start();
23382
+ await client.connect(wrapper);
23383
+ wrapper.enableHeartbeat();
23384
+ })(),
23385
+ 5e3,
23386
+ `Connection initialization timed out after 5s`
23387
+ );
23388
+ } catch (err) {
23389
+ try {
23390
+ await wrapper.close();
23391
+ } catch {
23392
+ }
23393
+ throw err;
23394
+ }
23082
23395
  return {
23083
23396
  client,
23397
+ transport: wrapper,
23084
23398
  cleanup: async () => {
23085
23399
  try {
23086
- await transport.close();
23400
+ await wrapper.close();
23087
23401
  } catch {
23088
23402
  }
23089
23403
  }
@@ -23093,17 +23407,41 @@ async function connectSse(id, entry) {
23093
23407
  logInfo(`Connecting to "${id}" (sse): ${entry.url}`);
23094
23408
  const headers = buildRequestHeaders(entry);
23095
23409
  const url2 = new URL(entry.url);
23096
- const transport = new SSEClientTransport(
23097
- url2,
23098
- headers ? { requestInit: { headers } } : void 0
23099
- );
23410
+ const wrapper = new ResilientTransportWrapper({
23411
+ name: id,
23412
+ connectParams: async () => {
23413
+ return new SSEClientTransport(
23414
+ url2,
23415
+ headers ? { requestInit: { headers } } : void 0
23416
+ );
23417
+ },
23418
+ pingIntervalMs: entry.pingIntervalMs,
23419
+ pingTimeoutMs: entry.pingTimeoutMs
23420
+ });
23100
23421
  const client = new Client({ name: `proxy-${id}`, version: "1.0.0" });
23101
- await client.connect(transport);
23422
+ try {
23423
+ await withTimeout(
23424
+ (async () => {
23425
+ await wrapper.start();
23426
+ await client.connect(wrapper);
23427
+ wrapper.enableHeartbeat();
23428
+ })(),
23429
+ 5e3,
23430
+ `Connection initialization timed out after 5s`
23431
+ );
23432
+ } catch (err) {
23433
+ try {
23434
+ await wrapper.close();
23435
+ } catch {
23436
+ }
23437
+ throw err;
23438
+ }
23102
23439
  return {
23103
23440
  client,
23441
+ transport: wrapper,
23104
23442
  cleanup: async () => {
23105
23443
  try {
23106
- await transport.close();
23444
+ await wrapper.close();
23107
23445
  } catch {
23108
23446
  }
23109
23447
  }
@@ -23767,7 +24105,7 @@ var StdioServerTransport = class {
23767
24105
 
23768
24106
  // src/constants.ts
23769
24107
  var PKG_NAME = "nuwax-mcp-stdio-proxy";
23770
- var PKG_VERSION = "1.4.6";
24108
+ var PKG_VERSION = "1.4.7";
23771
24109
 
23772
24110
  // src/shared.ts
23773
24111
  async function discoverTools(client) {
@@ -24074,11 +24412,11 @@ async function runConvert(args) {
24074
24412
  const entryId = "remote";
24075
24413
  let connected;
24076
24414
  if (protocol === "sse") {
24077
- const sseEntry = { url: targetUrl, transport: "sse" };
24415
+ const sseEntry = { url: targetUrl, transport: "sse", pingIntervalMs: args.pingIntervalMs, pingTimeoutMs: args.pingTimeoutMs };
24078
24416
  if (targetHeaders) sseEntry.headers = targetHeaders;
24079
24417
  connected = await connectSse(entryId, sseEntry);
24080
24418
  } else {
24081
- const streamEntry = { url: targetUrl };
24419
+ const streamEntry = { url: targetUrl, pingIntervalMs: args.pingIntervalMs, pingTimeoutMs: args.pingTimeoutMs };
24082
24420
  if (targetHeaders) streamEntry.headers = targetHeaders;
24083
24421
  connected = await connectStreamable(entryId, streamEntry);
24084
24422
  }
@@ -25354,7 +25692,7 @@ var StreamableHTTPServerTransport = class {
25354
25692
  };
25355
25693
 
25356
25694
  // src/bridge.ts
25357
- var LOG_TAG = "[PersistentMcpBridge]";
25695
+ var LOG_TAG = "[McpProxy] [PersistentMcpBridge]";
25358
25696
  var BASE_RESTART_COOLDOWN_MS = 5e3;
25359
25697
  var MAX_RESTART_ATTEMPTS = 5;
25360
25698
  var MAX_BODY_SIZE = 10 * 1024 * 1024;
@@ -25473,36 +25811,46 @@ var PersistentMcpBridge = class {
25473
25811
  async spawnAndConnect(id, entry) {
25474
25812
  try {
25475
25813
  this.log.info(`${LOG_TAG} Spawning server "${id}": ${entry.config.command} ${(entry.config.args || []).join(" ")}`);
25476
- const transport = new CustomStdioClientTransport({
25477
- command: entry.config.command,
25478
- args: entry.config.args || [],
25479
- env: entry.config.env,
25480
- stderr: "pipe"
25814
+ const wrapper = new ResilientTransportWrapper({
25815
+ name: id,
25816
+ logger: this.log,
25817
+ connectParams: async () => {
25818
+ const t = new CustomStdioClientTransport({
25819
+ command: entry.config.command,
25820
+ args: entry.config.args || [],
25821
+ env: entry.config.env,
25822
+ stderr: "pipe"
25823
+ });
25824
+ if (t.stderr) {
25825
+ t.stderr.on("data", (chunk) => {
25826
+ const text = chunk.toString().trim();
25827
+ if (text) this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
25828
+ });
25829
+ }
25830
+ return t;
25831
+ },
25832
+ pingIntervalMs: entry.config.pingIntervalMs,
25833
+ pingTimeoutMs: entry.config.pingTimeoutMs
25481
25834
  });
25482
25835
  const client = new Client(
25483
25836
  { name: "nuwax-persistent-bridge", version: "1.0.0" },
25484
25837
  { capabilities: {} }
25485
25838
  );
25486
- transport.onclose = () => {
25839
+ wrapper.onclose = () => {
25487
25840
  this.log.warn(`${LOG_TAG} Server "${id}" transport closed`);
25488
25841
  entry.healthy = false;
25489
25842
  if (this.running && !entry.restarting) {
25490
25843
  this.scheduleRestart(id, entry);
25491
25844
  }
25492
25845
  };
25493
- transport.onerror = (err) => {
25846
+ wrapper.onerror = (err) => {
25494
25847
  this.log.error(`${LOG_TAG} Server "${id}" transport error:`, err.message);
25495
25848
  };
25496
- await client.connect(transport);
25849
+ await wrapper.start();
25850
+ await client.connect(wrapper);
25851
+ wrapper.enableHeartbeat();
25497
25852
  entry.client = client;
25498
- entry.transport = transport;
25499
- const stderrStream = transport.stderr;
25500
- if (stderrStream) {
25501
- stderrStream.on("data", (chunk) => {
25502
- const text = chunk.toString().trim();
25503
- if (text) this.log.info(`${LOG_TAG} [${id}:stderr] ${text}`);
25504
- });
25505
- }
25853
+ entry.transport = wrapper;
25506
25854
  const result = await client.listTools();
25507
25855
  entry.tools = result.tools;
25508
25856
  entry.healthy = true;
@@ -25840,6 +26188,8 @@ function parseConvertArgs(args) {
25840
26188
  let protocol;
25841
26189
  let allowTools;
25842
26190
  let denyTools;
26191
+ let pingIntervalMs;
26192
+ let pingTimeoutMs;
25843
26193
  for (let i = 0; i < args.length; i++) {
25844
26194
  const arg = args[i];
25845
26195
  if (arg === "--config" && i + 1 < args.length) {
@@ -25862,6 +26212,20 @@ function parseConvertArgs(args) {
25862
26212
  } else if (arg === "--deny-tools" && i + 1 < args.length) {
25863
26213
  i++;
25864
26214
  denyTools = args[i].split(",").map((s) => s.trim()).filter(Boolean);
26215
+ } else if (arg === "--ping-interval" && i + 1 < args.length) {
26216
+ i++;
26217
+ pingIntervalMs = parseInt(args[i], 10);
26218
+ if (isNaN(pingIntervalMs)) {
26219
+ logError(`Invalid ping interval: "${args[i]}"`);
26220
+ process.exit(1);
26221
+ }
26222
+ } else if (arg === "--ping-timeout" && i + 1 < args.length) {
26223
+ i++;
26224
+ pingTimeoutMs = parseInt(args[i], 10);
26225
+ if (isNaN(pingTimeoutMs)) {
26226
+ logError(`Invalid ping timeout: "${args[i]}"`);
26227
+ process.exit(1);
26228
+ }
25865
26229
  } else if (!arg.startsWith("-") && !url2) {
25866
26230
  url2 = arg;
25867
26231
  } else {
@@ -25879,7 +26243,7 @@ function parseConvertArgs(args) {
25879
26243
  logError("Cannot use both --allow-tools and --deny-tools");
25880
26244
  process.exit(1);
25881
26245
  }
25882
- return { mode: "convert", url: url2, config: config2, name, protocol, allowTools, denyTools };
26246
+ return { mode: "convert", url: url2, config: config2, name, protocol, allowTools, denyTools, pingIntervalMs, pingTimeoutMs };
25883
26247
  }
25884
26248
  function parseProxyArgs(args) {
25885
26249
  let port;
@@ -25972,6 +26336,8 @@ function printConvertUsage() {
25972
26336
  logError(" --protocol <sse|stream> Protocol type (auto-detect if omitted)");
25973
26337
  logError(" --allow-tools <TOOLS> Tool whitelist (comma-separated)");
25974
26338
  logError(" --deny-tools <TOOLS> Tool blacklist (comma-separated)");
26339
+ logError(" --ping-interval <MS> Heartbeat ping interval (default: 20000)");
26340
+ logError(" --ping-timeout <MS> Heartbeat ping timeout (default: 5000)");
25975
26341
  }
25976
26342
  function printProxyUsage() {
25977
26343
  logError(`Usage: nuwax-mcp-stdio-proxy proxy --port <PORT> --config '{"mcpServers":{...}}'`);
@@ -12,5 +12,7 @@ export interface ConvertArgs {
12
12
  protocol?: 'sse' | 'stream';
13
13
  allowTools?: string[];
14
14
  denyTools?: string[];
15
+ pingIntervalMs?: number;
16
+ pingTimeoutMs?: number;
15
17
  }
16
18
  export declare function runConvert(args: ConvertArgs): Promise<void>;
@@ -67,13 +67,13 @@ export async function runConvert(args) {
67
67
  const entryId = 'remote';
68
68
  let connected;
69
69
  if (protocol === 'sse') {
70
- const sseEntry = { url: targetUrl, transport: 'sse' };
70
+ const sseEntry = { url: targetUrl, transport: 'sse', pingIntervalMs: args.pingIntervalMs, pingTimeoutMs: args.pingTimeoutMs };
71
71
  if (targetHeaders)
72
72
  sseEntry.headers = targetHeaders;
73
73
  connected = await connectSse(entryId, sseEntry);
74
74
  }
75
75
  else {
76
- const streamEntry = { url: targetUrl };
76
+ const streamEntry = { url: targetUrl, pingIntervalMs: args.pingIntervalMs, pingTimeoutMs: args.pingTimeoutMs };
77
77
  if (targetHeaders)
78
78
  streamEntry.headers = targetHeaders;
79
79
  connected = await connectStreamable(entryId, streamEntry);
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Resilient Transport Wrapper for MCP
3
+ *
4
+ * Provides heartbeat monitoring, automatic reconnection, and request queueing
5
+ * for MCP transports (HTTP, SSE, Stdio).
6
+ */
7
+ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
8
+ import { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
9
+ /** Logger interface — compatible with console, electron-log, and BridgeLogger */
10
+ export interface ResilientLogger {
11
+ info: (...args: unknown[]) => void;
12
+ warn: (...args: unknown[]) => void;
13
+ error: (...args: unknown[]) => void;
14
+ }
15
+ export interface ResilientTransportOptions {
16
+ /** Connection factory function */
17
+ connectParams: () => Promise<Transport>;
18
+ /** Heartbeat check interval (ms). Default: 20000 */
19
+ pingIntervalMs?: number;
20
+ /** Max consecutive failures before reconnecting. Default: 3 */
21
+ maxConsecutiveFailures?: number;
22
+ /** Timeout for checking ping or listTools (ms). Default: 5000 */
23
+ pingTimeoutMs?: number;
24
+ /** Backoff delay before reconnect attempt (ms). Default: 3000 */
25
+ reconnectDelayMs?: number;
26
+ /** Max queued requests during reconnect. Default: 100 */
27
+ maxQueueSize?: number;
28
+ /** Server name/ID for logging */
29
+ name?: string;
30
+ /**
31
+ * Optional custom logger. When running inside the Electron main process
32
+ * (e.g. PersistentMcpBridge), pass electron-log so heartbeat/reconnect
33
+ * logs appear in main.log alongside other application logs.
34
+ * Defaults to the built-in stderr logger (logger.js).
35
+ */
36
+ logger?: ResilientLogger;
37
+ }
38
+ export declare class ResilientTransportWrapper implements Transport {
39
+ private options;
40
+ private log;
41
+ private activeTransport;
42
+ private mcpClient;
43
+ private heartbeatTimer;
44
+ private consecutiveFailures;
45
+ private state;
46
+ onclose?: () => void;
47
+ onerror?: (error: Error) => void;
48
+ onmessage?: (message: JSONRPCMessage) => void;
49
+ private messageQueue;
50
+ private pendingPings;
51
+ constructor(options: ResilientTransportOptions);
52
+ /**
53
+ * Initializes the transport and connects to the backend
54
+ *
55
+ * This method is idempotent - if the transport is already connected or
56
+ * connecting, subsequent calls will return immediately without creating
57
+ * duplicate connections. This is important because MCP SDK's client.connect()
58
+ * internally calls transport.start(), and we also call it explicitly in bridge.ts.
59
+ */
60
+ start(): Promise<void>;
61
+ /**
62
+ * Enable heartbeat monitoring. Call this AFTER the MCP client has
63
+ * completed its initialize handshake (client.connect()), otherwise
64
+ * the server will reject ping requests with "Server not initialized".
65
+ */
66
+ enableHeartbeat(): void;
67
+ private performConnect;
68
+ private bindInnerTransport;
69
+ private startHeartbeat;
70
+ private stopHeartbeat;
71
+ /** Track consecutive ping timeouts (no response at all, not even an error) */
72
+ private consecutivePingTimeouts;
73
+ /** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
74
+ private pingDisabled;
75
+ private checkHealth;
76
+ private triggerReconnect;
77
+ private flushQueue;
78
+ send(message: JSONRPCMessage): Promise<void>;
79
+ close(): Promise<void>;
80
+ }