nuwax-mcp-stdio-proxy 1.4.7 → 1.4.8
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/bridge.js +1 -6
- package/dist/detect.d.ts +12 -6
- package/dist/detect.js +61 -52
- package/dist/index.js +140 -116
- package/dist/modes/stdio.js +45 -29
- package/dist/resilient.d.ts +1 -3
- package/dist/resilient.js +36 -31
- package/dist/transport.js +10 -6
- package/dist/types.d.ts +6 -4
- package/package.json +2 -2
package/dist/bridge.js
CHANGED
|
@@ -177,8 +177,7 @@ export class PersistentMcpBridge {
|
|
|
177
177
|
}
|
|
178
178
|
return t;
|
|
179
179
|
},
|
|
180
|
-
pingIntervalMs:
|
|
181
|
-
pingTimeoutMs: entry.config.pingTimeoutMs,
|
|
180
|
+
pingIntervalMs: 0, // No heartbeat for stdio — child process close/error events handle detection
|
|
182
181
|
});
|
|
183
182
|
const client = new Client({ name: 'nuwax-persistent-bridge', version: '1.0.0' }, { capabilities: {} });
|
|
184
183
|
// Handle transport close → auto restart
|
|
@@ -195,10 +194,6 @@ export class PersistentMcpBridge {
|
|
|
195
194
|
// Connect client to transport (this starts the subprocess)
|
|
196
195
|
await wrapper.start();
|
|
197
196
|
await client.connect(wrapper);
|
|
198
|
-
// Enable heartbeat monitoring AFTER the MCP initialize handshake
|
|
199
|
-
// completes — sending pings before initialize causes "Server not
|
|
200
|
-
// initialized" errors from the MCP server.
|
|
201
|
-
wrapper.enableHeartbeat();
|
|
202
197
|
entry.client = client;
|
|
203
198
|
entry.transport = wrapper;
|
|
204
199
|
// List tools (cached)
|
package/dist/detect.d.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Protocol auto-detection — determine whether a URL serves Streamable HTTP or SSE
|
|
3
|
+
*
|
|
4
|
+
* Aligned with workspace/mcp-proxy (Rust) detection logic:
|
|
5
|
+
* - Only probe Streamable HTTP (POST initialize)
|
|
6
|
+
* - Default to SSE if probe fails
|
|
3
7
|
*/
|
|
4
8
|
/**
|
|
5
9
|
* Detect the MCP transport protocol of a remote URL.
|
|
6
10
|
*
|
|
7
|
-
* Strategy:
|
|
8
|
-
* 1.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Strategy (matches Rust mcp-proxy):
|
|
12
|
+
* 1. Send a JSON-RPC initialize POST to probe for Streamable HTTP.
|
|
13
|
+
* 2. Check 4 criteria — any match means streamable-http:
|
|
14
|
+
* a. Response has `mcp-session-id` header
|
|
15
|
+
* b. Content-Type is `text/event-stream` with 2xx status
|
|
16
|
+
* c. Response body is valid JSON-RPC 2.0
|
|
17
|
+
* d. Status is 406 Not Acceptable
|
|
18
|
+
* 3. If probe fails or no criteria match → default to SSE.
|
|
13
19
|
*/
|
|
14
20
|
export declare function detectProtocol(url: string, headers?: Record<string, string>): Promise<'sse' | 'stream'>;
|
package/dist/detect.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Protocol auto-detection — determine whether a URL serves Streamable HTTP or SSE
|
|
3
|
+
*
|
|
4
|
+
* Aligned with workspace/mcp-proxy (Rust) detection logic:
|
|
5
|
+
* - Only probe Streamable HTTP (POST initialize)
|
|
6
|
+
* - Default to SSE if probe fails
|
|
3
7
|
*/
|
|
4
8
|
import { logInfo, logWarn } from './logger.js';
|
|
5
9
|
/**
|
|
6
10
|
* Detect the MCP transport protocol of a remote URL.
|
|
7
11
|
*
|
|
8
|
-
* Strategy:
|
|
9
|
-
* 1.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* Strategy (matches Rust mcp-proxy):
|
|
13
|
+
* 1. Send a JSON-RPC initialize POST to probe for Streamable HTTP.
|
|
14
|
+
* 2. Check 4 criteria — any match means streamable-http:
|
|
15
|
+
* a. Response has `mcp-session-id` header
|
|
16
|
+
* b. Content-Type is `text/event-stream` with 2xx status
|
|
17
|
+
* c. Response body is valid JSON-RPC 2.0
|
|
18
|
+
* d. Status is 406 Not Acceptable
|
|
19
|
+
* 3. If probe fails or no criteria match → default to SSE.
|
|
14
20
|
*/
|
|
15
21
|
export async function detectProtocol(url, headers) {
|
|
16
22
|
logInfo(`Auto-detecting protocol for ${url}...`);
|
|
17
|
-
// 1. Try Streamable HTTP — POST a JSON-RPC initialize request
|
|
18
23
|
try {
|
|
19
24
|
const reqHeaders = {
|
|
20
25
|
'Content-Type': 'application/json',
|
|
@@ -22,7 +27,7 @@ export async function detectProtocol(url, headers) {
|
|
|
22
27
|
...headers,
|
|
23
28
|
};
|
|
24
29
|
const controller = new AbortController();
|
|
25
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
30
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
26
31
|
const res = await fetch(url, {
|
|
27
32
|
method: 'POST',
|
|
28
33
|
headers: reqHeaders,
|
|
@@ -39,57 +44,61 @@ export async function detectProtocol(url, headers) {
|
|
|
39
44
|
signal: controller.signal,
|
|
40
45
|
});
|
|
41
46
|
clearTimeout(timeout);
|
|
42
|
-
|
|
43
|
-
if (res.
|
|
44
|
-
logInfo(`Detected streamable-http protocol for ${url}`);
|
|
45
|
-
|
|
47
|
+
// Check 1: mcp-session-id header (definitive Streamable HTTP marker)
|
|
48
|
+
if (res.headers.get('mcp-session-id')) {
|
|
49
|
+
logInfo(`Detected streamable-http protocol for ${url} (mcp-session-id header)`);
|
|
50
|
+
cleanupSession(url, res.headers.get('mcp-session-id'), headers);
|
|
46
51
|
await res.text().catch(() => { });
|
|
47
|
-
// Clean up orphan session — fire-and-forget DELETE so the server
|
|
48
|
-
// can discard the half-initialized session we created during probing.
|
|
49
|
-
// Not awaited: cleanup is best-effort and must not block detection.
|
|
50
|
-
const sessionId = res.headers.get('mcp-session-id');
|
|
51
|
-
if (sessionId) {
|
|
52
|
-
fetch(url, {
|
|
53
|
-
method: 'DELETE',
|
|
54
|
-
headers: { 'mcp-session-id': sessionId, ...headers },
|
|
55
|
-
signal: AbortSignal.timeout(5_000),
|
|
56
|
-
}).catch(() => { });
|
|
57
|
-
}
|
|
58
52
|
return 'stream';
|
|
59
53
|
}
|
|
60
|
-
await res.text().catch(() => { });
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
// Streamable HTTP probe failed, try SSE
|
|
64
|
-
}
|
|
65
|
-
// 2. Try SSE — GET and check for event-stream
|
|
66
|
-
try {
|
|
67
|
-
const reqHeaders = {
|
|
68
|
-
Accept: 'text/event-stream',
|
|
69
|
-
...headers,
|
|
70
|
-
};
|
|
71
|
-
const controller = new AbortController();
|
|
72
|
-
const timeout = setTimeout(() => controller.abort(), 10_000);
|
|
73
|
-
const res = await fetch(url, {
|
|
74
|
-
method: 'GET',
|
|
75
|
-
headers: reqHeaders,
|
|
76
|
-
signal: controller.signal,
|
|
77
|
-
});
|
|
78
54
|
const ct = res.headers.get('content-type') || '';
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
55
|
+
// Check 2: text/event-stream content-type with success status
|
|
56
|
+
if (ct.includes('text/event-stream') && res.ok) {
|
|
57
|
+
logInfo(`Detected streamable-http protocol for ${url} (event-stream response)`);
|
|
58
|
+
cleanupSession(url, res.headers.get('mcp-session-id'), headers);
|
|
59
|
+
// Abort the stream to free the connection
|
|
83
60
|
controller.abort();
|
|
84
|
-
return '
|
|
61
|
+
return 'stream';
|
|
62
|
+
}
|
|
63
|
+
// Read body for JSON-RPC check
|
|
64
|
+
let bodyText = '';
|
|
65
|
+
try {
|
|
66
|
+
bodyText = await res.text();
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore */ }
|
|
69
|
+
// Check 3: valid JSON-RPC 2.0 response
|
|
70
|
+
try {
|
|
71
|
+
const json = JSON.parse(bodyText);
|
|
72
|
+
if (json && json.jsonrpc === '2.0') {
|
|
73
|
+
logInfo(`Detected streamable-http protocol for ${url} (JSON-RPC 2.0 response)`);
|
|
74
|
+
cleanupSession(url, res.headers.get('mcp-session-id'), headers);
|
|
75
|
+
return 'stream';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch { /* not JSON */ }
|
|
79
|
+
// Check 4: 406 Not Acceptable (may indicate Streamable HTTP)
|
|
80
|
+
if (res.status === 406) {
|
|
81
|
+
logInfo(`Detected streamable-http protocol for ${url} (406 Not Acceptable)`);
|
|
82
|
+
return 'stream';
|
|
85
83
|
}
|
|
86
|
-
clearTimeout(timeout);
|
|
87
|
-
await res.text().catch(() => { });
|
|
88
84
|
}
|
|
89
85
|
catch {
|
|
90
|
-
//
|
|
86
|
+
// Probe failed (timeout, connection refused, etc.)
|
|
91
87
|
}
|
|
92
|
-
//
|
|
93
|
-
logWarn(`Could not
|
|
94
|
-
return '
|
|
88
|
+
// Default to SSE (matches Rust mcp-proxy behavior)
|
|
89
|
+
logWarn(`Could not detect streamable-http for ${url}, defaulting to SSE`);
|
|
90
|
+
return 'sse';
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Clean up orphan session — fire-and-forget DELETE so the server
|
|
94
|
+
* can discard the half-initialized session we created during probing.
|
|
95
|
+
*/
|
|
96
|
+
function cleanupSession(url, sessionId, headers) {
|
|
97
|
+
if (!sessionId)
|
|
98
|
+
return;
|
|
99
|
+
fetch(url, {
|
|
100
|
+
method: 'DELETE',
|
|
101
|
+
headers: { 'mcp-session-id': sessionId, ...headers },
|
|
102
|
+
signal: AbortSignal.timeout(5_000),
|
|
103
|
+
}).catch(() => { });
|
|
95
104
|
}
|
package/dist/index.js
CHANGED
|
@@ -23040,7 +23040,7 @@ var ResilientTransportWrapper = class {
|
|
|
23040
23040
|
mcpClient = null;
|
|
23041
23041
|
heartbeatTimer = null;
|
|
23042
23042
|
consecutiveFailures = 0;
|
|
23043
|
-
state = "
|
|
23043
|
+
state = "idle";
|
|
23044
23044
|
// Handlers required by the Transport interface
|
|
23045
23045
|
onclose;
|
|
23046
23046
|
onerror;
|
|
@@ -23051,7 +23051,17 @@ var ResilientTransportWrapper = class {
|
|
|
23051
23051
|
pendingPings = /* @__PURE__ */ new Map();
|
|
23052
23052
|
constructor(options) {
|
|
23053
23053
|
this.log = options.logger ?? defaultLogger;
|
|
23054
|
-
this.options = {
|
|
23054
|
+
this.options = {
|
|
23055
|
+
...DEFAULT_OPTIONS,
|
|
23056
|
+
logger: this.log,
|
|
23057
|
+
connectParams: options.connectParams,
|
|
23058
|
+
...options.name !== void 0 && { name: options.name },
|
|
23059
|
+
...options.pingIntervalMs !== void 0 && { pingIntervalMs: options.pingIntervalMs },
|
|
23060
|
+
...options.pingTimeoutMs !== void 0 && { pingTimeoutMs: options.pingTimeoutMs },
|
|
23061
|
+
...options.maxConsecutiveFailures !== void 0 && { maxConsecutiveFailures: options.maxConsecutiveFailures },
|
|
23062
|
+
...options.reconnectDelayMs !== void 0 && { reconnectDelayMs: options.reconnectDelayMs },
|
|
23063
|
+
...options.maxQueueSize !== void 0 && { maxQueueSize: options.maxQueueSize }
|
|
23064
|
+
};
|
|
23055
23065
|
}
|
|
23056
23066
|
/**
|
|
23057
23067
|
* Initializes the transport and connects to the backend
|
|
@@ -23071,7 +23081,7 @@ var ResilientTransportWrapper = class {
|
|
|
23071
23081
|
/**
|
|
23072
23082
|
* Enable heartbeat monitoring. Call this AFTER the MCP client has
|
|
23073
23083
|
* completed its initialize handshake (client.connect()), otherwise
|
|
23074
|
-
* the server will reject
|
|
23084
|
+
* the server will reject requests with "Server not initialized".
|
|
23075
23085
|
*/
|
|
23076
23086
|
enableHeartbeat() {
|
|
23077
23087
|
this.startHeartbeat();
|
|
@@ -23083,6 +23093,7 @@ var ResilientTransportWrapper = class {
|
|
|
23083
23093
|
await this.activeTransport.start();
|
|
23084
23094
|
this.state = "connected";
|
|
23085
23095
|
this.consecutiveFailures = 0;
|
|
23096
|
+
this.consecutivePingTimeouts = 0;
|
|
23086
23097
|
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
|
|
23087
23098
|
this.flushQueue();
|
|
23088
23099
|
if (!initial) {
|
|
@@ -23143,52 +23154,46 @@ var ResilientTransportWrapper = class {
|
|
|
23143
23154
|
}
|
|
23144
23155
|
/** Track consecutive ping timeouts (no response at all, not even an error) */
|
|
23145
23156
|
consecutivePingTimeouts = 0;
|
|
23146
|
-
/** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
|
|
23147
|
-
pingDisabled = false;
|
|
23148
23157
|
async checkHealth() {
|
|
23149
23158
|
if (this.state !== "connected" || !this.activeTransport) return;
|
|
23150
|
-
if (this.pingDisabled) return;
|
|
23151
23159
|
try {
|
|
23152
|
-
const
|
|
23160
|
+
const healthId = `respl-ping-${Date.now()}`;
|
|
23153
23161
|
const responsePromise = new Promise((resolve) => {
|
|
23154
|
-
this.pendingPings.set(
|
|
23162
|
+
this.pendingPings.set(healthId, resolve);
|
|
23155
23163
|
setTimeout(() => {
|
|
23156
|
-
if (this.pendingPings.has(
|
|
23157
|
-
this.pendingPings.delete(
|
|
23164
|
+
if (this.pendingPings.has(healthId)) {
|
|
23165
|
+
this.pendingPings.delete(healthId);
|
|
23158
23166
|
resolve(false);
|
|
23159
23167
|
}
|
|
23160
23168
|
}, this.options.pingTimeoutMs);
|
|
23161
23169
|
});
|
|
23162
|
-
let sendFailed = false;
|
|
23163
23170
|
try {
|
|
23164
|
-
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F493} Sending heartbeat
|
|
23171
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F493} Sending heartbeat (id: ${healthId})`);
|
|
23165
23172
|
await this.activeTransport.send({
|
|
23166
23173
|
jsonrpc: "2.0",
|
|
23167
|
-
id:
|
|
23168
|
-
method: "
|
|
23174
|
+
id: healthId,
|
|
23175
|
+
method: "tools/list",
|
|
23176
|
+
params: {}
|
|
23169
23177
|
});
|
|
23170
23178
|
} catch (sendErr) {
|
|
23171
|
-
sendFailed = true;
|
|
23172
23179
|
throw sendErr;
|
|
23173
23180
|
}
|
|
23174
23181
|
const success2 = await responsePromise;
|
|
23175
23182
|
if (!success2) {
|
|
23176
23183
|
this.consecutivePingTimeouts++;
|
|
23184
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
|
|
23177
23185
|
if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
|
|
23178
|
-
this.log.
|
|
23179
|
-
this.
|
|
23180
|
-
this.stopHeartbeat();
|
|
23181
|
-
return;
|
|
23186
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Force reconnecting.`);
|
|
23187
|
+
this.triggerReconnect();
|
|
23182
23188
|
}
|
|
23183
|
-
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Ping timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
|
|
23184
23189
|
return;
|
|
23185
23190
|
}
|
|
23186
|
-
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F496} Heartbeat
|
|
23191
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F496} Heartbeat OK (id: ${healthId})`);
|
|
23187
23192
|
this.consecutiveFailures = 0;
|
|
23188
23193
|
this.consecutivePingTimeouts = 0;
|
|
23189
23194
|
} catch (err) {
|
|
23190
23195
|
this.consecutiveFailures++;
|
|
23191
|
-
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F494} Heartbeat error (
|
|
23196
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] \u{1F494} Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
|
|
23192
23197
|
if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
|
|
23193
23198
|
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
|
|
23194
23199
|
this.triggerReconnect();
|
|
@@ -23274,6 +23279,8 @@ var ResilientTransportWrapper = class {
|
|
|
23274
23279
|
};
|
|
23275
23280
|
|
|
23276
23281
|
// src/transport.ts
|
|
23282
|
+
var DEFAULT_STDIO_CONNECTION_TIMEOUT_MS = 6e4;
|
|
23283
|
+
var DEFAULT_HTTP_CONNECTION_TIMEOUT_MS = 3e4;
|
|
23277
23284
|
async function withTimeout(promise2, ms, message) {
|
|
23278
23285
|
let timeoutId;
|
|
23279
23286
|
const timeoutPromise = new Promise((_, reject) => {
|
|
@@ -23327,19 +23334,19 @@ async function connectStdio(id, entry, baseEnv) {
|
|
|
23327
23334
|
}
|
|
23328
23335
|
return t;
|
|
23329
23336
|
},
|
|
23330
|
-
|
|
23331
|
-
|
|
23337
|
+
// No heartbeat for stdio — child process close/error events handle detection
|
|
23338
|
+
pingIntervalMs: 0
|
|
23332
23339
|
});
|
|
23333
23340
|
const client = new Client({ name: `proxy-${id}`, version: "1.0.0" });
|
|
23341
|
+
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_STDIO_CONNECTION_TIMEOUT_MS;
|
|
23334
23342
|
try {
|
|
23335
23343
|
await withTimeout(
|
|
23336
23344
|
(async () => {
|
|
23337
23345
|
await wrapper.start();
|
|
23338
23346
|
await client.connect(wrapper);
|
|
23339
|
-
wrapper.enableHeartbeat();
|
|
23340
23347
|
})(),
|
|
23341
|
-
|
|
23342
|
-
`Connection initialization timed out after
|
|
23348
|
+
timeoutMs,
|
|
23349
|
+
`Connection initialization timed out after ${timeoutMs / 1e3}s`
|
|
23343
23350
|
);
|
|
23344
23351
|
} catch (err) {
|
|
23345
23352
|
try {
|
|
@@ -23375,6 +23382,7 @@ async function connectStreamable(id, entry) {
|
|
|
23375
23382
|
pingTimeoutMs: entry.pingTimeoutMs
|
|
23376
23383
|
});
|
|
23377
23384
|
const client = new Client({ name: `proxy-${id}`, version: "1.0.0" });
|
|
23385
|
+
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
|
|
23378
23386
|
try {
|
|
23379
23387
|
await withTimeout(
|
|
23380
23388
|
(async () => {
|
|
@@ -23382,8 +23390,8 @@ async function connectStreamable(id, entry) {
|
|
|
23382
23390
|
await client.connect(wrapper);
|
|
23383
23391
|
wrapper.enableHeartbeat();
|
|
23384
23392
|
})(),
|
|
23385
|
-
|
|
23386
|
-
`Connection initialization timed out after
|
|
23393
|
+
timeoutMs,
|
|
23394
|
+
`Connection initialization timed out after ${timeoutMs / 1e3}s`
|
|
23387
23395
|
);
|
|
23388
23396
|
} catch (err) {
|
|
23389
23397
|
try {
|
|
@@ -23419,6 +23427,7 @@ async function connectSse(id, entry) {
|
|
|
23419
23427
|
pingTimeoutMs: entry.pingTimeoutMs
|
|
23420
23428
|
});
|
|
23421
23429
|
const client = new Client({ name: `proxy-${id}`, version: "1.0.0" });
|
|
23430
|
+
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
|
|
23422
23431
|
try {
|
|
23423
23432
|
await withTimeout(
|
|
23424
23433
|
(async () => {
|
|
@@ -23426,8 +23435,8 @@ async function connectSse(id, entry) {
|
|
|
23426
23435
|
await client.connect(wrapper);
|
|
23427
23436
|
wrapper.enableHeartbeat();
|
|
23428
23437
|
})(),
|
|
23429
|
-
|
|
23430
|
-
`Connection initialization timed out after
|
|
23438
|
+
timeoutMs,
|
|
23439
|
+
`Connection initialization timed out after ${timeoutMs / 1e3}s`
|
|
23431
23440
|
);
|
|
23432
23441
|
} catch (err) {
|
|
23433
23442
|
try {
|
|
@@ -24105,7 +24114,7 @@ var StdioServerTransport = class {
|
|
|
24105
24114
|
|
|
24106
24115
|
// src/constants.ts
|
|
24107
24116
|
var PKG_NAME = "nuwax-mcp-stdio-proxy";
|
|
24108
|
-
var PKG_VERSION = "1.4.
|
|
24117
|
+
var PKG_VERSION = "1.4.8";
|
|
24109
24118
|
|
|
24110
24119
|
// src/shared.ts
|
|
24111
24120
|
async function discoverTools(client) {
|
|
@@ -24190,7 +24199,7 @@ async function detectProtocol(url2, headers) {
|
|
|
24190
24199
|
...headers
|
|
24191
24200
|
};
|
|
24192
24201
|
const controller = new AbortController();
|
|
24193
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
24202
|
+
const timeout = setTimeout(() => controller.abort(), 5e3);
|
|
24194
24203
|
const res = await fetch(url2, {
|
|
24195
24204
|
method: "POST",
|
|
24196
24205
|
headers: reqHeaders,
|
|
@@ -24207,52 +24216,51 @@ async function detectProtocol(url2, headers) {
|
|
|
24207
24216
|
signal: controller.signal
|
|
24208
24217
|
});
|
|
24209
24218
|
clearTimeout(timeout);
|
|
24210
|
-
|
|
24211
|
-
|
|
24212
|
-
|
|
24219
|
+
if (res.headers.get("mcp-session-id")) {
|
|
24220
|
+
logInfo(`Detected streamable-http protocol for ${url2} (mcp-session-id header)`);
|
|
24221
|
+
cleanupSession(url2, res.headers.get("mcp-session-id"), headers);
|
|
24213
24222
|
await res.text().catch(() => {
|
|
24214
24223
|
});
|
|
24215
|
-
const sessionId = res.headers.get("mcp-session-id");
|
|
24216
|
-
if (sessionId) {
|
|
24217
|
-
fetch(url2, {
|
|
24218
|
-
method: "DELETE",
|
|
24219
|
-
headers: { "mcp-session-id": sessionId, ...headers },
|
|
24220
|
-
signal: AbortSignal.timeout(5e3)
|
|
24221
|
-
}).catch(() => {
|
|
24222
|
-
});
|
|
24223
|
-
}
|
|
24224
24224
|
return "stream";
|
|
24225
24225
|
}
|
|
24226
|
-
await res.text().catch(() => {
|
|
24227
|
-
});
|
|
24228
|
-
} catch {
|
|
24229
|
-
}
|
|
24230
|
-
try {
|
|
24231
|
-
const reqHeaders = {
|
|
24232
|
-
Accept: "text/event-stream",
|
|
24233
|
-
...headers
|
|
24234
|
-
};
|
|
24235
|
-
const controller = new AbortController();
|
|
24236
|
-
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
24237
|
-
const res = await fetch(url2, {
|
|
24238
|
-
method: "GET",
|
|
24239
|
-
headers: reqHeaders,
|
|
24240
|
-
signal: controller.signal
|
|
24241
|
-
});
|
|
24242
24226
|
const ct = res.headers.get("content-type") || "";
|
|
24243
|
-
if (ct.includes("text/event-stream")) {
|
|
24244
|
-
|
|
24245
|
-
|
|
24227
|
+
if (ct.includes("text/event-stream") && res.ok) {
|
|
24228
|
+
logInfo(`Detected streamable-http protocol for ${url2} (event-stream response)`);
|
|
24229
|
+
cleanupSession(url2, res.headers.get("mcp-session-id"), headers);
|
|
24246
24230
|
controller.abort();
|
|
24247
|
-
return "
|
|
24231
|
+
return "stream";
|
|
24232
|
+
}
|
|
24233
|
+
let bodyText = "";
|
|
24234
|
+
try {
|
|
24235
|
+
bodyText = await res.text();
|
|
24236
|
+
} catch {
|
|
24237
|
+
}
|
|
24238
|
+
try {
|
|
24239
|
+
const json2 = JSON.parse(bodyText);
|
|
24240
|
+
if (json2 && json2.jsonrpc === "2.0") {
|
|
24241
|
+
logInfo(`Detected streamable-http protocol for ${url2} (JSON-RPC 2.0 response)`);
|
|
24242
|
+
cleanupSession(url2, res.headers.get("mcp-session-id"), headers);
|
|
24243
|
+
return "stream";
|
|
24244
|
+
}
|
|
24245
|
+
} catch {
|
|
24246
|
+
}
|
|
24247
|
+
if (res.status === 406) {
|
|
24248
|
+
logInfo(`Detected streamable-http protocol for ${url2} (406 Not Acceptable)`);
|
|
24249
|
+
return "stream";
|
|
24248
24250
|
}
|
|
24249
|
-
clearTimeout(timeout);
|
|
24250
|
-
await res.text().catch(() => {
|
|
24251
|
-
});
|
|
24252
24251
|
} catch {
|
|
24253
24252
|
}
|
|
24254
|
-
logWarn(`Could not
|
|
24255
|
-
return "
|
|
24253
|
+
logWarn(`Could not detect streamable-http for ${url2}, defaulting to SSE`);
|
|
24254
|
+
return "sse";
|
|
24255
|
+
}
|
|
24256
|
+
function cleanupSession(url2, sessionId, headers) {
|
|
24257
|
+
if (!sessionId) return;
|
|
24258
|
+
fetch(url2, {
|
|
24259
|
+
method: "DELETE",
|
|
24260
|
+
headers: { "mcp-session-id": sessionId, ...headers },
|
|
24261
|
+
signal: AbortSignal.timeout(5e3)
|
|
24262
|
+
}).catch(() => {
|
|
24263
|
+
});
|
|
24256
24264
|
}
|
|
24257
24265
|
|
|
24258
24266
|
// src/modes/stdio.ts
|
|
@@ -24271,52 +24279,69 @@ async function runStdio(config2, allowTools, denyTools) {
|
|
|
24271
24279
|
const toolToClient = /* @__PURE__ */ new Map();
|
|
24272
24280
|
const toolToServer = /* @__PURE__ */ new Map();
|
|
24273
24281
|
const toolsByName = /* @__PURE__ */ new Map();
|
|
24274
|
-
|
|
24275
|
-
|
|
24276
|
-
|
|
24277
|
-
|
|
24278
|
-
|
|
24279
|
-
|
|
24280
|
-
|
|
24281
|
-
|
|
24282
|
-
|
|
24283
|
-
|
|
24284
|
-
|
|
24285
|
-
|
|
24282
|
+
const connectableEntries = entries.filter(([id, entry]) => {
|
|
24283
|
+
if (entry.persistent) {
|
|
24284
|
+
logWarn(`Skipping persistent server "${id}" (handled by PersistentMcpBridge)`);
|
|
24285
|
+
return false;
|
|
24286
|
+
}
|
|
24287
|
+
return true;
|
|
24288
|
+
});
|
|
24289
|
+
const results = await Promise.allSettled(
|
|
24290
|
+
connectableEntries.map(async ([id, entry]) => {
|
|
24291
|
+
try {
|
|
24292
|
+
let connected;
|
|
24293
|
+
if (isSseEntry(entry)) {
|
|
24294
|
+
connected = await connectSse(id, entry);
|
|
24295
|
+
} else if (isStreamableEntry(entry)) {
|
|
24286
24296
|
connected = await connectStreamable(id, entry);
|
|
24297
|
+
} else if (needsProtocolDetection(entry)) {
|
|
24298
|
+
const detected = await detectProtocol(entry.url, buildRequestHeaders(entry));
|
|
24299
|
+
if (detected === "sse") {
|
|
24300
|
+
connected = await connectSse(id, { ...entry, transport: "sse" });
|
|
24301
|
+
} else {
|
|
24302
|
+
connected = await connectStreamable(id, entry);
|
|
24303
|
+
}
|
|
24304
|
+
} else {
|
|
24305
|
+
connected = await connectStdio(id, entry, baseEnv);
|
|
24287
24306
|
}
|
|
24288
|
-
|
|
24289
|
-
|
|
24290
|
-
|
|
24291
|
-
const { client, cleanup } = connected;
|
|
24292
|
-
clients.set(id, client);
|
|
24293
|
-
cleanups.set(id, cleanup);
|
|
24294
|
-
let serverTools = await discoverTools(client);
|
|
24295
|
-
if (entry.allowTools || entry.denyTools) {
|
|
24296
|
-
const perFilter = {};
|
|
24297
|
-
if (entry.allowTools) perFilter.allowTools = new Set(entry.allowTools);
|
|
24298
|
-
if (entry.denyTools) perFilter.denyTools = new Set(entry.denyTools);
|
|
24299
|
-
const before = serverTools.length;
|
|
24300
|
-
serverTools = filterTools(serverTools, perFilter);
|
|
24301
|
-
if (serverTools.length !== before) {
|
|
24302
|
-
logInfo(`Server "${id}": filtered ${before} \u2192 ${serverTools.length} tool(s)`);
|
|
24303
|
-
}
|
|
24304
|
-
}
|
|
24305
|
-
logInfo(
|
|
24306
|
-
`Server "${id}": ${serverTools.length} tool(s)${serverTools.length > 0 ? " \u2014 " + serverTools.map((t) => t.name).join(", ") : ""}`
|
|
24307
|
-
);
|
|
24308
|
-
for (const tool of serverTools) {
|
|
24309
|
-
if (toolToClient.has(tool.name)) {
|
|
24310
|
-
logWarn(
|
|
24311
|
-
`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`
|
|
24312
|
-
);
|
|
24313
|
-
}
|
|
24314
|
-
toolToClient.set(tool.name, client);
|
|
24315
|
-
toolToServer.set(tool.name, id);
|
|
24316
|
-
toolsByName.set(tool.name, tool);
|
|
24307
|
+
return { id, entry, connected };
|
|
24308
|
+
} catch (e) {
|
|
24309
|
+
throw new Error(`Server "${id}": ${e}`);
|
|
24317
24310
|
}
|
|
24318
|
-
}
|
|
24319
|
-
|
|
24311
|
+
})
|
|
24312
|
+
);
|
|
24313
|
+
for (const result of results) {
|
|
24314
|
+
if (result.status === "rejected") {
|
|
24315
|
+
logError(`Failed to connect: ${result.reason}`);
|
|
24316
|
+
continue;
|
|
24317
|
+
}
|
|
24318
|
+
const { id, entry, connected } = result.value;
|
|
24319
|
+
const { client, cleanup } = connected;
|
|
24320
|
+
clients.set(id, client);
|
|
24321
|
+
cleanups.set(id, cleanup);
|
|
24322
|
+
let serverTools = await discoverTools(client);
|
|
24323
|
+
if (entry.allowTools || entry.denyTools) {
|
|
24324
|
+
const perFilter = {};
|
|
24325
|
+
if (entry.allowTools) perFilter.allowTools = new Set(entry.allowTools);
|
|
24326
|
+
if (entry.denyTools) perFilter.denyTools = new Set(entry.denyTools);
|
|
24327
|
+
const before = serverTools.length;
|
|
24328
|
+
serverTools = filterTools(serverTools, perFilter);
|
|
24329
|
+
if (serverTools.length !== before) {
|
|
24330
|
+
logInfo(`Server "${id}": filtered ${before} \u2192 ${serverTools.length} tool(s)`);
|
|
24331
|
+
}
|
|
24332
|
+
}
|
|
24333
|
+
logInfo(
|
|
24334
|
+
`Server "${id}": ${serverTools.length} tool(s)${serverTools.length > 0 ? " \u2014 " + serverTools.map((t) => t.name).join(", ") : ""}`
|
|
24335
|
+
);
|
|
24336
|
+
for (const tool of serverTools) {
|
|
24337
|
+
if (toolToClient.has(tool.name)) {
|
|
24338
|
+
logWarn(
|
|
24339
|
+
`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`
|
|
24340
|
+
);
|
|
24341
|
+
}
|
|
24342
|
+
toolToClient.set(tool.name, client);
|
|
24343
|
+
toolToServer.set(tool.name, id);
|
|
24344
|
+
toolsByName.set(tool.name, tool);
|
|
24320
24345
|
}
|
|
24321
24346
|
}
|
|
24322
24347
|
if (clients.size === 0) {
|
|
@@ -25829,8 +25854,8 @@ var PersistentMcpBridge = class {
|
|
|
25829
25854
|
}
|
|
25830
25855
|
return t;
|
|
25831
25856
|
},
|
|
25832
|
-
pingIntervalMs:
|
|
25833
|
-
|
|
25857
|
+
pingIntervalMs: 0
|
|
25858
|
+
// No heartbeat for stdio — child process close/error events handle detection
|
|
25834
25859
|
});
|
|
25835
25860
|
const client = new Client(
|
|
25836
25861
|
{ name: "nuwax-persistent-bridge", version: "1.0.0" },
|
|
@@ -25848,7 +25873,6 @@ var PersistentMcpBridge = class {
|
|
|
25848
25873
|
};
|
|
25849
25874
|
await wrapper.start();
|
|
25850
25875
|
await client.connect(wrapper);
|
|
25851
|
-
wrapper.enableHeartbeat();
|
|
25852
25876
|
entry.client = client;
|
|
25853
25877
|
entry.transport = wrapper;
|
|
25854
25878
|
const result = await client.listTools();
|
package/dist/modes/stdio.js
CHANGED
|
@@ -24,7 +24,16 @@ export async function runStdio(config, allowTools, denyTools) {
|
|
|
24
24
|
const toolToClient = new Map();
|
|
25
25
|
const toolToServer = new Map();
|
|
26
26
|
const toolsByName = new Map();
|
|
27
|
-
|
|
27
|
+
// Filter out persistent entries (handled by PersistentMcpBridge, not this proxy)
|
|
28
|
+
const connectableEntries = entries.filter(([id, entry]) => {
|
|
29
|
+
if (entry.persistent) {
|
|
30
|
+
logWarn(`Skipping persistent server "${id}" (handled by PersistentMcpBridge)`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
// Connect to all servers in parallel
|
|
36
|
+
const results = await Promise.allSettled(connectableEntries.map(async ([id, entry]) => {
|
|
28
37
|
try {
|
|
29
38
|
let connected;
|
|
30
39
|
if (isSseEntry(entry)) {
|
|
@@ -46,36 +55,43 @@ export async function runStdio(config, allowTools, denyTools) {
|
|
|
46
55
|
else {
|
|
47
56
|
connected = await connectStdio(id, entry, baseEnv);
|
|
48
57
|
}
|
|
49
|
-
|
|
50
|
-
clients.set(id, client);
|
|
51
|
-
cleanups.set(id, cleanup);
|
|
52
|
-
let serverTools = await discoverTools(client);
|
|
53
|
-
// Per-server tool filtering (allowTools/denyTools in config entry)
|
|
54
|
-
if (entry.allowTools || entry.denyTools) {
|
|
55
|
-
const perFilter = {};
|
|
56
|
-
if (entry.allowTools)
|
|
57
|
-
perFilter.allowTools = new Set(entry.allowTools);
|
|
58
|
-
if (entry.denyTools)
|
|
59
|
-
perFilter.denyTools = new Set(entry.denyTools);
|
|
60
|
-
const before = serverTools.length;
|
|
61
|
-
serverTools = filterTools(serverTools, perFilter);
|
|
62
|
-
if (serverTools.length !== before) {
|
|
63
|
-
logInfo(`Server "${id}": filtered ${before} → ${serverTools.length} tool(s)`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
logInfo(`Server "${id}": ${serverTools.length} tool(s)${serverTools.length > 0 ? ' — ' + serverTools.map((t) => t.name).join(', ') : ''}`);
|
|
67
|
-
for (const tool of serverTools) {
|
|
68
|
-
if (toolToClient.has(tool.name)) {
|
|
69
|
-
logWarn(`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`);
|
|
70
|
-
}
|
|
71
|
-
toolToClient.set(tool.name, client);
|
|
72
|
-
toolToServer.set(tool.name, id);
|
|
73
|
-
toolsByName.set(tool.name, tool);
|
|
74
|
-
}
|
|
58
|
+
return { id, entry, connected };
|
|
75
59
|
}
|
|
76
60
|
catch (e) {
|
|
77
|
-
|
|
78
|
-
|
|
61
|
+
throw new Error(`Server "${id}": ${e}`);
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
for (const result of results) {
|
|
65
|
+
if (result.status === 'rejected') {
|
|
66
|
+
logError(`Failed to connect: ${result.reason}`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const { id, entry, connected } = result.value;
|
|
70
|
+
const { client, cleanup } = connected;
|
|
71
|
+
clients.set(id, client);
|
|
72
|
+
cleanups.set(id, cleanup);
|
|
73
|
+
let serverTools = await discoverTools(client);
|
|
74
|
+
// Per-server tool filtering (allowTools/denyTools in config entry)
|
|
75
|
+
if (entry.allowTools || entry.denyTools) {
|
|
76
|
+
const perFilter = {};
|
|
77
|
+
if (entry.allowTools)
|
|
78
|
+
perFilter.allowTools = new Set(entry.allowTools);
|
|
79
|
+
if (entry.denyTools)
|
|
80
|
+
perFilter.denyTools = new Set(entry.denyTools);
|
|
81
|
+
const before = serverTools.length;
|
|
82
|
+
serverTools = filterTools(serverTools, perFilter);
|
|
83
|
+
if (serverTools.length !== before) {
|
|
84
|
+
logInfo(`Server "${id}": filtered ${before} → ${serverTools.length} tool(s)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
logInfo(`Server "${id}": ${serverTools.length} tool(s)${serverTools.length > 0 ? ' — ' + serverTools.map((t) => t.name).join(', ') : ''}`);
|
|
88
|
+
for (const tool of serverTools) {
|
|
89
|
+
if (toolToClient.has(tool.name)) {
|
|
90
|
+
logWarn(`Tool "${tool.name}" from "${id}" shadows existing tool from "${toolToServer.get(tool.name)}"`);
|
|
91
|
+
}
|
|
92
|
+
toolToClient.set(tool.name, client);
|
|
93
|
+
toolToServer.set(tool.name, id);
|
|
94
|
+
toolsByName.set(tool.name, tool);
|
|
79
95
|
}
|
|
80
96
|
}
|
|
81
97
|
if (clients.size === 0) {
|
package/dist/resilient.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export declare class ResilientTransportWrapper implements Transport {
|
|
|
61
61
|
/**
|
|
62
62
|
* Enable heartbeat monitoring. Call this AFTER the MCP client has
|
|
63
63
|
* completed its initialize handshake (client.connect()), otherwise
|
|
64
|
-
* the server will reject
|
|
64
|
+
* the server will reject requests with "Server not initialized".
|
|
65
65
|
*/
|
|
66
66
|
enableHeartbeat(): void;
|
|
67
67
|
private performConnect;
|
|
@@ -70,8 +70,6 @@ export declare class ResilientTransportWrapper implements Transport {
|
|
|
70
70
|
private stopHeartbeat;
|
|
71
71
|
/** Track consecutive ping timeouts (no response at all, not even an error) */
|
|
72
72
|
private consecutivePingTimeouts;
|
|
73
|
-
/** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
|
|
74
|
-
private pingDisabled;
|
|
75
73
|
private checkHealth;
|
|
76
74
|
private triggerReconnect;
|
|
77
75
|
private flushQueue;
|
package/dist/resilient.js
CHANGED
|
@@ -26,7 +26,7 @@ export class ResilientTransportWrapper {
|
|
|
26
26
|
mcpClient = null;
|
|
27
27
|
heartbeatTimer = null;
|
|
28
28
|
consecutiveFailures = 0;
|
|
29
|
-
state = '
|
|
29
|
+
state = 'idle';
|
|
30
30
|
// Handlers required by the Transport interface
|
|
31
31
|
onclose;
|
|
32
32
|
onerror;
|
|
@@ -37,7 +37,21 @@ export class ResilientTransportWrapper {
|
|
|
37
37
|
pendingPings = new Map();
|
|
38
38
|
constructor(options) {
|
|
39
39
|
this.log = options.logger ?? defaultLogger;
|
|
40
|
-
|
|
40
|
+
// Build options by starting from defaults and only overriding with
|
|
41
|
+
// explicitly provided values. Spreading { pingIntervalMs: undefined }
|
|
42
|
+
// would clobber the default 20000, causing setInterval(fn, undefined)
|
|
43
|
+
// → interval ~0ms (fires every tick).
|
|
44
|
+
this.options = {
|
|
45
|
+
...DEFAULT_OPTIONS,
|
|
46
|
+
logger: this.log,
|
|
47
|
+
connectParams: options.connectParams,
|
|
48
|
+
...(options.name !== undefined && { name: options.name }),
|
|
49
|
+
...(options.pingIntervalMs !== undefined && { pingIntervalMs: options.pingIntervalMs }),
|
|
50
|
+
...(options.pingTimeoutMs !== undefined && { pingTimeoutMs: options.pingTimeoutMs }),
|
|
51
|
+
...(options.maxConsecutiveFailures !== undefined && { maxConsecutiveFailures: options.maxConsecutiveFailures }),
|
|
52
|
+
...(options.reconnectDelayMs !== undefined && { reconnectDelayMs: options.reconnectDelayMs }),
|
|
53
|
+
...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
|
|
54
|
+
};
|
|
41
55
|
}
|
|
42
56
|
/**
|
|
43
57
|
* Initializes the transport and connects to the backend
|
|
@@ -58,7 +72,7 @@ export class ResilientTransportWrapper {
|
|
|
58
72
|
/**
|
|
59
73
|
* Enable heartbeat monitoring. Call this AFTER the MCP client has
|
|
60
74
|
* completed its initialize handshake (client.connect()), otherwise
|
|
61
|
-
* the server will reject
|
|
75
|
+
* the server will reject requests with "Server not initialized".
|
|
62
76
|
*/
|
|
63
77
|
enableHeartbeat() {
|
|
64
78
|
this.startHeartbeat();
|
|
@@ -71,6 +85,7 @@ export class ResilientTransportWrapper {
|
|
|
71
85
|
await this.activeTransport.start();
|
|
72
86
|
this.state = 'connected';
|
|
73
87
|
this.consecutiveFailures = 0;
|
|
88
|
+
this.consecutivePingTimeouts = 0;
|
|
74
89
|
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
|
|
75
90
|
// Flush any queued messages
|
|
76
91
|
this.flushQueue();
|
|
@@ -112,7 +127,7 @@ export class ResilientTransportWrapper {
|
|
|
112
127
|
}
|
|
113
128
|
};
|
|
114
129
|
transport.onmessage = (message) => {
|
|
115
|
-
// Intercept our own
|
|
130
|
+
// Intercept our own heartbeat responses (tools/list used as health check)
|
|
116
131
|
if ('id' in message && typeof message.id === 'string' && message.id.startsWith('respl-ping-')) {
|
|
117
132
|
const resolve = this.pendingPings.get(message.id);
|
|
118
133
|
if (resolve) {
|
|
@@ -143,64 +158,54 @@ export class ResilientTransportWrapper {
|
|
|
143
158
|
}
|
|
144
159
|
/** Track consecutive ping timeouts (no response at all, not even an error) */
|
|
145
160
|
consecutivePingTimeouts = 0;
|
|
146
|
-
/** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
|
|
147
|
-
pingDisabled = false;
|
|
148
161
|
async checkHealth() {
|
|
149
162
|
if (this.state !== 'connected' || !this.activeTransport)
|
|
150
163
|
return;
|
|
151
|
-
if (this.pingDisabled)
|
|
152
|
-
return;
|
|
153
164
|
try {
|
|
154
|
-
//
|
|
155
|
-
|
|
165
|
+
// Use tools/list as health check (all MCP servers must support it).
|
|
166
|
+
// Unlike ping, which is optional and many servers ignore, tools/list
|
|
167
|
+
// is a required MCP method — matching Rust mcp-proxy behavior.
|
|
168
|
+
const healthId = `respl-ping-${Date.now()}`;
|
|
156
169
|
const responsePromise = new Promise((resolve) => {
|
|
157
|
-
this.pendingPings.set(
|
|
170
|
+
this.pendingPings.set(healthId, resolve);
|
|
158
171
|
setTimeout(() => {
|
|
159
|
-
if (this.pendingPings.has(
|
|
160
|
-
this.pendingPings.delete(
|
|
172
|
+
if (this.pendingPings.has(healthId)) {
|
|
173
|
+
this.pendingPings.delete(healthId);
|
|
161
174
|
resolve(false); // Timeout
|
|
162
175
|
}
|
|
163
176
|
}, this.options.pingTimeoutMs);
|
|
164
177
|
});
|
|
165
|
-
// Try to send
|
|
166
|
-
let sendFailed = false;
|
|
178
|
+
// Try to send tools/list — if send() throws, the transport itself is broken
|
|
167
179
|
try {
|
|
168
|
-
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat
|
|
180
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat (id: ${healthId})`);
|
|
169
181
|
await this.activeTransport.send({
|
|
170
182
|
jsonrpc: '2.0',
|
|
171
|
-
id:
|
|
172
|
-
method: '
|
|
183
|
+
id: healthId,
|
|
184
|
+
method: 'tools/list',
|
|
185
|
+
params: {},
|
|
173
186
|
});
|
|
174
187
|
}
|
|
175
188
|
catch (sendErr) {
|
|
176
|
-
sendFailed = true;
|
|
177
189
|
throw sendErr; // Transport broken, treat as real failure
|
|
178
190
|
}
|
|
179
191
|
const success = await responsePromise;
|
|
180
192
|
if (!success) {
|
|
181
|
-
// Ping was sent successfully but timed out — server might not support ping
|
|
182
193
|
this.consecutivePingTimeouts++;
|
|
194
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
|
|
183
195
|
if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// the transport is likely healthy (sends succeed, no errors from transport).
|
|
187
|
-
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Server does not respond to ping. Disabling heartbeat monitoring.`);
|
|
188
|
-
this.pingDisabled = true;
|
|
189
|
-
this.stopHeartbeat();
|
|
190
|
-
return;
|
|
196
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Force reconnecting.`);
|
|
197
|
+
this.triggerReconnect();
|
|
191
198
|
}
|
|
192
|
-
// Still count as a failure for logging, but don't reconnect yet
|
|
193
|
-
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Ping timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
|
|
194
199
|
return;
|
|
195
200
|
}
|
|
196
201
|
// Got a response — server is alive
|
|
197
|
-
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat
|
|
202
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat OK (id: ${healthId})`);
|
|
198
203
|
this.consecutiveFailures = 0;
|
|
199
204
|
this.consecutivePingTimeouts = 0;
|
|
200
205
|
}
|
|
201
206
|
catch (err) {
|
|
202
207
|
this.consecutiveFailures++;
|
|
203
|
-
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (
|
|
208
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
|
|
204
209
|
if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
|
|
205
210
|
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
|
|
206
211
|
this.triggerReconnect();
|
package/dist/transport.js
CHANGED
|
@@ -7,6 +7,8 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
|
7
7
|
import { CustomStdioClientTransport } from './customStdio.js';
|
|
8
8
|
import { logInfo } from './logger.js';
|
|
9
9
|
import { ResilientTransportWrapper } from './resilient.js';
|
|
10
|
+
const DEFAULT_STDIO_CONNECTION_TIMEOUT_MS = 60_000;
|
|
11
|
+
const DEFAULT_HTTP_CONNECTION_TIMEOUT_MS = 30_000;
|
|
10
12
|
/**
|
|
11
13
|
* Helper to wrap a promise with a timeout
|
|
12
14
|
*/
|
|
@@ -73,16 +75,16 @@ export async function connectStdio(id, entry, baseEnv) {
|
|
|
73
75
|
}
|
|
74
76
|
return t;
|
|
75
77
|
},
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
// No heartbeat for stdio — child process close/error events handle detection
|
|
79
|
+
pingIntervalMs: 0,
|
|
78
80
|
});
|
|
79
81
|
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
|
82
|
+
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_STDIO_CONNECTION_TIMEOUT_MS;
|
|
80
83
|
try {
|
|
81
84
|
await withTimeout((async () => {
|
|
82
85
|
await wrapper.start();
|
|
83
86
|
await client.connect(wrapper);
|
|
84
|
-
|
|
85
|
-
})(), 5000, `Connection initialization timed out after 5s`);
|
|
87
|
+
})(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s`);
|
|
86
88
|
}
|
|
87
89
|
catch (err) {
|
|
88
90
|
try {
|
|
@@ -121,12 +123,13 @@ export async function connectStreamable(id, entry) {
|
|
|
121
123
|
pingTimeoutMs: entry.pingTimeoutMs,
|
|
122
124
|
});
|
|
123
125
|
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
|
126
|
+
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
|
|
124
127
|
try {
|
|
125
128
|
await withTimeout((async () => {
|
|
126
129
|
await wrapper.start();
|
|
127
130
|
await client.connect(wrapper);
|
|
128
131
|
wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
|
|
129
|
-
})(),
|
|
132
|
+
})(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s`);
|
|
130
133
|
}
|
|
131
134
|
catch (err) {
|
|
132
135
|
try {
|
|
@@ -165,12 +168,13 @@ export async function connectSse(id, entry) {
|
|
|
165
168
|
pingTimeoutMs: entry.pingTimeoutMs,
|
|
166
169
|
});
|
|
167
170
|
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
|
171
|
+
const timeoutMs = entry.connectionTimeoutMs ?? DEFAULT_HTTP_CONNECTION_TIMEOUT_MS;
|
|
168
172
|
try {
|
|
169
173
|
await withTimeout((async () => {
|
|
170
174
|
await wrapper.start();
|
|
171
175
|
await client.connect(wrapper);
|
|
172
176
|
wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
|
|
173
|
-
})(),
|
|
177
|
+
})(), timeoutMs, `Connection initialization timed out after ${timeoutMs / 1000}s`);
|
|
174
178
|
}
|
|
175
179
|
catch (err) {
|
|
176
180
|
try {
|
package/dist/types.d.ts
CHANGED
|
@@ -10,10 +10,8 @@ export interface StdioServerEntry {
|
|
|
10
10
|
allowTools?: string[];
|
|
11
11
|
/** 工具黑名单(排除指定工具) */
|
|
12
12
|
denyTools?: string[];
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
/** Heartbeat ping timeout configuration (ms) */
|
|
16
|
-
pingTimeoutMs?: number;
|
|
13
|
+
/** Connection initialization timeout (ms). Defaults to 60000 for stdio. */
|
|
14
|
+
connectionTimeoutMs?: number;
|
|
17
15
|
}
|
|
18
16
|
/**
|
|
19
17
|
* Streamable HTTP 类型: 连接远程 MCP server (Streamable HTTP)
|
|
@@ -34,6 +32,8 @@ export interface StreamableServerEntry {
|
|
|
34
32
|
pingIntervalMs?: number;
|
|
35
33
|
/** Heartbeat ping timeout configuration (ms) */
|
|
36
34
|
pingTimeoutMs?: number;
|
|
35
|
+
/** Connection initialization timeout (ms). Defaults to 30000. */
|
|
36
|
+
connectionTimeoutMs?: number;
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* SSE 类型: 连接远程 MCP server (Server-Sent Events)
|
|
@@ -53,6 +53,8 @@ export interface SseServerEntry {
|
|
|
53
53
|
pingIntervalMs?: number;
|
|
54
54
|
/** Heartbeat ping timeout configuration (ms) */
|
|
55
55
|
pingTimeoutMs?: number;
|
|
56
|
+
/** Connection initialization timeout (ms). Defaults to 30000. */
|
|
57
|
+
connectionTimeoutMs?: number;
|
|
56
58
|
}
|
|
57
59
|
export type McpServerEntry = StdioServerEntry | StreamableServerEntry | SseServerEntry;
|
|
58
60
|
export declare function isSseEntry(entry: McpServerEntry): entry is SseServerEntry;
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuwax-mcp-stdio-proxy",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.8",
|
|
4
4
|
"description": "TypeScript MCP proxy — aggregates multiple MCP servers (stdio + streamable-http + SSE) with convert & proxy modes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"nuwax-mcp-stdio-proxy": "
|
|
7
|
+
"nuwax-mcp-stdio-proxy": "dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "./dist/lib.js",
|
|
10
10
|
"types": "./dist/lib.d.ts",
|