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 +225 -44
- package/dist/logger.d.ts +7 -1
- package/dist/logger.js +97 -2
- package/dist/resilient.d.ts +34 -1
- package/dist/resilient.js +144 -24
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -3222,8 +3222,8 @@ var require_utils = __commonJS({
|
|
|
3222
3222
|
}
|
|
3223
3223
|
return ind;
|
|
3224
3224
|
}
|
|
3225
|
-
function removeDotSegments(
|
|
3226
|
-
let input =
|
|
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 [
|
|
3426
|
-
wsComponent.path =
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
7184
|
-
const fullPath = [...
|
|
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,
|
|
7376
|
+
constructor(parent, value, path3, key) {
|
|
7300
7377
|
this._cachedPath = [];
|
|
7301
7378
|
this.parent = parent;
|
|
7302
7379
|
this.data = value;
|
|
7303
|
-
this._path =
|
|
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,
|
|
10948
|
-
if (!
|
|
11024
|
+
function getElementAtPath(obj, path3) {
|
|
11025
|
+
if (!path3)
|
|
10949
11026
|
return obj;
|
|
10950
|
-
return
|
|
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(
|
|
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(
|
|
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
|
|
22814
|
-
import * as
|
|
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 =
|
|
22852
|
-
if (
|
|
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:
|
|
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.
|
|
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.
|
|
23104
|
-
|
|
23105
|
-
|
|
23106
|
-
|
|
23107
|
-
|
|
23108
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
23204
|
-
|
|
23205
|
-
|
|
23206
|
-
|
|
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
|
-
},
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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);
|
package/dist/resilient.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
},
|
|
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.
|
|
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"
|