@juspay/neurolink 12.27.0 → 12.27.2
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/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +373 -373
- package/dist/cli/factories/commandFactory.js +16 -0
- package/dist/cli/loop/session.js +6 -0
- package/dist/cli/utils/stdinRef.d.ts +16 -0
- package/dist/cli/utils/stdinRef.js +21 -0
- package/dist/mcp/externalServerManager.js +173 -8
- package/dist/mcp/mcpCircuitBreaker.d.ts +8 -3
- package/dist/mcp/mcpCircuitBreaker.js +25 -9
- package/dist/mcp/mcpClientFactory.d.ts +9 -4
- package/dist/mcp/mcpClientFactory.js +26 -15
- package/dist/mcp/toolDiscoveryService.d.ts +8 -3
- package/dist/mcp/toolDiscoveryService.js +17 -7
- package/dist/proxy/proxyTracer.d.ts +9 -0
- package/dist/proxy/proxyTracer.js +101 -0
- package/package.json +2 -1
|
@@ -15,6 +15,7 @@ import { createThinkingConfigFromRecord } from "../../utils/thinkingConfig.js";
|
|
|
15
15
|
import { buildToolRoutingConfigFromCli } from "../utils/toolRoutingFlags.js";
|
|
16
16
|
import { buildClassifierRouterConfigFromCli } from "../utils/classifierRouterFlags.js";
|
|
17
17
|
import { buildSkillsConfigFromCli } from "../utils/skillsFlags.js";
|
|
18
|
+
import { ensureStdinRef } from "../utils/stdinRef.js";
|
|
18
19
|
import { SkillsManager } from "../../skills/skillsManager.js";
|
|
19
20
|
import { configManager } from "../commands/config.js";
|
|
20
21
|
import { MCPCommandFactory } from "../commands/mcp.js";
|
|
@@ -2275,6 +2276,11 @@ export class CLICommandFactory {
|
|
|
2275
2276
|
const isSttOnly = !!(argv.stt &&
|
|
2276
2277
|
argv.inputAudio);
|
|
2277
2278
|
if (!argv.input && !process.stdin.isTTY) {
|
|
2279
|
+
// The SDK's MCP stdio transport unrefs stdin on import so an idle
|
|
2280
|
+
// process can exit on its own (see externalServerManager.ts). This
|
|
2281
|
+
// path actually reads stdin and must block on it, so ref it back
|
|
2282
|
+
// before consuming.
|
|
2283
|
+
ensureStdinRef();
|
|
2278
2284
|
let stdinData = "";
|
|
2279
2285
|
process.stdin.setEncoding("utf8");
|
|
2280
2286
|
for await (const chunk of process.stdin) {
|
|
@@ -3322,6 +3328,11 @@ export class CLICommandFactory {
|
|
|
3322
3328
|
// as the prompt). Skip the stdin/empty-input rejection in that case.
|
|
3323
3329
|
const isSttOnly = !!argv.stt && !!argv.inputAudio;
|
|
3324
3330
|
if (!argv.input && !process.stdin.isTTY) {
|
|
3331
|
+
// The SDK's MCP stdio transport unrefs stdin on import so an idle
|
|
3332
|
+
// process can exit on its own (see externalServerManager.ts). This
|
|
3333
|
+
// path actually reads stdin and must block on it, so ref it back
|
|
3334
|
+
// before consuming.
|
|
3335
|
+
ensureStdinRef();
|
|
3325
3336
|
let stdinData = "";
|
|
3326
3337
|
process.stdin.setEncoding("utf8");
|
|
3327
3338
|
for await (const chunk of process.stdin) {
|
|
@@ -4320,6 +4331,11 @@ export class CLICommandFactory {
|
|
|
4320
4331
|
logger.always(chalk.yellow(`Usage: neurolink memory delete ${argv.sessionId} --force`));
|
|
4321
4332
|
return;
|
|
4322
4333
|
}
|
|
4334
|
+
// The SDK's MCP stdio transport unrefs stdin on import so an idle
|
|
4335
|
+
// process can exit on its own (see externalServerManager.ts). This
|
|
4336
|
+
// prompt actually reads stdin and must block on it, so ref it back
|
|
4337
|
+
// before consuming.
|
|
4338
|
+
ensureStdinRef();
|
|
4323
4339
|
const readline = await import("readline");
|
|
4324
4340
|
const rl = readline.createInterface({
|
|
4325
4341
|
input: process.stdin,
|
package/dist/cli/loop/session.js
CHANGED
|
@@ -10,6 +10,7 @@ import { tracers } from "../../telemetry/tracers.js";
|
|
|
10
10
|
import { handleError } from "../errorHandler.js";
|
|
11
11
|
import { ConversationSelector } from "./conversationSelector.js";
|
|
12
12
|
import { textGenerationOptionsSchema } from "./optionsSchema.js";
|
|
13
|
+
import { ensureStdinRef } from "../utils/stdinRef.js";
|
|
13
14
|
// Banner Art
|
|
14
15
|
const NEUROLINK_BANNER = `
|
|
15
16
|
▗▖ ▗▖▗▄▄▄▖▗▖ ▗▖▗▄▄▖ ▗▄▖ ▗▖ ▗▄▄▄▖▗▖ ▗▖▗▖ ▗▖
|
|
@@ -455,6 +456,11 @@ export class LoopSession {
|
|
|
455
456
|
* Get command input with history support using readline
|
|
456
457
|
*/
|
|
457
458
|
async getCommandWithHistory() {
|
|
459
|
+
// The SDK's MCP stdio transport unrefs stdin on import so an idle
|
|
460
|
+
// process can exit on its own (see externalServerManager.ts). The
|
|
461
|
+
// interactive loop reads a line from stdin on every prompt and must
|
|
462
|
+
// block on it, so ref it back before consuming.
|
|
463
|
+
ensureStdinRef();
|
|
458
464
|
return new Promise((resolve) => {
|
|
459
465
|
const rl = readline.createInterface({
|
|
460
466
|
input: process.stdin,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK unrefs `process.stdin` on import (see externalServerManager.ts) so
|
|
3
|
+
* that a process which never reads stdin can still exit on its own instead
|
|
4
|
+
* of being kept alive forever by a handle it never uses. Any CLI code path
|
|
5
|
+
* that actually reads from stdin — piped generate/stream input, a readline
|
|
6
|
+
* confirmation prompt, the interactive loop's line reader — must ref it
|
|
7
|
+
* back first, or the process can exit (or simply never treat stdin as a
|
|
8
|
+
* reason to stay alive) before the read completes.
|
|
9
|
+
*
|
|
10
|
+
* Centralizing the call here means a new stdin consumer only has to
|
|
11
|
+
* remember to call this, not to rediscover and re-derive the reason why.
|
|
12
|
+
* Three call sites each doing this inline already produced one that forgot
|
|
13
|
+
* to (the memory-delete confirmation prompt) — a fourth is just as likely
|
|
14
|
+
* to repeat that.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ensureStdinRef(): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK unrefs `process.stdin` on import (see externalServerManager.ts) so
|
|
3
|
+
* that a process which never reads stdin can still exit on its own instead
|
|
4
|
+
* of being kept alive forever by a handle it never uses. Any CLI code path
|
|
5
|
+
* that actually reads from stdin — piped generate/stream input, a readline
|
|
6
|
+
* confirmation prompt, the interactive loop's line reader — must ref it
|
|
7
|
+
* back first, or the process can exit (or simply never treat stdin as a
|
|
8
|
+
* reason to stay alive) before the read completes.
|
|
9
|
+
*
|
|
10
|
+
* Centralizing the call here means a new stdin consumer only has to
|
|
11
|
+
* remember to call this, not to rediscover and re-derive the reason why.
|
|
12
|
+
* Three call sites each doing this inline already produced one that forgot
|
|
13
|
+
* to (the memory-delete confirmation prompt) — a fourth is just as likely
|
|
14
|
+
* to repeat that.
|
|
15
|
+
*/
|
|
16
|
+
export function ensureStdinRef() {
|
|
17
|
+
if (process.stdin && typeof process.stdin.ref === "function") {
|
|
18
|
+
process.stdin.ref();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=stdinRef.js.map
|
|
@@ -18,6 +18,45 @@ import { isObject, isNonNullObject } from "../utils/typeUtils.js";
|
|
|
18
18
|
import { TelemetryService } from "../telemetry/telemetryService.js";
|
|
19
19
|
import { tracers } from "../telemetry/tracers.js";
|
|
20
20
|
import { SpanStatusCode } from "@opentelemetry/api";
|
|
21
|
+
// `@modelcontextprotocol/sdk`'s stdio transport (imported above) does
|
|
22
|
+
// `import process from "node:process"` at module scope. Node's ESM/CJS
|
|
23
|
+
// interop builds a synthetic module facade for that import by walking every
|
|
24
|
+
// property getter on the builtin `process` singleton — including the
|
|
25
|
+
// lazily-initialized `stdin` getter — so merely importing this module
|
|
26
|
+
// creates and permanently refs a stdin handle, whether or not an MCP stdio
|
|
27
|
+
// client is ever constructed. A single ref'd handle is enough to keep the
|
|
28
|
+
// event loop, and the whole process, alive forever. unref() does not close
|
|
29
|
+
// stdin or stop it from being read (a consumer that later actually reads
|
|
30
|
+
// from it — e.g. the CLI's piped-input path — calls `.ref()` again right
|
|
31
|
+
// before it does), it only stops an otherwise-idle stdin from blocking exit.
|
|
32
|
+
//
|
|
33
|
+
// This unref runs at import time, before any consumer of THIS package —
|
|
34
|
+
// this package's own CLI paths and an external SDK caller alike — has had a
|
|
35
|
+
// chance to attach its own stdin listeners. A CLI code path knows to call
|
|
36
|
+
// `ensureStdinRef()` first (see src/cli/utils/stdinRef.ts), but an external
|
|
37
|
+
// script that just does `import { NeuroLink } from "@juspay/neurolink"` and
|
|
38
|
+
// then reads `process.stdin` itself — an ordinary, previously-working
|
|
39
|
+
// pattern — has no reason to know this package touched stdin at all, and
|
|
40
|
+
// without a safety net the process can exit before the piped data ever
|
|
41
|
+
// arrives: silently, with no error, because an unref'd handle simply stops
|
|
42
|
+
// keeping the event loop alive on its own.
|
|
43
|
+
//
|
|
44
|
+
// So: re-ref stdin the first time anything registers a listener that
|
|
45
|
+
// actually consumes it. "data" covers `.on("data", ...)` directly and
|
|
46
|
+
// `.pipe()` (which attaches its own "data" listener internally); "readable"
|
|
47
|
+
// covers paused-mode reads and the async iterator
|
|
48
|
+
// (`for await (const chunk of process.stdin)`, which attaches a "readable"
|
|
49
|
+
// listener under the hood). This restores stdin's normal, non-unref'd
|
|
50
|
+
// behavior for any real consumer — internal or external — while a process
|
|
51
|
+
// that never touches stdin still exits on its own.
|
|
52
|
+
if (process.stdin && typeof process.stdin.unref === "function") {
|
|
53
|
+
process.stdin.unref();
|
|
54
|
+
process.stdin.prependListener("newListener", (event) => {
|
|
55
|
+
if (event === "data" || event === "readable") {
|
|
56
|
+
process.stdin.ref();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
21
60
|
/**
|
|
22
61
|
* Recursively substitute environment variables in strings
|
|
23
62
|
* Replaces ${VAR_NAME} with the value from process.env.VAR_NAME
|
|
@@ -218,18 +257,127 @@ function isValidExternalMCPServerConfig(config) {
|
|
|
218
257
|
*/
|
|
219
258
|
const liveManagers = new Set();
|
|
220
259
|
let processCleanupInstalled = false;
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
260
|
+
const cleanupLiveManagers = async () => {
|
|
261
|
+
// Concurrent across managers, matching the pattern one level down: a
|
|
262
|
+
// single manager's own shutdown() already fans its servers out via
|
|
263
|
+
// Promise.all rather than awaiting them one at a time. A sequential
|
|
264
|
+
// for-await here would make total SIGTERM/beforeExit cleanup time scale
|
|
265
|
+
// with the number of concurrently-alive managers instead of being bounded
|
|
266
|
+
// by the slowest one — real for this codebase, since the shared handler
|
|
267
|
+
// set above exists specifically because multiple managers are commonly
|
|
268
|
+
// alive at once (one per worker an agent delegates to).
|
|
269
|
+
await Promise.all(Array.from(liveManagers).map((manager) => manager.shutdown().catch((error) => {
|
|
270
|
+
mcpLogger.error("[ExternalServerManager] Error during process-exit cleanup:", error);
|
|
271
|
+
})));
|
|
272
|
+
};
|
|
273
|
+
/**
|
|
274
|
+
* beforeExit fires when the event loop is about to go idle. The cleanup it
|
|
275
|
+
* kicks off does its own async I/O, so this cycle will not drain — Node
|
|
276
|
+
* fires beforeExit again once that work resolves and finds nothing left,
|
|
277
|
+
* which is how the process is meant to exit on its own here, without an
|
|
278
|
+
* explicit process.exit() call.
|
|
279
|
+
*/
|
|
280
|
+
const shutdownOnBeforeExit = () => {
|
|
281
|
+
void cleanupLiveManagers();
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* signal-exit@4 (reached at runtime through `ora` -> `cli-cursor` ->
|
|
285
|
+
* `restore-cursor`, a direct dependency this package's CLI spinners use)
|
|
286
|
+
* makes the mirror-image decision to the gate below: it skips its own
|
|
287
|
+
* post-cleanup re-raise whenever it sees another listener for the signal
|
|
288
|
+
* still registered. Counting signal-exit's listener as host-owned here means
|
|
289
|
+
* both sides defer to the other and neither ever re-raises — a live
|
|
290
|
+
* spinner's cursor-restore listener would silently reintroduce the exact
|
|
291
|
+
* defect this file exists to fix.
|
|
292
|
+
*
|
|
293
|
+
* There's no reference to signal-exit's own listener function to compare
|
|
294
|
+
* against (it's private state inside that package), so it's detected the
|
|
295
|
+
* same way signal-exit's v3/v4 majors detect each other: a shared marker
|
|
296
|
+
* signal-exit itself defines. Every signal-exit v4 instance is a
|
|
297
|
+
* `globalThis`-wide singleton keyed by `Symbol.for("signal-exit emitter")`
|
|
298
|
+
* — but that key is shared across ANY number of distinct resolved copies of
|
|
299
|
+
* the package, not just identical ones. If two transitive dependencies pin
|
|
300
|
+
* non-overlapping signal-exit@4 ranges, npm/pnpm will not dedupe them, and
|
|
301
|
+
* each loaded copy independently increments the shared emitter's `count` by
|
|
302
|
+
* one AND registers its own real, distinct listener per signal it covers
|
|
303
|
+
* (SIGINT and SIGTERM both are, in every signal-exit v4 release). So `count`
|
|
304
|
+
* is a faithful running total of real signal-exit-owned listeners on
|
|
305
|
+
* `process` — one per loaded copy — and has to be discounted in full,
|
|
306
|
+
* never clamped to at most one, or a second loaded copy's very real
|
|
307
|
+
* listener gets miscounted as a host's own and silently defeats the
|
|
308
|
+
* re-raise this file exists to perform.
|
|
309
|
+
*/
|
|
310
|
+
const SIGNAL_EXIT_EMITTER = Symbol.for("signal-exit emitter");
|
|
311
|
+
const getSignalExitOwnedListenerCount = () => {
|
|
312
|
+
const globalWithSignalExit = globalThis;
|
|
313
|
+
const count = globalWithSignalExit[SIGNAL_EXIT_EMITTER]?.count;
|
|
314
|
+
return typeof count === "number" && count > 0 ? count : 0;
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* SIGINT/SIGTERM: registering a listener for either suppresses Node's
|
|
318
|
+
* default immediate-termination behavior for that signal. The previous
|
|
319
|
+
* handler ran cleanup fire-and-forget and never terminated the process
|
|
320
|
+
* afterward, so the process survived a signal it claimed to handle and
|
|
321
|
+
* needed SIGKILL to actually stop.
|
|
322
|
+
*
|
|
323
|
+
* This must NOT call process.exit() itself — this is library code that can
|
|
324
|
+
* run inside a host application, and unilaterally choosing the process's
|
|
325
|
+
* exit code/timing pre-empts whatever shutdown behavior the host installed
|
|
326
|
+
* for this same signal. Instead, once cleanup settles, remove our own
|
|
327
|
+
* listener and re-send the signal: if the host registered its own handler
|
|
328
|
+
* it now runs (we are no longer in front of it), and if nothing else is
|
|
329
|
+
* listening, Node's default terminate-on-signal behavior applies and the
|
|
330
|
+
* exit code is the correct signal-based one without us deciding it. Either
|
|
331
|
+
* way the process actually stops in response to the signal, which is the
|
|
332
|
+
* behavior this fixes — we just stop being the one that forces it down.
|
|
333
|
+
*/
|
|
334
|
+
const shutdownOnSignal = (signal) => {
|
|
335
|
+
// Whether anyone ELSE is listening has to be decided HERE, synchronously, at
|
|
336
|
+
// the top of the dispatch — not after cleanup resolves. Node removes a
|
|
337
|
+
// `process.once` listener as it dispatches to it, so a host using the very
|
|
338
|
+
// common one-shot shutdown shape leaves a listener count of zero behind
|
|
339
|
+
// while its own async drain is still running. Reading the count later would
|
|
340
|
+
// see that zero, conclude nobody was listening, re-raise, and kill the host
|
|
341
|
+
// mid-shutdown — the exact harm this gate exists to prevent, reached from
|
|
342
|
+
// the other side. Pairing this snapshot with `prependListener` at
|
|
343
|
+
// registration is what makes it correct: we run before any other listener,
|
|
344
|
+
// so nothing has been consumed yet and every host listener is still counted.
|
|
345
|
+
//
|
|
346
|
+
// signal-exit's own listener (if loaded) is subtracted out first: it is a
|
|
347
|
+
// cursor-restore helper, not a host that owns this process's shutdown
|
|
348
|
+
// decision, and counting it as one would defer to it forever (see above).
|
|
349
|
+
const hadOtherListeners = process.listenerCount(signal) - getSignalExitOwnedListenerCount() > 1;
|
|
350
|
+
void cleanupLiveManagers().finally(() => {
|
|
351
|
+
// With a host handler present, that handler already received this delivery
|
|
352
|
+
// and already made its own keep-alive/exit decision; re-sending would run
|
|
353
|
+
// it a second time it never asked for, and for the common "first signal
|
|
354
|
+
// drains, second one forces" shape that tears it down early.
|
|
355
|
+
//
|
|
356
|
+
// Note what is NOT done in that case: the listener stays registered. A
|
|
357
|
+
// host that owns the signal may well decide to keep running, and
|
|
358
|
+
// `processCleanupInstalled` is a one-shot latch — so a listener removed
|
|
359
|
+
// here is never reinstalled, and any manager constructed afterwards would
|
|
360
|
+
// silently lose signal cleanup for the rest of the process's life. The
|
|
361
|
+
// removal exists for exactly one purpose, to let the re-raise reach Node's
|
|
362
|
+
// default disposition instead of coming back to us, so it belongs with the
|
|
363
|
+
// re-raise and nowhere else.
|
|
364
|
+
if (!hadOtherListeners) {
|
|
365
|
+
process.removeListener(signal, shutdownOnSignal);
|
|
366
|
+
process.kill(process.pid, signal);
|
|
367
|
+
}
|
|
368
|
+
});
|
|
225
369
|
};
|
|
226
370
|
const registerManagerForProcessCleanup = (manager) => {
|
|
227
371
|
liveManagers.add(manager);
|
|
228
372
|
if (!processCleanupInstalled) {
|
|
229
373
|
processCleanupInstalled = true;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
374
|
+
// prependListener, not on: the gate above counts listeners at dispatch, and
|
|
375
|
+
// that count is only trustworthy if nothing has been consumed yet. Running
|
|
376
|
+
// first guarantees that. It also costs a host nothing — our handler only
|
|
377
|
+
// starts async cleanup and never blocks the listeners behind it.
|
|
378
|
+
process.prependListener("SIGINT", shutdownOnSignal);
|
|
379
|
+
process.prependListener("SIGTERM", shutdownOnSignal);
|
|
380
|
+
process.on("beforeExit", shutdownOnBeforeExit);
|
|
233
381
|
}
|
|
234
382
|
};
|
|
235
383
|
export class ExternalServerManager extends EventEmitter {
|
|
@@ -994,7 +1142,15 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
994
1142
|
instance.client.onclose = undefined;
|
|
995
1143
|
instance.client.onerror = undefined;
|
|
996
1144
|
try {
|
|
997
|
-
|
|
1145
|
+
// Forward the child handle so the factory's SIGKILL escalation can run
|
|
1146
|
+
// when the transport's own close does not bring the process down.
|
|
1147
|
+
// NOTE: `instance.process` is currently always null for stdio servers —
|
|
1148
|
+
// MCPClientFactory.createStdioTransport returns only `{ transport }`, so
|
|
1149
|
+
// `clientResult.process` is never populated and the escalation inside
|
|
1150
|
+
// closeClient remains unreachable. Surfacing the spawned child from the
|
|
1151
|
+
// transport is a separate change; this argument is wired so that it
|
|
1152
|
+
// starts working the moment it is.
|
|
1153
|
+
await MCPClientFactory.closeClient(instance.client, instance.transportInstance, instance.process ?? undefined);
|
|
998
1154
|
}
|
|
999
1155
|
catch (error) {
|
|
1000
1156
|
mcpLogger.debug(`[ExternalServerManager] Error closing client for ${serverId}:`, error);
|
|
@@ -1256,6 +1412,15 @@ export class ExternalServerManager extends EventEmitter {
|
|
|
1256
1412
|
instance.healthTimer = setInterval(async () => {
|
|
1257
1413
|
await this.performHealthCheck(serverId);
|
|
1258
1414
|
}, interval);
|
|
1415
|
+
// Unref'd so a live health check on a connected server doesn't itself
|
|
1416
|
+
// keep an otherwise-idle process alive — a caller that never calls
|
|
1417
|
+
// shutdown()/stopServer() (e.g. a script that just uses the SDK and
|
|
1418
|
+
// lets the process exit naturally) shouldn't be forced to wait out
|
|
1419
|
+
// this interval. stopServer() still clearInterval()s it for explicit
|
|
1420
|
+
// lifecycle management.
|
|
1421
|
+
if (instance.healthTimer.unref) {
|
|
1422
|
+
instance.healthTimer.unref();
|
|
1423
|
+
}
|
|
1259
1424
|
}
|
|
1260
1425
|
/**
|
|
1261
1426
|
* Perform a health check by pinging the server over its live transport.
|
|
@@ -58,9 +58,14 @@ export declare class MCPCircuitBreaker extends EventEmitter {
|
|
|
58
58
|
*/
|
|
59
59
|
private changeState;
|
|
60
60
|
/**
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
* Race an operation against a timeout, clearing the timer on either
|
|
62
|
+
* outcome. A bare `Promise.race([operation, timeoutPromise])` leaves the
|
|
63
|
+
* timeout's `setTimeout` handle ref'd and pending until it fires even
|
|
64
|
+
* after `operation` already won the race — on the (common) success path
|
|
65
|
+
* that stray timer holds the event loop open for the full timeout
|
|
66
|
+
* duration for no reason.
|
|
67
|
+
*/
|
|
68
|
+
private raceWithTimeout;
|
|
64
69
|
/**
|
|
65
70
|
* Clean up old call records
|
|
66
71
|
*/
|
|
@@ -42,8 +42,15 @@ export class MCPCircuitBreaker extends EventEmitter {
|
|
|
42
42
|
minimumCallsBeforeCalculation: config.minimumCallsBeforeCalculation ?? 10,
|
|
43
43
|
statisticsWindowSize: config.statisticsWindowSize ?? 300000, // 5 minutes
|
|
44
44
|
};
|
|
45
|
-
// Clean up old call records periodically - now storing the timer reference
|
|
45
|
+
// Clean up old call records periodically - now storing the timer reference.
|
|
46
|
+
// Unref'd so a live breaker (the manager's Map is a module-level
|
|
47
|
+
// singleton, never torn down by a script that merely imports the SDK)
|
|
48
|
+
// doesn't block process exit on its own — destroy()/destroyAll() still
|
|
49
|
+
// clearInterval() it for callers that do explicit lifecycle management.
|
|
46
50
|
this.cleanupTimer = setInterval(() => this.cleanupCallHistory(), 60000);
|
|
51
|
+
if (this.cleanupTimer.unref) {
|
|
52
|
+
this.cleanupTimer.unref();
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
55
|
/**
|
|
49
56
|
* Execute an operation with circuit breaker protection.
|
|
@@ -116,10 +123,7 @@ export class MCPCircuitBreaker extends EventEmitter {
|
|
|
116
123
|
resolvedFailureReason = reason || "Operation resolved a failure result";
|
|
117
124
|
};
|
|
118
125
|
// Execute operation with timeout
|
|
119
|
-
const result = await
|
|
120
|
-
operation(recordResolvedFailure),
|
|
121
|
-
this.timeoutPromise(this.config.operationTimeout),
|
|
122
|
-
]);
|
|
126
|
+
const result = await this.raceWithTimeout(operation(recordResolvedFailure), this.config.operationTimeout);
|
|
123
127
|
if (resolvedFailureReason !== undefined) {
|
|
124
128
|
// Same bookkeeping as a thrown error below, but the resolved value
|
|
125
129
|
// is still returned to the caller unchanged — no transport error is
|
|
@@ -257,14 +261,26 @@ export class MCPCircuitBreaker extends EventEmitter {
|
|
|
257
261
|
});
|
|
258
262
|
}
|
|
259
263
|
/**
|
|
260
|
-
*
|
|
264
|
+
* Race an operation against a timeout, clearing the timer on either
|
|
265
|
+
* outcome. A bare `Promise.race([operation, timeoutPromise])` leaves the
|
|
266
|
+
* timeout's `setTimeout` handle ref'd and pending until it fires even
|
|
267
|
+
* after `operation` already won the race — on the (common) success path
|
|
268
|
+
* that stray timer holds the event loop open for the full timeout
|
|
269
|
+
* duration for no reason.
|
|
261
270
|
*/
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
271
|
+
async raceWithTimeout(operation, timeout) {
|
|
272
|
+
let timeoutId;
|
|
273
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
274
|
+
timeoutId = setTimeout(() => {
|
|
265
275
|
reject(new Error(`Operation timed out after ${timeout}ms`));
|
|
266
276
|
}, timeout);
|
|
267
277
|
});
|
|
278
|
+
try {
|
|
279
|
+
return await Promise.race([operation, timeoutPromise]);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
clearTimeout(timeoutId);
|
|
283
|
+
}
|
|
268
284
|
}
|
|
269
285
|
/**
|
|
270
286
|
* Clean up old call records
|
|
@@ -97,10 +97,15 @@ export declare class MCPClientFactory {
|
|
|
97
97
|
*/
|
|
98
98
|
private static extractCapabilities;
|
|
99
99
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
100
|
+
* Race an operation against a timeout (with optional AbortController
|
|
101
|
+
* support), clearing the timer on every outcome. A bare
|
|
102
|
+
* `Promise.race([operation, timeoutPromise])` leaves the timeout's
|
|
103
|
+
* `setTimeout` handle ref'd and pending until it fires even after
|
|
104
|
+
* `operation` already won the race — on the (common) success path that
|
|
105
|
+
* stray timer holds the event loop open for the full timeout duration for
|
|
106
|
+
* no reason.
|
|
107
|
+
*/
|
|
108
|
+
private static raceWithTimeout;
|
|
104
109
|
/**
|
|
105
110
|
* Close an MCP client and clean up resources
|
|
106
111
|
*/
|
|
@@ -213,10 +213,7 @@ export class MCPClientFactory {
|
|
|
213
213
|
capabilities: this.DEFAULT_CAPABILITIES,
|
|
214
214
|
});
|
|
215
215
|
// Connect with timeout
|
|
216
|
-
await
|
|
217
|
-
client.connect(transport),
|
|
218
|
-
this.createTimeoutPromise(timeout, `Client connection timeout for ${config.id}`),
|
|
219
|
-
]);
|
|
216
|
+
await this.raceWithTimeout(client.connect(transport), timeout, `Client connection timeout for ${config.id}`);
|
|
220
217
|
// Perform handshake to get server capabilities
|
|
221
218
|
const serverCapabilities = await this.performHandshake(client, timeout);
|
|
222
219
|
mcpLogger.debug(`[MCPClientFactory] Handshake completed for ${config.id}`, {
|
|
@@ -516,10 +513,7 @@ export class MCPClientFactory {
|
|
|
516
513
|
try {
|
|
517
514
|
// The MCP SDK handles the handshake automatically during connect()
|
|
518
515
|
// We can request server info to verify the connection
|
|
519
|
-
const serverInfo = await
|
|
520
|
-
this.getServerInfo(client),
|
|
521
|
-
this.createTimeoutPromise(timeout, "Handshake timeout"),
|
|
522
|
-
]);
|
|
516
|
+
const serverInfo = await this.raceWithTimeout(this.getServerInfo(client), timeout, "Handshake timeout");
|
|
523
517
|
// Extract capabilities from server info
|
|
524
518
|
return this.extractCapabilities(serverInfo);
|
|
525
519
|
}
|
|
@@ -563,22 +557,39 @@ export class MCPClientFactory {
|
|
|
563
557
|
};
|
|
564
558
|
}
|
|
565
559
|
/**
|
|
566
|
-
*
|
|
567
|
-
*
|
|
560
|
+
* Race an operation against a timeout (with optional AbortController
|
|
561
|
+
* support), clearing the timer on every outcome. A bare
|
|
562
|
+
* `Promise.race([operation, timeoutPromise])` leaves the timeout's
|
|
563
|
+
* `setTimeout` handle ref'd and pending until it fires even after
|
|
564
|
+
* `operation` already won the race — on the (common) success path that
|
|
565
|
+
* stray timer holds the event loop open for the full timeout duration for
|
|
566
|
+
* no reason.
|
|
568
567
|
*/
|
|
569
|
-
static
|
|
570
|
-
|
|
571
|
-
|
|
568
|
+
static async raceWithTimeout(operation, timeout, message, abortSignal) {
|
|
569
|
+
let timeoutId;
|
|
570
|
+
let onAbort;
|
|
571
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
572
|
+
timeoutId = setTimeout(() => {
|
|
572
573
|
reject(new Error(message));
|
|
573
574
|
}, timeout);
|
|
574
575
|
// Support abortion for better async cleanup
|
|
575
576
|
if (abortSignal) {
|
|
576
|
-
|
|
577
|
+
onAbort = () => {
|
|
577
578
|
clearTimeout(timeoutId);
|
|
578
579
|
reject(new Error(`Operation aborted: ${message}`));
|
|
579
|
-
}
|
|
580
|
+
};
|
|
581
|
+
abortSignal.addEventListener("abort", onAbort);
|
|
580
582
|
}
|
|
581
583
|
});
|
|
584
|
+
try {
|
|
585
|
+
return await Promise.race([operation, timeoutPromise]);
|
|
586
|
+
}
|
|
587
|
+
finally {
|
|
588
|
+
clearTimeout(timeoutId);
|
|
589
|
+
if (abortSignal && onAbort) {
|
|
590
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
582
593
|
}
|
|
583
594
|
/**
|
|
584
595
|
* Close an MCP client and clean up resources
|
|
@@ -103,9 +103,14 @@ export declare class ToolDiscoveryService extends EventEmitter {
|
|
|
103
103
|
*/
|
|
104
104
|
private createToolKey;
|
|
105
105
|
/**
|
|
106
|
-
*
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
* Race an operation against a timeout, clearing the timer on either
|
|
107
|
+
* outcome. A bare `Promise.race([operation, timeoutPromise])` leaves the
|
|
108
|
+
* timeout's `setTimeout` handle ref'd and pending until it fires even
|
|
109
|
+
* after `operation` already won the race — on the (common) success path
|
|
110
|
+
* that stray timer holds the event loop open for the full timeout
|
|
111
|
+
* duration for no reason.
|
|
112
|
+
*/
|
|
113
|
+
private raceWithTimeout;
|
|
109
114
|
/**
|
|
110
115
|
* Destroy the tool discovery service and clean up all resources
|
|
111
116
|
* This method should be called when the service is no longer needed
|
|
@@ -191,9 +191,7 @@ export class ToolDiscoveryService extends EventEmitter {
|
|
|
191
191
|
*/
|
|
192
192
|
async performToolDiscovery(serverId, client, timeout) {
|
|
193
193
|
// List tools from the MCP server
|
|
194
|
-
const
|
|
195
|
-
const timeoutPromise = this.createTimeoutPromise(timeout, "Tool discovery timeout");
|
|
196
|
-
const result = await Promise.race([listToolsPromise, timeoutPromise]);
|
|
194
|
+
const result = await this.raceWithTimeout(client.listTools(), timeout, "Tool discovery timeout");
|
|
197
195
|
if (!result || !result.tools) {
|
|
198
196
|
throw new Error("No tools returned from server");
|
|
199
197
|
}
|
|
@@ -889,14 +887,26 @@ export class ToolDiscoveryService extends EventEmitter {
|
|
|
889
887
|
return `${serverId}:${toolName}`;
|
|
890
888
|
}
|
|
891
889
|
/**
|
|
892
|
-
*
|
|
890
|
+
* Race an operation against a timeout, clearing the timer on either
|
|
891
|
+
* outcome. A bare `Promise.race([operation, timeoutPromise])` leaves the
|
|
892
|
+
* timeout's `setTimeout` handle ref'd and pending until it fires even
|
|
893
|
+
* after `operation` already won the race — on the (common) success path
|
|
894
|
+
* that stray timer holds the event loop open for the full timeout
|
|
895
|
+
* duration for no reason.
|
|
893
896
|
*/
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
+
async raceWithTimeout(operation, timeout, message) {
|
|
898
|
+
let timeoutId;
|
|
899
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
900
|
+
timeoutId = setTimeout(() => {
|
|
897
901
|
reject(new Error(message));
|
|
898
902
|
}, timeout);
|
|
899
903
|
});
|
|
904
|
+
try {
|
|
905
|
+
return await Promise.race([operation, timeoutPromise]);
|
|
906
|
+
}
|
|
907
|
+
finally {
|
|
908
|
+
clearTimeout(timeoutId);
|
|
909
|
+
}
|
|
900
910
|
}
|
|
901
911
|
/**
|
|
902
912
|
* Destroy the tool discovery service and clean up all resources
|
|
@@ -127,6 +127,15 @@ export declare function recordFallbackAttempt(attrs: {
|
|
|
127
127
|
errorMessage?: string;
|
|
128
128
|
durationMs: number;
|
|
129
129
|
}): void;
|
|
130
|
+
/** Reset leg-health state between tests. */
|
|
131
|
+
export declare const __fallbackLegHealthTestHooks: {
|
|
132
|
+
threshold: number;
|
|
133
|
+
maxTrackedLegs: number;
|
|
134
|
+
trackedCount: () => number;
|
|
135
|
+
reset: () => void;
|
|
136
|
+
streakOf: (legKey: string) => number;
|
|
137
|
+
isReported: (legKey: string) => boolean;
|
|
138
|
+
};
|
|
130
139
|
export { ProxyTracer };
|
|
131
140
|
/** Standard SERVER span covers every proxy door, including parsing and admission failures. */
|
|
132
141
|
export declare function startProxyHttpTrace(metadata: RuntimeRequestMetadata, headers: Record<string, string>): {
|