@juspay/neurolink 12.27.1 → 12.28.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +3 -3
  2. package/dist/agent/directTools.d.ts +118 -94
  3. package/dist/agent/directTools.js +578 -446
  4. package/dist/agent/index.d.ts +1 -0
  5. package/dist/agent/index.js +1 -0
  6. package/dist/agent/prompts/terminalAgentPrompt.d.ts +13 -0
  7. package/dist/agent/prompts/terminalAgentPrompt.js +59 -0
  8. package/dist/browser/neurolink.min.js +382 -378
  9. package/dist/cli/factories/commandFactory.d.ts +25 -0
  10. package/dist/cli/factories/commandFactory.js +94 -8
  11. package/dist/cli/loop/optionsSchema.d.ts +1 -1
  12. package/dist/cli/loop/session.js +6 -0
  13. package/dist/cli/utils/stdinRef.d.ts +16 -0
  14. package/dist/cli/utils/stdinRef.js +21 -0
  15. package/dist/core/baseProvider.d.ts +17 -1
  16. package/dist/core/baseProvider.js +48 -1
  17. package/dist/core/modules/ToolsManager.d.ts +7 -1
  18. package/dist/core/modules/ToolsManager.js +8 -0
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.js +1 -0
  21. package/dist/mcp/externalServerManager.js +173 -8
  22. package/dist/mcp/mcpCircuitBreaker.d.ts +8 -3
  23. package/dist/mcp/mcpCircuitBreaker.js +25 -9
  24. package/dist/mcp/mcpClientFactory.d.ts +9 -4
  25. package/dist/mcp/mcpClientFactory.js +26 -15
  26. package/dist/mcp/toolDiscoveryService.d.ts +8 -3
  27. package/dist/mcp/toolDiscoveryService.js +17 -7
  28. package/dist/mcp/toolRegistry.d.ts +12 -1
  29. package/dist/mcp/toolRegistry.js +26 -2
  30. package/dist/neurolink.d.ts +13 -0
  31. package/dist/neurolink.js +97 -10
  32. package/dist/session/globalSessionState.d.ts +8 -0
  33. package/dist/session/globalSessionState.js +17 -0
  34. package/dist/types/agentMode.d.ts +9 -0
  35. package/dist/types/agentMode.js +1 -0
  36. package/dist/types/cli.d.ts +6 -0
  37. package/dist/types/config.d.ts +9 -0
  38. package/dist/types/fileToolRoots.d.ts +17 -0
  39. package/dist/types/fileToolRoots.js +7 -0
  40. package/dist/types/generate.d.ts +17 -0
  41. package/dist/types/index.d.ts +2 -0
  42. package/dist/types/index.js +2 -0
  43. package/dist/types/providers.d.ts +6 -0
  44. package/dist/types/stream.d.ts +7 -0
  45. package/dist/types/tools.d.ts +7 -0
  46. package/dist/utils/fileToolRoots.d.ts +34 -0
  47. package/dist/utils/fileToolRoots.js +111 -0
  48. package/dist/utils/pathSandbox.d.ts +9 -6
  49. package/dist/utils/pathSandbox.js +35 -9
  50. package/docs-site/static/search-index.json +6 -5
  51. package/package.json +6 -2
@@ -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 shutdownLiveManagers = () => {
222
- for (const manager of liveManagers) {
223
- void manager.shutdown();
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
- process.on("SIGINT", shutdownLiveManagers);
231
- process.on("SIGTERM", shutdownLiveManagers);
232
- process.on("beforeExit", shutdownLiveManagers);
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
- await MCPClientFactory.closeClient(instance.client, instance.transportInstance);
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
- * Create a timeout promise
62
- */
63
- private timeoutPromise;
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 Promise.race([
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
- * Create a timeout promise
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
- timeoutPromise(timeout) {
263
- return new Promise((_, reject) => {
264
- setTimeout(() => {
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
- * Create a timeout promise with AbortController support
101
- * Provides consistent async timeout patterns across the factory
102
- */
103
- private static createTimeoutPromise;
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 Promise.race([
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 Promise.race([
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
- * Create a timeout promise with AbortController support
567
- * Provides consistent async timeout patterns across the factory
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 createTimeoutPromise(timeout, message, abortSignal) {
570
- return new Promise((_, reject) => {
571
- const timeoutId = setTimeout(() => {
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
- abortSignal.addEventListener("abort", () => {
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
- * Create timeout promise
107
- */
108
- private createTimeoutPromise;
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 listToolsPromise = client.listTools();
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
- * Create timeout promise
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
- createTimeoutPromise(timeout, message) {
895
- return new Promise((_, reject) => {
896
- setTimeout(() => {
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
@@ -2,7 +2,7 @@
2
2
  * MCP Tool Registry - Extended Registry with Tool Management
3
3
  * Updated to match industry standard camelCase interfaces
4
4
  */
5
- import type { MCPServerInfo, ToolImplementation, ToolInfo, ExecutionContext, HITLManager } from "../types/index.js";
5
+ import type { MCPServerInfo, ToolImplementation, ToolInfo, ExecutionContext, FileToolRootPolicy, HITLManager } from "../types/index.js";
6
6
  import { MCPRegistry } from "./registry.js";
7
7
  export declare class MCPToolRegistry extends MCPRegistry {
8
8
  private tools;
@@ -10,6 +10,7 @@ export declare class MCPToolRegistry extends MCPRegistry {
10
10
  private toolExecutionStats;
11
11
  private builtInServerInfos;
12
12
  private hitlManager?;
13
+ private fileToolRootResolver;
13
14
  constructor();
14
15
  /**
15
16
  * Set HITL manager for human-in-the-loop safety mechanisms
@@ -20,6 +21,16 @@ export declare class MCPToolRegistry extends MCPRegistry {
20
21
  * Get current HITL manager
21
22
  */
22
23
  getHITLManager(): HITLManager | undefined;
24
+ /**
25
+ * Resolve file-tool roots for built-in tools executed on this registry
26
+ * without a per-call policy. Set by the NeuroLink instance that created it.
27
+ */
28
+ setFileToolRootResolver(resolver: () => FileToolRootPolicy): void;
29
+ /**
30
+ * The built-in tool bound to the policy that applies to this execution: one
31
+ * NeuroLink bound on the context, else this registry's owner's roots.
32
+ */
33
+ private directToolFor;
23
34
  /**
24
35
  * Register all direct tools from directAgentTools
25
36
  */
@@ -6,7 +6,8 @@ import { MCPRegistry } from "./registry.js";
6
6
  import { registryLogger } from "../utils/logger.js";
7
7
  import { randomUUID } from "crypto";
8
8
  import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
9
- import { directAgentTools } from "../agent/directTools.js";
9
+ import { createDirectAgentTools, directAgentTools, } from "../agent/directTools.js";
10
+ import { boundFileToolRoots, resolveFileToolRootPolicy, } from "../utils/fileToolRoots.js";
10
11
  import { convertZodToJsonSchema } from "../utils/schemaConversion.js";
11
12
  import { detectCategory, createMCPServerInfo } from "../utils/mcpDefaults.js";
12
13
  import { FlexibleToolValidator } from "./flexibleToolValidator.js";
@@ -21,6 +22,9 @@ export class MCPToolRegistry extends MCPRegistry {
21
22
  toolExecutionStats = new Map();
22
23
  builtInServerInfos = []; // DIRECT storage for MCPServerInfo
23
24
  hitlManager; // Optional HITL manager for safety mechanisms
25
+ // The owning NeuroLink's file-tool roots, for callers (HTTP tool routes, CLI)
26
+ // that execute built-in tools on this registry without a bound policy.
27
+ fileToolRootResolver = () => resolveFileToolRootPolicy({});
24
28
  constructor() {
25
29
  super();
26
30
  if (!shouldDisableBuiltinTools()) {
@@ -46,6 +50,25 @@ export class MCPToolRegistry extends MCPRegistry {
46
50
  getHITLManager() {
47
51
  return this.hitlManager;
48
52
  }
53
+ /**
54
+ * Resolve file-tool roots for built-in tools executed on this registry
55
+ * without a per-call policy. Set by the NeuroLink instance that created it.
56
+ */
57
+ setFileToolRootResolver(resolver) {
58
+ this.fileToolRootResolver = resolver;
59
+ }
60
+ /**
61
+ * The built-in tool bound to the policy that applies to this execution: one
62
+ * NeuroLink bound on the context, else this registry's owner's roots.
63
+ */
64
+ directToolFor(toolName, fallback, context) {
65
+ const policy = boundFileToolRoots(context) ?? this.fileToolRootResolver();
66
+ if (policy.roots === null) {
67
+ return fallback;
68
+ }
69
+ const bound = createDirectAgentTools(policy);
70
+ return bound[toolName] ?? fallback;
71
+ }
49
72
  /**
50
73
  * Register all direct tools from directAgentTools
51
74
  */
@@ -75,7 +98,8 @@ export class MCPToolRegistry extends MCPRegistry {
75
98
  try {
76
99
  // Direct tools from AI SDK expect their specific parameter structure
77
100
  // Each tool validates its own parameters, so we safely pass them through
78
- const result = await toolDef.execute(params, {
101
+ const boundTool = this.directToolFor(toolName, toolDef, context);
102
+ const result = await boundTool.execute(params, {
79
103
  toolCallId: context?.sessionId || "unknown",
80
104
  messages: [],
81
105
  });
@@ -1388,6 +1388,19 @@ export declare class NeuroLink {
1388
1388
  * Called by `BaseProvider.applyToolFiltering` so the tool gate composes the
1389
1389
  * instance policy with per-call options on every generate/stream call.
1390
1390
  */
1391
+ /**
1392
+ * The policy an entry point bound to `options`, or — for an internal path
1393
+ * that built its own options — the instance policy narrowed by any
1394
+ * per-call `toolRoots`. Never trusts a public field.
1395
+ */
1396
+ private fileToolRootsFor;
1397
+ /**
1398
+ * A worker's tool config: it inherits this instance's file-tool roots, and a
1399
+ * worker-level `fileRoots` may only narrow them (throws otherwise).
1400
+ */
1401
+ private workerToolsConfig;
1402
+ /** File-tool roots for one call: per-call roots narrow the instance's `tools.fileRoots`. */
1403
+ private resolveFileToolRoots;
1391
1404
  getToolsConfig(): ToolConfig | undefined;
1392
1405
  /**
1393
1406
  * Tools discovered via `search_tools` for a session (`tools.discovery`