nuwax-mcp-stdio-proxy 1.4.7 → 1.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge.js +1 -6
- package/dist/detect.d.ts +12 -6
- package/dist/detect.js +61 -52
- package/dist/index.js +278 -151
- package/dist/logger.d.ts +7 -1
- package/dist/logger.js +97 -2
- package/dist/modes/stdio.js +45 -29
- package/dist/resilient.d.ts +19 -4
- package/dist/resilient.js +80 -44
- package/dist/transport.js +10 -6
- package/dist/types.d.ts +6 -4
- package/package.json +4 -3
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/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
|
@@ -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 */
|
|
@@ -43,12 +48,19 @@ export declare class ResilientTransportWrapper implements Transport {
|
|
|
43
48
|
private heartbeatTimer;
|
|
44
49
|
private consecutiveFailures;
|
|
45
50
|
private state;
|
|
51
|
+
/** Current retry attempt count (reset on successful connect) */
|
|
52
|
+
private retryAttempt;
|
|
46
53
|
onclose?: () => void;
|
|
47
54
|
onerror?: (error: Error) => void;
|
|
48
55
|
onmessage?: (message: JSONRPCMessage) => void;
|
|
49
56
|
private messageQueue;
|
|
50
57
|
private pendingPings;
|
|
51
58
|
constructor(options: ResilientTransportOptions);
|
|
59
|
+
/**
|
|
60
|
+
* Calculate backoff delay using capped exponential backoff.
|
|
61
|
+
* 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
|
|
62
|
+
*/
|
|
63
|
+
private getBackoffDelay;
|
|
52
64
|
/**
|
|
53
65
|
* Initializes the transport and connects to the backend
|
|
54
66
|
*
|
|
@@ -61,7 +73,7 @@ export declare class ResilientTransportWrapper implements Transport {
|
|
|
61
73
|
/**
|
|
62
74
|
* Enable heartbeat monitoring. Call this AFTER the MCP client has
|
|
63
75
|
* completed its initialize handshake (client.connect()), otherwise
|
|
64
|
-
* the server will reject
|
|
76
|
+
* the server will reject requests with "Server not initialized".
|
|
65
77
|
*/
|
|
66
78
|
enableHeartbeat(): void;
|
|
67
79
|
private performConnect;
|
|
@@ -70,9 +82,12 @@ export declare class ResilientTransportWrapper implements Transport {
|
|
|
70
82
|
private stopHeartbeat;
|
|
71
83
|
/** Track consecutive ping timeouts (no response at all, not even an error) */
|
|
72
84
|
private consecutivePingTimeouts;
|
|
73
|
-
/**
|
|
74
|
-
private
|
|
85
|
+
/** Successful heartbeat counter (for reducing log volume) */
|
|
86
|
+
private heartbeatOkCount;
|
|
75
87
|
private checkHealth;
|
|
88
|
+
/**
|
|
89
|
+
* Close the current transport and schedule a reconnect with exponential backoff.
|
|
90
|
+
*/
|
|
76
91
|
private triggerReconnect;
|
|
77
92
|
private flushQueue;
|
|
78
93
|
send(message: JSONRPCMessage): Promise<void>;
|
package/dist/resilient.js
CHANGED
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides heartbeat monitoring, automatic reconnection, and request queueing
|
|
5
5
|
* for MCP transports (HTTP, SSE, Stdio).
|
|
6
|
+
*
|
|
7
|
+
* Retry strategy: exponential backoff 1s → 2s → 4s → ... → 60s (capped),
|
|
8
|
+
* unlimited retries. Matches the Rust mcp-proxy CappedExponentialBackoff.
|
|
6
9
|
*/
|
|
7
10
|
import { logInfo, logWarn, logError } from './logger.js';
|
|
8
11
|
const DEFAULT_OPTIONS = {
|
|
9
12
|
pingIntervalMs: 20000,
|
|
10
13
|
maxConsecutiveFailures: 3,
|
|
11
14
|
pingTimeoutMs: 5000,
|
|
12
|
-
reconnectDelayMs:
|
|
15
|
+
reconnectDelayMs: 1000,
|
|
16
|
+
maxReconnectDelayMs: 60000,
|
|
13
17
|
maxQueueSize: 100,
|
|
14
18
|
name: 'remote',
|
|
15
19
|
};
|
|
@@ -26,7 +30,9 @@ export class ResilientTransportWrapper {
|
|
|
26
30
|
mcpClient = null;
|
|
27
31
|
heartbeatTimer = null;
|
|
28
32
|
consecutiveFailures = 0;
|
|
29
|
-
state = '
|
|
33
|
+
state = 'idle';
|
|
34
|
+
/** Current retry attempt count (reset on successful connect) */
|
|
35
|
+
retryAttempt = 0;
|
|
30
36
|
// Handlers required by the Transport interface
|
|
31
37
|
onclose;
|
|
32
38
|
onerror;
|
|
@@ -37,7 +43,30 @@ export class ResilientTransportWrapper {
|
|
|
37
43
|
pendingPings = new Map();
|
|
38
44
|
constructor(options) {
|
|
39
45
|
this.log = options.logger ?? defaultLogger;
|
|
40
|
-
|
|
46
|
+
// Build options by starting from defaults and only overriding with
|
|
47
|
+
// explicitly provided values. Spreading { pingIntervalMs: undefined }
|
|
48
|
+
// would clobber the default 20000, causing setInterval(fn, undefined)
|
|
49
|
+
// → interval ~0ms (fires every tick).
|
|
50
|
+
this.options = {
|
|
51
|
+
...DEFAULT_OPTIONS,
|
|
52
|
+
logger: this.log,
|
|
53
|
+
connectParams: options.connectParams,
|
|
54
|
+
...(options.name !== undefined && { name: options.name }),
|
|
55
|
+
...(options.pingIntervalMs !== undefined && { pingIntervalMs: options.pingIntervalMs }),
|
|
56
|
+
...(options.pingTimeoutMs !== undefined && { pingTimeoutMs: options.pingTimeoutMs }),
|
|
57
|
+
...(options.maxConsecutiveFailures !== undefined && { maxConsecutiveFailures: options.maxConsecutiveFailures }),
|
|
58
|
+
...(options.reconnectDelayMs !== undefined && { reconnectDelayMs: options.reconnectDelayMs }),
|
|
59
|
+
...(options.maxReconnectDelayMs !== undefined && { maxReconnectDelayMs: options.maxReconnectDelayMs }),
|
|
60
|
+
...(options.maxQueueSize !== undefined && { maxQueueSize: options.maxQueueSize }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Calculate backoff delay using capped exponential backoff.
|
|
65
|
+
* 1s → 2s → 4s → 8s → 16s → 32s → 60s (capped)
|
|
66
|
+
*/
|
|
67
|
+
getBackoffDelay() {
|
|
68
|
+
const delay = this.options.reconnectDelayMs * Math.pow(2, this.retryAttempt);
|
|
69
|
+
return Math.min(delay, this.options.maxReconnectDelayMs);
|
|
41
70
|
}
|
|
42
71
|
/**
|
|
43
72
|
* Initializes the transport and connects to the backend
|
|
@@ -48,8 +77,8 @@ export class ResilientTransportWrapper {
|
|
|
48
77
|
* internally calls transport.start(), and we also call it explicitly in bridge.ts.
|
|
49
78
|
*/
|
|
50
79
|
async start() {
|
|
51
|
-
// Idempotent check: if already connected or
|
|
52
|
-
if (this.state === 'connected' || this.state === 'connecting') {
|
|
80
|
+
// Idempotent check: if already connected, connecting, or retrying, return early
|
|
81
|
+
if (this.state === 'connected' || this.state === 'connecting' || this.state === 'reconnecting') {
|
|
53
82
|
return;
|
|
54
83
|
}
|
|
55
84
|
this.state = 'connecting';
|
|
@@ -58,12 +87,14 @@ export class ResilientTransportWrapper {
|
|
|
58
87
|
/**
|
|
59
88
|
* Enable heartbeat monitoring. Call this AFTER the MCP client has
|
|
60
89
|
* completed its initialize handshake (client.connect()), otherwise
|
|
61
|
-
* the server will reject
|
|
90
|
+
* the server will reject requests with "Server not initialized".
|
|
62
91
|
*/
|
|
63
92
|
enableHeartbeat() {
|
|
64
93
|
this.startHeartbeat();
|
|
65
94
|
}
|
|
66
95
|
async performConnect(initial = false) {
|
|
96
|
+
if (this.state === 'closed')
|
|
97
|
+
return;
|
|
67
98
|
try {
|
|
68
99
|
this.activeTransport = await this.options.connectParams();
|
|
69
100
|
// Inherit the handlers from this wrapper
|
|
@@ -71,7 +102,10 @@ export class ResilientTransportWrapper {
|
|
|
71
102
|
await this.activeTransport.start();
|
|
72
103
|
this.state = 'connected';
|
|
73
104
|
this.consecutiveFailures = 0;
|
|
74
|
-
this.
|
|
105
|
+
this.consecutivePingTimeouts = 0;
|
|
106
|
+
this.retryAttempt = 0;
|
|
107
|
+
this.heartbeatOkCount = 0;
|
|
108
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] ✅ Connected via ${this.activeTransport.constructor.name}`);
|
|
75
109
|
// Flush any queued messages
|
|
76
110
|
this.flushQueue();
|
|
77
111
|
// Only start heartbeat on reconnects — initial connections need
|
|
@@ -83,12 +117,14 @@ export class ResilientTransportWrapper {
|
|
|
83
117
|
}
|
|
84
118
|
}
|
|
85
119
|
catch (err) {
|
|
86
|
-
this.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
const delay = this.getBackoffDelay();
|
|
121
|
+
this.retryAttempt++;
|
|
122
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] ❌ Connect failed (attempt ${this.retryAttempt}): ${err}`);
|
|
123
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Retrying in ${delay}ms...`);
|
|
124
|
+
this.state = 'reconnecting';
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
this.performConnect();
|
|
127
|
+
}, delay);
|
|
92
128
|
}
|
|
93
129
|
}
|
|
94
130
|
bindInnerTransport(transport) {
|
|
@@ -112,7 +148,7 @@ export class ResilientTransportWrapper {
|
|
|
112
148
|
}
|
|
113
149
|
};
|
|
114
150
|
transport.onmessage = (message) => {
|
|
115
|
-
// Intercept our own
|
|
151
|
+
// Intercept our own heartbeat responses (tools/list used as health check)
|
|
116
152
|
if ('id' in message && typeof message.id === 'string' && message.id.startsWith('respl-ping-')) {
|
|
117
153
|
const resolve = this.pendingPings.get(message.id);
|
|
118
154
|
if (resolve) {
|
|
@@ -143,74 +179,71 @@ export class ResilientTransportWrapper {
|
|
|
143
179
|
}
|
|
144
180
|
/** Track consecutive ping timeouts (no response at all, not even an error) */
|
|
145
181
|
consecutivePingTimeouts = 0;
|
|
146
|
-
/**
|
|
147
|
-
|
|
182
|
+
/** Successful heartbeat counter (for reducing log volume) */
|
|
183
|
+
heartbeatOkCount = 0;
|
|
148
184
|
async checkHealth() {
|
|
149
185
|
if (this.state !== 'connected' || !this.activeTransport)
|
|
150
186
|
return;
|
|
151
|
-
if (this.pingDisabled)
|
|
152
|
-
return;
|
|
153
187
|
try {
|
|
154
|
-
//
|
|
155
|
-
|
|
188
|
+
// Use tools/list as health check (all MCP servers must support it).
|
|
189
|
+
// Unlike ping, which is optional and many servers ignore, tools/list
|
|
190
|
+
// is a required MCP method — matching Rust mcp-proxy behavior.
|
|
191
|
+
const healthId = `respl-ping-${Date.now()}`;
|
|
156
192
|
const responsePromise = new Promise((resolve) => {
|
|
157
|
-
this.pendingPings.set(
|
|
193
|
+
this.pendingPings.set(healthId, resolve);
|
|
158
194
|
setTimeout(() => {
|
|
159
|
-
if (this.pendingPings.has(
|
|
160
|
-
this.pendingPings.delete(
|
|
195
|
+
if (this.pendingPings.has(healthId)) {
|
|
196
|
+
this.pendingPings.delete(healthId);
|
|
161
197
|
resolve(false); // Timeout
|
|
162
198
|
}
|
|
163
199
|
}, this.options.pingTimeoutMs);
|
|
164
200
|
});
|
|
165
|
-
// Try to send
|
|
166
|
-
let sendFailed = false;
|
|
201
|
+
// Try to send tools/list — if send() throws, the transport itself is broken
|
|
167
202
|
try {
|
|
168
|
-
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat ping (id: ${pingId})`);
|
|
169
203
|
await this.activeTransport.send({
|
|
170
204
|
jsonrpc: '2.0',
|
|
171
|
-
id:
|
|
172
|
-
method: '
|
|
205
|
+
id: healthId,
|
|
206
|
+
method: 'tools/list',
|
|
207
|
+
params: {},
|
|
173
208
|
});
|
|
174
209
|
}
|
|
175
210
|
catch (sendErr) {
|
|
176
|
-
sendFailed = true;
|
|
177
211
|
throw sendErr; // Transport broken, treat as real failure
|
|
178
212
|
}
|
|
179
213
|
const success = await responsePromise;
|
|
180
214
|
if (!success) {
|
|
181
|
-
// Ping was sent successfully but timed out — server might not support ping
|
|
182
215
|
this.consecutivePingTimeouts++;
|
|
216
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] ⏱️ Heartbeat timeout (${this.consecutivePingTimeouts}/${this.options.maxConsecutiveFailures})`);
|
|
183
217
|
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;
|
|
218
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat timeouts reached. Closing and retrying...`);
|
|
219
|
+
this.triggerReconnect();
|
|
191
220
|
}
|
|
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
221
|
return;
|
|
195
222
|
}
|
|
196
223
|
// Got a response — server is alive
|
|
197
|
-
this.
|
|
224
|
+
this.heartbeatOkCount++;
|
|
225
|
+
// Only log every 5th success to reduce log volume (~100s interval)
|
|
226
|
+
if (this.heartbeatOkCount % 5 === 1 || this.consecutiveFailures > 0 || this.consecutivePingTimeouts > 0) {
|
|
227
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat OK (count: ${this.heartbeatOkCount})`);
|
|
228
|
+
}
|
|
198
229
|
this.consecutiveFailures = 0;
|
|
199
230
|
this.consecutivePingTimeouts = 0;
|
|
200
231
|
}
|
|
201
232
|
catch (err) {
|
|
202
233
|
this.consecutiveFailures++;
|
|
203
|
-
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (
|
|
234
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
|
|
204
235
|
if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
|
|
205
|
-
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached.
|
|
236
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Closing and retrying...`);
|
|
206
237
|
this.triggerReconnect();
|
|
207
238
|
}
|
|
208
239
|
}
|
|
209
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Close the current transport and schedule a reconnect with exponential backoff.
|
|
243
|
+
*/
|
|
210
244
|
triggerReconnect() {
|
|
211
245
|
if (this.state === 'reconnecting' || this.state === 'closed')
|
|
212
246
|
return;
|
|
213
|
-
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Triggering reconnect (previous state: ${this.state})`);
|
|
214
247
|
this.state = 'reconnecting';
|
|
215
248
|
this.stopHeartbeat();
|
|
216
249
|
// Clean up old transport
|
|
@@ -224,9 +257,12 @@ export class ResilientTransportWrapper {
|
|
|
224
257
|
catch { /* ignore */ }
|
|
225
258
|
this.activeTransport = null;
|
|
226
259
|
}
|
|
260
|
+
const delay = this.getBackoffDelay();
|
|
261
|
+
this.retryAttempt++;
|
|
262
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Closed. Retrying in ${delay}ms (attempt ${this.retryAttempt})...`);
|
|
227
263
|
setTimeout(() => {
|
|
228
264
|
this.performConnect();
|
|
229
|
-
},
|
|
265
|
+
}, delay);
|
|
230
266
|
}
|
|
231
267
|
async flushQueue() {
|
|
232
268
|
if (!this.activeTransport || this.state !== 'connected')
|
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.9",
|
|
4
4
|
"description": "TypeScript MCP proxy — aggregates multiple MCP servers (stdio + streamable-http + SSE) with convert & proxy modes",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
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",
|
|
@@ -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"
|