nuwax-mcp-stdio-proxy 1.4.6 → 1.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -31
- package/dist/bridge.js +33 -20
- package/dist/index.js +414 -48
- package/dist/modes/convert.d.ts +2 -0
- package/dist/modes/convert.js +2 -2
- package/dist/resilient.d.ts +80 -0
- package/dist/resilient.js +297 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +101 -22
- package/dist/types.d.ts +12 -0
- package/package.json +5 -3
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilient Transport Wrapper for MCP
|
|
3
|
+
*
|
|
4
|
+
* Provides heartbeat monitoring, automatic reconnection, and request queueing
|
|
5
|
+
* for MCP transports (HTTP, SSE, Stdio).
|
|
6
|
+
*/
|
|
7
|
+
import { logInfo, logWarn, logError } from './logger.js';
|
|
8
|
+
const DEFAULT_OPTIONS = {
|
|
9
|
+
pingIntervalMs: 20000,
|
|
10
|
+
maxConsecutiveFailures: 3,
|
|
11
|
+
pingTimeoutMs: 5000,
|
|
12
|
+
reconnectDelayMs: 3000,
|
|
13
|
+
maxQueueSize: 100,
|
|
14
|
+
name: 'remote',
|
|
15
|
+
};
|
|
16
|
+
/** Default logger that delegates to logger.js (stderr) */
|
|
17
|
+
const defaultLogger = {
|
|
18
|
+
info: (...args) => logInfo(args.map(String).join(' ')),
|
|
19
|
+
warn: (...args) => logWarn(args.map(String).join(' ')),
|
|
20
|
+
error: (...args) => logError(args.map(String).join(' ')),
|
|
21
|
+
};
|
|
22
|
+
export class ResilientTransportWrapper {
|
|
23
|
+
options;
|
|
24
|
+
log;
|
|
25
|
+
activeTransport = null;
|
|
26
|
+
mcpClient = null;
|
|
27
|
+
heartbeatTimer = null;
|
|
28
|
+
consecutiveFailures = 0;
|
|
29
|
+
state = 'connecting';
|
|
30
|
+
// Handlers required by the Transport interface
|
|
31
|
+
onclose;
|
|
32
|
+
onerror;
|
|
33
|
+
onmessage;
|
|
34
|
+
// Queue for messages sent while reconnecting
|
|
35
|
+
messageQueue = [];
|
|
36
|
+
// Pending ping requests awaiting a response
|
|
37
|
+
pendingPings = new Map();
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.log = options.logger ?? defaultLogger;
|
|
40
|
+
this.options = { ...DEFAULT_OPTIONS, logger: this.log, ...options };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Initializes the transport and connects to the backend
|
|
44
|
+
*
|
|
45
|
+
* This method is idempotent - if the transport is already connected or
|
|
46
|
+
* connecting, subsequent calls will return immediately without creating
|
|
47
|
+
* duplicate connections. This is important because MCP SDK's client.connect()
|
|
48
|
+
* internally calls transport.start(), and we also call it explicitly in bridge.ts.
|
|
49
|
+
*/
|
|
50
|
+
async start() {
|
|
51
|
+
// Idempotent check: if already connected or connecting, return early
|
|
52
|
+
if (this.state === 'connected' || this.state === 'connecting') {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.state = 'connecting';
|
|
56
|
+
await this.performConnect(true);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Enable heartbeat monitoring. Call this AFTER the MCP client has
|
|
60
|
+
* completed its initialize handshake (client.connect()), otherwise
|
|
61
|
+
* the server will reject ping requests with "Server not initialized".
|
|
62
|
+
*/
|
|
63
|
+
enableHeartbeat() {
|
|
64
|
+
this.startHeartbeat();
|
|
65
|
+
}
|
|
66
|
+
async performConnect(initial = false) {
|
|
67
|
+
try {
|
|
68
|
+
this.activeTransport = await this.options.connectParams();
|
|
69
|
+
// Inherit the handlers from this wrapper
|
|
70
|
+
this.bindInnerTransport(this.activeTransport);
|
|
71
|
+
await this.activeTransport.start();
|
|
72
|
+
this.state = 'connected';
|
|
73
|
+
this.consecutiveFailures = 0;
|
|
74
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Connected via ${this.activeTransport.constructor.name}`);
|
|
75
|
+
// Flush any queued messages
|
|
76
|
+
this.flushQueue();
|
|
77
|
+
// Only start heartbeat on reconnects — initial connections need
|
|
78
|
+
// the caller to invoke enableHeartbeat() after client.connect()
|
|
79
|
+
// completes the MCP initialize handshake. Sending pings before
|
|
80
|
+
// initialize causes "Server not initialized" errors.
|
|
81
|
+
if (!initial) {
|
|
82
|
+
this.startHeartbeat();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Connect failed: ${err}`);
|
|
87
|
+
if (initial) {
|
|
88
|
+
this.state = 'closed';
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
this.triggerReconnect(); // Retry connection
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
bindInnerTransport(transport) {
|
|
95
|
+
transport.onclose = () => {
|
|
96
|
+
// If the inner transport closes but we are not intentionally closed
|
|
97
|
+
if (this.state !== 'closed') {
|
|
98
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Inner transport closed unexpectedly. Reconnecting...`);
|
|
99
|
+
this.triggerReconnect();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
transport.onerror = (error) => {
|
|
103
|
+
// If it throws ENOENT or similar before we even catch it, we might be here.
|
|
104
|
+
if (this.state !== 'closed') {
|
|
105
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Inner transport error: ${error.message}`);
|
|
106
|
+
if (this.state === 'connecting') {
|
|
107
|
+
if (this.onerror)
|
|
108
|
+
this.onerror(error);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.triggerReconnect();
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
transport.onmessage = (message) => {
|
|
115
|
+
// Intercept our own ping responses here (both success and error responses)
|
|
116
|
+
if ('id' in message && typeof message.id === 'string' && message.id.startsWith('respl-ping-')) {
|
|
117
|
+
const resolve = this.pendingPings.get(message.id);
|
|
118
|
+
if (resolve) {
|
|
119
|
+
// Any response (even error like "Method not found") means server is alive
|
|
120
|
+
resolve(true);
|
|
121
|
+
this.pendingPings.delete(message.id);
|
|
122
|
+
}
|
|
123
|
+
return; // Don't forward ping responses to downstream
|
|
124
|
+
}
|
|
125
|
+
if (this.onmessage) {
|
|
126
|
+
this.onmessage(message);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
startHeartbeat() {
|
|
131
|
+
this.stopHeartbeat();
|
|
132
|
+
if (this.options.pingIntervalMs <= 0)
|
|
133
|
+
return;
|
|
134
|
+
this.heartbeatTimer = setInterval(() => {
|
|
135
|
+
this.checkHealth();
|
|
136
|
+
}, this.options.pingIntervalMs);
|
|
137
|
+
}
|
|
138
|
+
stopHeartbeat() {
|
|
139
|
+
if (this.heartbeatTimer) {
|
|
140
|
+
clearInterval(this.heartbeatTimer);
|
|
141
|
+
this.heartbeatTimer = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Track consecutive ping timeouts (no response at all, not even an error) */
|
|
145
|
+
consecutivePingTimeouts = 0;
|
|
146
|
+
/** If server doesn't support ping, auto-disable heartbeat after maxConsecutiveFailures timeouts */
|
|
147
|
+
pingDisabled = false;
|
|
148
|
+
async checkHealth() {
|
|
149
|
+
if (this.state !== 'connected' || !this.activeTransport)
|
|
150
|
+
return;
|
|
151
|
+
if (this.pingDisabled)
|
|
152
|
+
return;
|
|
153
|
+
try {
|
|
154
|
+
// Send raw ping JSON-RPC
|
|
155
|
+
const pingId = `respl-ping-${Date.now()}`;
|
|
156
|
+
const responsePromise = new Promise((resolve) => {
|
|
157
|
+
this.pendingPings.set(pingId, resolve);
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
if (this.pendingPings.has(pingId)) {
|
|
160
|
+
this.pendingPings.delete(pingId);
|
|
161
|
+
resolve(false); // Timeout
|
|
162
|
+
}
|
|
163
|
+
}, this.options.pingTimeoutMs);
|
|
164
|
+
});
|
|
165
|
+
// Try to send ping — if send() throws, the transport itself is broken
|
|
166
|
+
let sendFailed = false;
|
|
167
|
+
try {
|
|
168
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💓 Sending heartbeat ping (id: ${pingId})`);
|
|
169
|
+
await this.activeTransport.send({
|
|
170
|
+
jsonrpc: '2.0',
|
|
171
|
+
id: pingId,
|
|
172
|
+
method: 'ping',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (sendErr) {
|
|
176
|
+
sendFailed = true;
|
|
177
|
+
throw sendErr; // Transport broken, treat as real failure
|
|
178
|
+
}
|
|
179
|
+
const success = await responsePromise;
|
|
180
|
+
if (!success) {
|
|
181
|
+
// Ping was sent successfully but timed out — server might not support ping
|
|
182
|
+
this.consecutivePingTimeouts++;
|
|
183
|
+
if (this.consecutivePingTimeouts >= this.options.maxConsecutiveFailures) {
|
|
184
|
+
// Server consistently never responds to ping — it probably doesn't support
|
|
185
|
+
// the ping method. Auto-disable heartbeat instead of reconnecting, since
|
|
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;
|
|
191
|
+
}
|
|
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
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// Got a response — server is alive
|
|
197
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] 💖 Heartbeat successful (id: ${pingId})`);
|
|
198
|
+
this.consecutiveFailures = 0;
|
|
199
|
+
this.consecutivePingTimeouts = 0;
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
this.consecutiveFailures++;
|
|
203
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 💔 Heartbeat error (attempt ${this.consecutiveFailures}/${this.options.maxConsecutiveFailures}): ${err}`);
|
|
204
|
+
if (this.consecutiveFailures >= this.options.maxConsecutiveFailures) {
|
|
205
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Max consecutive heartbeat failures reached. Force reconnecting.`);
|
|
206
|
+
this.triggerReconnect();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
triggerReconnect() {
|
|
211
|
+
if (this.state === 'reconnecting' || this.state === 'closed')
|
|
212
|
+
return;
|
|
213
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] 🔄 Triggering reconnect (previous state: ${this.state})`);
|
|
214
|
+
this.state = 'reconnecting';
|
|
215
|
+
this.stopHeartbeat();
|
|
216
|
+
// Clean up old transport
|
|
217
|
+
if (this.activeTransport) {
|
|
218
|
+
// Avoid triggering our own onclose
|
|
219
|
+
this.activeTransport.onclose = undefined;
|
|
220
|
+
this.activeTransport.onerror = undefined;
|
|
221
|
+
try {
|
|
222
|
+
this.activeTransport.close();
|
|
223
|
+
}
|
|
224
|
+
catch { /* ignore */ }
|
|
225
|
+
this.activeTransport = null;
|
|
226
|
+
}
|
|
227
|
+
setTimeout(() => {
|
|
228
|
+
this.performConnect();
|
|
229
|
+
}, this.options.reconnectDelayMs);
|
|
230
|
+
}
|
|
231
|
+
async flushQueue() {
|
|
232
|
+
if (!this.activeTransport || this.state !== 'connected')
|
|
233
|
+
return;
|
|
234
|
+
if (this.messageQueue.length > 0) {
|
|
235
|
+
this.log.info(`[McpProxy] [ResilientTransport:${this.options.name}] Flushing ${this.messageQueue.length} queued requests...`);
|
|
236
|
+
}
|
|
237
|
+
const queueToFlush = [...this.messageQueue];
|
|
238
|
+
this.messageQueue = [];
|
|
239
|
+
for (let i = 0; i < queueToFlush.length; i++) {
|
|
240
|
+
const msg = queueToFlush[i];
|
|
241
|
+
try {
|
|
242
|
+
await this.activeTransport.send(msg);
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
this.log.error(`[McpProxy] [ResilientTransport:${this.options.name}] Error flushing queue: ${e}`);
|
|
246
|
+
// If sending fails, put this message and remaining ones back at the front of the queue
|
|
247
|
+
const failedAndRemaining = queueToFlush.slice(i);
|
|
248
|
+
this.messageQueue = [...failedAndRemaining, ...this.messageQueue];
|
|
249
|
+
this.triggerReconnect();
|
|
250
|
+
break; // Stop flushing until reconnected
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// --- Transport Interface Methods ---
|
|
255
|
+
async send(message) {
|
|
256
|
+
if (this.state === 'closed') {
|
|
257
|
+
throw new Error('Transport is closed');
|
|
258
|
+
}
|
|
259
|
+
if (this.state === 'reconnecting' || this.state === 'connecting') {
|
|
260
|
+
// Queue the message
|
|
261
|
+
if (this.messageQueue.length >= this.options.maxQueueSize) {
|
|
262
|
+
this.log.warn(`[McpProxy] [ResilientTransport:${this.options.name}] Message queue full, dropping oldest request.`);
|
|
263
|
+
this.messageQueue.shift(); // Drop oldest
|
|
264
|
+
}
|
|
265
|
+
this.messageQueue.push(message);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!this.activeTransport) {
|
|
269
|
+
throw new Error('No active transport to send message');
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
await this.activeTransport.send(message);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
// Queue it and reconnect
|
|
276
|
+
this.messageQueue.push(message);
|
|
277
|
+
this.triggerReconnect();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async close() {
|
|
281
|
+
if (this.state === 'closed')
|
|
282
|
+
return;
|
|
283
|
+
this.state = 'closed';
|
|
284
|
+
this.stopHeartbeat();
|
|
285
|
+
this.messageQueue = [];
|
|
286
|
+
if (this.activeTransport) {
|
|
287
|
+
// Prevent bubble up closure
|
|
288
|
+
this.activeTransport.onclose = undefined;
|
|
289
|
+
this.activeTransport.onerror = undefined;
|
|
290
|
+
await this.activeTransport.close();
|
|
291
|
+
this.activeTransport = null;
|
|
292
|
+
}
|
|
293
|
+
if (this.onclose) {
|
|
294
|
+
this.onclose();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* Transport layer — connect to upstream MCP servers via stdio, Streamable HTTP, or SSE
|
|
3
3
|
*/
|
|
4
4
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
5
|
+
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
5
6
|
import type { StdioServerEntry, StreamableServerEntry, SseServerEntry } from './types.js';
|
|
6
7
|
export interface ConnectedClient {
|
|
7
8
|
client: Client;
|
|
8
9
|
cleanup: () => Promise<void>;
|
|
10
|
+
transport: Transport;
|
|
9
11
|
}
|
|
10
12
|
/**
|
|
11
13
|
* Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
|
package/dist/transport.js
CHANGED
|
@@ -6,6 +6,22 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|
|
6
6
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
7
7
|
import { CustomStdioClientTransport } from './customStdio.js';
|
|
8
8
|
import { logInfo } from './logger.js';
|
|
9
|
+
import { ResilientTransportWrapper } from './resilient.js';
|
|
10
|
+
/**
|
|
11
|
+
* Helper to wrap a promise with a timeout
|
|
12
|
+
*/
|
|
13
|
+
async function withTimeout(promise, ms, message) {
|
|
14
|
+
let timeoutId;
|
|
15
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
16
|
+
timeoutId = setTimeout(() => reject(new Error(message)), ms);
|
|
17
|
+
});
|
|
18
|
+
try {
|
|
19
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
clearTimeout(timeoutId);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
9
25
|
/**
|
|
10
26
|
* Build a clean env for child processes (strips ELECTRON_RUN_AS_NODE)
|
|
11
27
|
*/
|
|
@@ -38,28 +54,49 @@ export function buildRequestHeaders(entry) {
|
|
|
38
54
|
*/
|
|
39
55
|
export async function connectStdio(id, entry, baseEnv) {
|
|
40
56
|
logInfo(`Connecting to "${id}" (stdio): ${entry.command} ${(entry.args || []).join(' ')}`);
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
const wrapper = new ResilientTransportWrapper({
|
|
58
|
+
name: id,
|
|
59
|
+
connectParams: async () => {
|
|
60
|
+
const t = new CustomStdioClientTransport({
|
|
61
|
+
command: entry.command,
|
|
62
|
+
args: entry.args || [],
|
|
63
|
+
env: { ...baseEnv, ...(entry.env || {}) },
|
|
64
|
+
stderr: 'pipe',
|
|
65
|
+
});
|
|
66
|
+
if (t.stderr) {
|
|
67
|
+
t.stderr.on('data', (chunk) => {
|
|
68
|
+
const text = chunk.toString().trim();
|
|
69
|
+
if (text) {
|
|
70
|
+
process.stderr.write(`[child:${id}] ${text}\n`);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
53
73
|
}
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
return t;
|
|
75
|
+
},
|
|
76
|
+
pingIntervalMs: entry.pingIntervalMs,
|
|
77
|
+
pingTimeoutMs: entry.pingTimeoutMs,
|
|
78
|
+
});
|
|
56
79
|
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
|
57
|
-
|
|
80
|
+
try {
|
|
81
|
+
await withTimeout((async () => {
|
|
82
|
+
await wrapper.start();
|
|
83
|
+
await client.connect(wrapper);
|
|
84
|
+
wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
|
|
85
|
+
})(), 5000, `Connection initialization timed out after 5s`);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
try {
|
|
89
|
+
await wrapper.close();
|
|
90
|
+
}
|
|
91
|
+
catch { /* ignore */ }
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
58
94
|
return {
|
|
59
95
|
client,
|
|
96
|
+
transport: wrapper,
|
|
60
97
|
cleanup: async () => {
|
|
61
98
|
try {
|
|
62
|
-
await
|
|
99
|
+
await wrapper.close();
|
|
63
100
|
}
|
|
64
101
|
catch { /* ignore */ }
|
|
65
102
|
},
|
|
@@ -75,14 +112,35 @@ export async function connectStreamable(id, entry) {
|
|
|
75
112
|
logInfo(`Connecting to "${id}" (streamable-http): ${entry.url}`);
|
|
76
113
|
const headers = buildRequestHeaders(entry);
|
|
77
114
|
const url = new URL(entry.url);
|
|
78
|
-
const
|
|
115
|
+
const wrapper = new ResilientTransportWrapper({
|
|
116
|
+
name: id,
|
|
117
|
+
connectParams: async () => {
|
|
118
|
+
return new StreamableHTTPClientTransport(url, headers ? { requestInit: { headers } } : undefined);
|
|
119
|
+
},
|
|
120
|
+
pingIntervalMs: entry.pingIntervalMs,
|
|
121
|
+
pingTimeoutMs: entry.pingTimeoutMs,
|
|
122
|
+
});
|
|
79
123
|
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
|
80
|
-
|
|
124
|
+
try {
|
|
125
|
+
await withTimeout((async () => {
|
|
126
|
+
await wrapper.start();
|
|
127
|
+
await client.connect(wrapper);
|
|
128
|
+
wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
|
|
129
|
+
})(), 5000, `Connection initialization timed out after 5s`);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
try {
|
|
133
|
+
await wrapper.close();
|
|
134
|
+
}
|
|
135
|
+
catch { /* ignore */ }
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
81
138
|
return {
|
|
82
139
|
client,
|
|
140
|
+
transport: wrapper,
|
|
83
141
|
cleanup: async () => {
|
|
84
142
|
try {
|
|
85
|
-
await
|
|
143
|
+
await wrapper.close();
|
|
86
144
|
}
|
|
87
145
|
catch { /* ignore */ }
|
|
88
146
|
},
|
|
@@ -98,14 +156,35 @@ export async function connectSse(id, entry) {
|
|
|
98
156
|
logInfo(`Connecting to "${id}" (sse): ${entry.url}`);
|
|
99
157
|
const headers = buildRequestHeaders(entry);
|
|
100
158
|
const url = new URL(entry.url);
|
|
101
|
-
const
|
|
159
|
+
const wrapper = new ResilientTransportWrapper({
|
|
160
|
+
name: id,
|
|
161
|
+
connectParams: async () => {
|
|
162
|
+
return new SSEClientTransport(url, headers ? { requestInit: { headers } } : undefined);
|
|
163
|
+
},
|
|
164
|
+
pingIntervalMs: entry.pingIntervalMs,
|
|
165
|
+
pingTimeoutMs: entry.pingTimeoutMs,
|
|
166
|
+
});
|
|
102
167
|
const client = new Client({ name: `proxy-${id}`, version: '1.0.0' });
|
|
103
|
-
|
|
168
|
+
try {
|
|
169
|
+
await withTimeout((async () => {
|
|
170
|
+
await wrapper.start();
|
|
171
|
+
await client.connect(wrapper);
|
|
172
|
+
wrapper.enableHeartbeat(); // Start heartbeat AFTER MCP initialize completes
|
|
173
|
+
})(), 5000, `Connection initialization timed out after 5s`);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
try {
|
|
177
|
+
await wrapper.close();
|
|
178
|
+
}
|
|
179
|
+
catch { /* ignore */ }
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
104
182
|
return {
|
|
105
183
|
client,
|
|
184
|
+
transport: wrapper,
|
|
106
185
|
cleanup: async () => {
|
|
107
186
|
try {
|
|
108
|
-
await
|
|
187
|
+
await wrapper.close();
|
|
109
188
|
}
|
|
110
189
|
catch { /* ignore */ }
|
|
111
190
|
},
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export interface StdioServerEntry {
|
|
|
10
10
|
allowTools?: string[];
|
|
11
11
|
/** 工具黑名单(排除指定工具) */
|
|
12
12
|
denyTools?: string[];
|
|
13
|
+
/** Heartbeat ping interval configuration (ms) */
|
|
14
|
+
pingIntervalMs?: number;
|
|
15
|
+
/** Heartbeat ping timeout configuration (ms) */
|
|
16
|
+
pingTimeoutMs?: number;
|
|
13
17
|
}
|
|
14
18
|
/**
|
|
15
19
|
* Streamable HTTP 类型: 连接远程 MCP server (Streamable HTTP)
|
|
@@ -26,6 +30,10 @@ export interface StreamableServerEntry {
|
|
|
26
30
|
allowTools?: string[];
|
|
27
31
|
/** 工具黑名单(排除指定工具) */
|
|
28
32
|
denyTools?: string[];
|
|
33
|
+
/** Heartbeat ping interval configuration (ms) */
|
|
34
|
+
pingIntervalMs?: number;
|
|
35
|
+
/** Heartbeat ping timeout configuration (ms) */
|
|
36
|
+
pingTimeoutMs?: number;
|
|
29
37
|
}
|
|
30
38
|
/**
|
|
31
39
|
* SSE 类型: 连接远程 MCP server (Server-Sent Events)
|
|
@@ -41,6 +49,10 @@ export interface SseServerEntry {
|
|
|
41
49
|
allowTools?: string[];
|
|
42
50
|
/** 工具黑名单(排除指定工具) */
|
|
43
51
|
denyTools?: string[];
|
|
52
|
+
/** Heartbeat ping interval configuration (ms) */
|
|
53
|
+
pingIntervalMs?: number;
|
|
54
|
+
/** Heartbeat ping timeout configuration (ms) */
|
|
55
|
+
pingTimeoutMs?: number;
|
|
44
56
|
}
|
|
45
57
|
export type McpServerEntry = StdioServerEntry | StreamableServerEntry | SseServerEntry;
|
|
46
58
|
export declare function isSseEntry(entry: McpServerEntry): entry is SseServerEntry;
|
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.7",
|
|
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": {
|
|
@@ -12,18 +12,20 @@
|
|
|
12
12
|
"dist"
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
|
-
"build": "tsc && node build.mjs",
|
|
15
|
+
"build": "tsc && node scripts/build.mjs",
|
|
16
16
|
"build:tsc": "tsc",
|
|
17
17
|
"test": "npm run build:tsc && vitest",
|
|
18
18
|
"test:run": "npm run build:tsc && vitest run",
|
|
19
19
|
"test:coverage": "npm run build:tsc && vitest run --coverage",
|
|
20
|
-
"prepublishOnly": "npm run build"
|
|
20
|
+
"prepublishOnly": "npm run build",
|
|
21
|
+
"publish:non-latest": "npm publish --tag beta"
|
|
21
22
|
},
|
|
22
23
|
"dependencies": {
|
|
23
24
|
"@modelcontextprotocol/sdk": "^1.27.1"
|
|
24
25
|
},
|
|
25
26
|
"devDependencies": {
|
|
26
27
|
"@types/node": "^22.0.0",
|
|
28
|
+
"@vitest/coverage-v8": "^2.1.9",
|
|
27
29
|
"esbuild": "^0.27.3",
|
|
28
30
|
"typescript": "^5.7.0",
|
|
29
31
|
"vitest": "^2.1.8"
|