@juspay/neurolink 13.8.16 → 13.8.18

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 (194) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/README.md +1 -1
  3. package/dist/action/actionExecutor.d.ts +33 -3
  4. package/dist/action/actionExecutor.js +71 -15
  5. package/dist/action/actionInputs.js +4 -1
  6. package/dist/action/cliVersion.d.ts +25 -0
  7. package/dist/action/cliVersion.js +26 -0
  8. package/dist/action/index.d.ts +2 -1
  9. package/dist/action/index.js +2 -1
  10. package/dist/adapters/video/directorPipeline.js +46 -36
  11. package/dist/adapters/video/ffmpegAdapter.d.ts +17 -0
  12. package/dist/adapters/video/ffmpegAdapter.js +32 -5
  13. package/dist/adapters/video/frameExtractor.js +1 -1
  14. package/dist/adapters/video/videoMerger.js +5 -2
  15. package/dist/agent/arithmeticEvaluator.d.ts +32 -0
  16. package/dist/agent/arithmeticEvaluator.js +349 -0
  17. package/dist/agent/directTools.d.ts +6 -18
  18. package/dist/agent/directTools.js +17 -56
  19. package/dist/artifacts/artifactStore.d.ts +22 -1
  20. package/dist/artifacts/artifactStore.js +66 -6
  21. package/dist/auth/anthropicOAuth.d.ts +26 -1
  22. package/dist/auth/anthropicOAuth.js +81 -5
  23. package/dist/auth/secureFiles.d.ts +37 -0
  24. package/dist/auth/secureFiles.js +95 -0
  25. package/dist/auth/tokenStore.d.ts +43 -6
  26. package/dist/auth/tokenStore.js +260 -79
  27. package/dist/avatar/providers/DIDAvatar.js +49 -45
  28. package/dist/avatar/providers/ReplicateAvatar.js +33 -40
  29. package/dist/browser/neurolink.min.js +485 -558
  30. package/dist/cli/commands/auth.js +112 -70
  31. package/dist/cli/commands/proxy.d.ts +4 -0
  32. package/dist/cli/commands/proxy.js +128 -13
  33. package/dist/cli/commands/serve.d.ts +10 -1
  34. package/dist/cli/commands/serve.js +98 -17
  35. package/dist/cli/commands/server.js +17 -5
  36. package/dist/cli/index.js +146 -5
  37. package/dist/cli/loop/optionsSchema.d.ts +1 -1
  38. package/dist/cli/utils/envManager.js +17 -3
  39. package/dist/cli/utils/serverUtils.d.ts +39 -3
  40. package/dist/cli/utils/serverUtils.js +63 -6
  41. package/dist/core/baseProvider.js +7 -1
  42. package/dist/core/modules/MessageBuilder.js +5 -0
  43. package/dist/core/modules/ToolsManager.d.ts +46 -1
  44. package/dist/core/modules/ToolsManager.js +190 -43
  45. package/dist/files/fileReferenceRegistry.d.ts +2 -23
  46. package/dist/files/fileReferenceRegistry.js +55 -5
  47. package/dist/hitl/hitlManager.js +1 -0
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +6 -1
  50. package/dist/mcp/auth/tokenStorage.d.ts +7 -0
  51. package/dist/mcp/auth/tokenStorage.js +23 -4
  52. package/dist/mcp/externalServerManager.d.ts +13 -3
  53. package/dist/mcp/externalServerManager.js +154 -136
  54. package/dist/mcp/index.d.ts +2 -0
  55. package/dist/mcp/index.js +3 -0
  56. package/dist/mcp/mcpClientFactory.d.ts +14 -0
  57. package/dist/mcp/mcpClientFactory.js +70 -7
  58. package/dist/mcp/projectConfigTrust.d.ts +72 -0
  59. package/dist/mcp/projectConfigTrust.js +237 -0
  60. package/dist/mcp/transportUrlPolicy.d.ts +39 -0
  61. package/dist/mcp/transportUrlPolicy.js +172 -0
  62. package/dist/music/providers/BeatovenMusic.js +35 -46
  63. package/dist/music/providers/ReplicateMusic.js +33 -40
  64. package/dist/neurolink.d.ts +16 -1
  65. package/dist/neurolink.js +64 -11
  66. package/dist/processors/archive/ArchiveProcessor.js +15 -1
  67. package/dist/processors/archive/zipEntryIndex.d.ts +22 -0
  68. package/dist/processors/archive/zipEntryIndex.js +33 -0
  69. package/dist/processors/base/BaseFileProcessor.d.ts +9 -21
  70. package/dist/processors/base/BaseFileProcessor.js +40 -111
  71. package/dist/processors/data/YamlProcessor.d.ts +13 -1
  72. package/dist/processors/data/YamlProcessor.js +93 -2
  73. package/dist/processors/document/OpenDocumentProcessor.js +7 -2
  74. package/dist/processors/document/PptxProcessor.js +12 -7
  75. package/dist/processors/media/VideoProcessor.js +93 -15
  76. package/dist/proxy/inboundAuth.d.ts +89 -0
  77. package/dist/proxy/inboundAuth.js +219 -0
  78. package/dist/proxy/tokenRefresh.d.ts +6 -0
  79. package/dist/proxy/tokenRefresh.js +13 -1
  80. package/dist/proxy/updateState.js +4 -4
  81. package/dist/rag/document/loaders.d.ts +0 -6
  82. package/dist/rag/document/loaders.js +16 -10
  83. package/dist/rag/ragIntegration.d.ts +2 -2
  84. package/dist/rag/ragIntegration.js +35 -10
  85. package/dist/rag/retrieval/vectorQueryTool.d.ts +7 -0
  86. package/dist/rag/retrieval/vectorQueryTool.js +54 -17
  87. package/dist/server/abstract/baseServerAdapter.d.ts +43 -0
  88. package/dist/server/abstract/baseServerAdapter.js +119 -6
  89. package/dist/server/adapters/expressAdapter.d.ts +3 -0
  90. package/dist/server/adapters/expressAdapter.js +83 -8
  91. package/dist/server/adapters/fastifyAdapter.d.ts +3 -0
  92. package/dist/server/adapters/fastifyAdapter.js +165 -70
  93. package/dist/server/adapters/honoAdapter.d.ts +9 -1
  94. package/dist/server/adapters/honoAdapter.js +99 -21
  95. package/dist/server/adapters/koaAdapter.d.ts +1 -0
  96. package/dist/server/adapters/koaAdapter.js +153 -74
  97. package/dist/server/index.d.ts +2 -0
  98. package/dist/server/index.js +4 -0
  99. package/dist/server/middleware/auth.js +6 -2
  100. package/dist/server/middleware/cache.js +6 -1
  101. package/dist/server/middleware/rateLimit.js +29 -4
  102. package/dist/server/routes/agentRoutes.js +24 -11
  103. package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
  104. package/dist/server/routes/claudeProxyRoutes.js +273 -84
  105. package/dist/server/routes/codexProxyRoutes.d.ts +2 -2
  106. package/dist/server/routes/codexProxyRoutes.js +125 -40
  107. package/dist/server/routes/index.d.ts +4 -0
  108. package/dist/server/routes/index.js +35 -4
  109. package/dist/server/routes/mcpRoutes.d.ts +6 -2
  110. package/dist/server/routes/mcpRoutes.js +21 -7
  111. package/dist/server/routes/memoryRoutes.js +7 -6
  112. package/dist/server/routes/toolRoutes.d.ts +12 -2
  113. package/dist/server/routes/toolRoutes.js +33 -3
  114. package/dist/server/routes/transcribeRoutes.js +60 -13
  115. package/dist/server/utils/clientAddress.d.ts +23 -0
  116. package/dist/server/utils/clientAddress.js +65 -0
  117. package/dist/server/utils/clientError.d.ts +35 -0
  118. package/dist/server/utils/clientError.js +129 -0
  119. package/dist/server/utils/expressBody.d.ts +33 -0
  120. package/dist/server/utils/expressBody.js +353 -0
  121. package/dist/server/utils/pathMatch.d.ts +21 -0
  122. package/dist/server/utils/pathMatch.js +41 -0
  123. package/dist/server/utils/validation.d.ts +9 -0
  124. package/dist/server/utils/validation.js +9 -0
  125. package/dist/server/websocket/WebSocketHandler.js +22 -3
  126. package/dist/server/websocket/frameQueue.d.ts +19 -0
  127. package/dist/server/websocket/frameQueue.js +41 -3
  128. package/dist/server/websocket/transcribeWebSocket.d.ts +8 -1
  129. package/dist/server/websocket/transcribeWebSocket.js +223 -34
  130. package/dist/skills/skillStores.js +5 -0
  131. package/dist/tasks/store/fileTaskStore.d.ts +2 -0
  132. package/dist/tasks/store/fileTaskStore.js +11 -4
  133. package/dist/types/attachments.d.ts +117 -0
  134. package/dist/types/attachments.js +15 -0
  135. package/dist/types/auth.d.ts +6 -0
  136. package/dist/types/cli.d.ts +4 -0
  137. package/dist/types/config.d.ts +56 -0
  138. package/dist/types/file.d.ts +8 -0
  139. package/dist/types/hitl.d.ts +8 -0
  140. package/dist/types/index.d.ts +1 -0
  141. package/dist/types/index.js +1 -0
  142. package/dist/types/mcp.d.ts +98 -0
  143. package/dist/types/multimodal.js +0 -41
  144. package/dist/types/processor.d.ts +14 -1
  145. package/dist/types/proxy.d.ts +62 -3
  146. package/dist/types/rag.d.ts +9 -0
  147. package/dist/types/safeFetch.d.ts +40 -0
  148. package/dist/types/server.d.ts +255 -10
  149. package/dist/types/tools.d.ts +57 -0
  150. package/dist/types/transcribe.d.ts +6 -0
  151. package/dist/utils/attachmentPolicy.d.ts +89 -0
  152. package/dist/utils/attachmentPolicy.js +327 -0
  153. package/dist/utils/dotenvBootstrap.d.ts +48 -19
  154. package/dist/utils/dotenvBootstrap.js +172 -31
  155. package/dist/utils/fileDetector.js +115 -65
  156. package/dist/utils/json/coerce.js +11 -1
  157. package/dist/utils/logSanitize.d.ts +5 -0
  158. package/dist/utils/logSanitize.js +8 -0
  159. package/dist/utils/logger.js +73 -1
  160. package/dist/utils/messageBuilder.d.ts +2 -2
  161. package/dist/utils/messageBuilder.js +118 -105
  162. package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
  163. package/dist/utils/multimodalOptionsBuilder.js +2 -0
  164. package/dist/utils/redis.d.ts +12 -0
  165. package/dist/utils/redis.js +72 -0
  166. package/dist/utils/safeFetch.d.ts +22 -1
  167. package/dist/utils/safeFetch.js +132 -38
  168. package/dist/utils/safeStoreId.d.ts +19 -0
  169. package/dist/utils/safeStoreId.js +29 -0
  170. package/dist/utils/schemaConversion.d.ts +30 -3
  171. package/dist/utils/schemaConversion.js +457 -84
  172. package/dist/utils/sizeGuard.js +2 -2
  173. package/dist/utils/ssrfGuard.d.ts +3 -3
  174. package/dist/utils/ssrfGuard.js +71 -39
  175. package/dist/voice/eval/sttEvalStore.d.ts +26 -3
  176. package/dist/voice/eval/sttEvalStore.js +77 -12
  177. package/dist/voice/livekit/agentDefinition.d.ts +18 -0
  178. package/dist/voice/livekit/agentDefinition.js +22 -0
  179. package/dist/voice/livekit/realtimeMcpTools.d.ts +1 -1
  180. package/dist/voice/livekit/realtimeMcpTools.js +8 -7
  181. package/dist/voice/livekit/realtimeVoiceAgent.d.ts +2 -4
  182. package/dist/voice/livekit/realtimeVoiceAgent.js +18 -7
  183. package/dist/voice/livekit/voiceAgent.d.ts +2 -2
  184. package/dist/voice/livekit/voiceAgent.js +2 -1
  185. package/dist/voice/livekit/xaiSessionWatch.d.ts +0 -5
  186. package/dist/voice/livekit/xaiSessionWatch.js +30 -0
  187. package/dist/voice/providers/GeminiLive.js +21 -6
  188. package/dist/voice/providers/GoogleSTT.js +8 -5
  189. package/dist/voice/transcribe.d.ts +2 -2
  190. package/dist/voice/transcribe.js +34 -18
  191. package/dist/voice/whistle/audio.js +11 -5
  192. package/docs-site/static/search-index.json +75 -52
  193. package/package.json +171 -156
  194. package/scripts/observability/import-openobserve-dashboard.mjs +11 -3
@@ -10,6 +10,7 @@ import { CircuitBreakerOpenError } from "../types/index.js";
10
10
  import { withHTTPRetry, DEFAULT_HTTP_RETRY_CONFIG, } from "./httpRetryHandler.js";
11
11
  import { globalRateLimiterManager } from "./httpRateLimiter.js";
12
12
  import { NeuroLinkOAuthProvider, InMemoryTokenStorage } from "./auth/index.js";
13
+ import { assertMcpTransportUrlAllowed } from "./transportUrlPolicy.js";
13
14
  import { SpanSerializer, SpanType, SpanStatus, getMetricsAggregator, } from "../observability/index.js";
14
15
  import { getActiveTraceContext } from "../telemetry/traceContext.js";
15
16
  /**
@@ -197,8 +198,13 @@ export class MCPClientFactory {
197
198
  name: "neurolink-sdk",
198
199
  version: "1.0.0",
199
200
  };
201
+ /**
202
+ * No `sampling` here: advertising it tells a server it may send
203
+ * `sampling/createMessage` and have this client run an LLM call on its
204
+ * behalf, and the client has no handler for it. Only what is implemented
205
+ * is announced.
206
+ */
200
207
  static DEFAULT_CAPABILITIES = {
201
- sampling: {},
202
208
  roots: {
203
209
  listChanged: false,
204
210
  },
@@ -396,15 +402,36 @@ export class MCPClientFactory {
396
402
  case "stdio":
397
403
  return this.createStdioTransport(config);
398
404
  case "sse":
405
+ await this.assertNetworkUrlAllowed(config);
399
406
  return this.createSSETransport(config);
400
407
  case "websocket":
408
+ await this.assertNetworkUrlAllowed(config);
401
409
  return this.createWebSocketTransport(config);
402
410
  case "http":
411
+ await this.assertNetworkUrlAllowed(config);
403
412
  return this.createHTTPTransport(config);
404
413
  default:
405
414
  throw new Error(`Unsupported transport type: ${config.transport}`);
406
415
  }
407
416
  }
417
+ /**
418
+ * Apply the transport URL policy (`transportUrlPolicy.ts`) before any
419
+ * network transport is built: TLS unless loopback, and no private,
420
+ * link-local or metadata destinations unless the server config says
421
+ * `allowPrivateNetwork: true`. Runs for every network server, whether it
422
+ * came from a project config or from `addExternalMCPServer`.
423
+ */
424
+ static async assertNetworkUrlAllowed(config) {
425
+ if (!config.url) {
426
+ throw new Error(`URL is required for ${config.transport} transport`);
427
+ }
428
+ await assertMcpTransportUrlAllowed(config.url, {
429
+ transport: config.transport,
430
+ allowPrivateNetwork: config.allowPrivateNetwork,
431
+ hostOwnedEgress: typeof config.fetch === "function",
432
+ serverId: config.id,
433
+ });
434
+ }
408
435
  /**
409
436
  * Create a stdio transport.
410
437
  *
@@ -431,13 +458,23 @@ export class MCPClientFactory {
431
458
  if (!config.command) {
432
459
  throw new Error(`Command is required for stdio transport`);
433
460
  }
434
- const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
461
+ const { StdioClientTransport, getDefaultEnvironment } = await import("@modelcontextprotocol/sdk/client/stdio.js");
435
462
  const { ChildProcess: childProcessClass } = await import("node:child_process");
463
+ // The child gets the SDK's minimal default environment (HOME, PATH,
464
+ // SHELL, TERM, USER, LOGNAME on POSIX) plus the server's own `env`. It
465
+ // used to get the whole parent `process.env` — every provider key and
466
+ // token this process held — and an `npx -y <package>` server is exactly
467
+ // the kind of code that should not. `inheritParentEnv: true` restores
468
+ // the old behaviour for a server that needs it; a single variable is
469
+ // better passed through explicitly: `"env": {"X": "${X}"}`.
470
+ const baseEnv = config.inheritParentEnv === true
471
+ ? { ...process.env }
472
+ : getDefaultEnvironment();
436
473
  const transport = new StdioClientTransport({
437
474
  command: config.command,
438
475
  args: config.args || [],
439
476
  env: Object.fromEntries(Object.entries({
440
- ...process.env,
477
+ ...baseEnv,
441
478
  ...config.env,
442
479
  })
443
480
  .filter(([, value]) => value !== undefined)
@@ -495,15 +532,41 @@ export class MCPClientFactory {
495
532
  mcpLogger.debug(`[MCPClientFactory] Creating WebSocket transport for ${config.id}`, {
496
533
  url: config.url,
497
534
  });
535
+ // The SDK's WebSocket transport opens the platform `WebSocket`, which
536
+ // takes a URL and a subprotocol and nothing else: there is no way to
537
+ // send `headers` or an `auth` token with the handshake. Dropping them
538
+ // silently would connect unauthenticated to a server the operator
539
+ // configured credentials for, so this fails closed instead.
540
+ const headerCount = Object.keys(config.headers ?? {}).length;
541
+ if (headerCount > 0 || config.auth !== undefined) {
542
+ throw new Error(`WebSocket transport for ${config.id} cannot send the configured ${headerCount > 0 ? "headers" : "auth"}: the WebSocket handshake carries no custom headers. Use the "http" or "sse" transport for an authenticated server, or remove the credentials.`);
543
+ }
544
+ let url;
545
+ let transportModule;
498
546
  try {
499
- const url = new URL(config.url);
500
- const { WebSocketClientTransport } = await import("@modelcontextprotocol/sdk/client/websocket.js");
501
- const transport = new WebSocketClientTransport(url);
502
- return { transport };
547
+ url = new URL(config.url);
548
+ transportModule =
549
+ await import("@modelcontextprotocol/sdk/client/websocket.js");
503
550
  }
504
551
  catch (error) {
505
552
  throw new Error(`Invalid WebSocket URL: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
506
553
  }
554
+ const transport = new transportModule.WebSocketClientTransport(url);
555
+ // Same connect bound as the HTTP transports: a socket that never opens
556
+ // or errors would otherwise leave `connect()` pending for the whole
557
+ // client timeout. On expiry the socket is closed so nothing lingers.
558
+ const connectTimeoutMs = config.httpOptions?.connectionTimeout ?? 30000;
559
+ const start = transport.start.bind(transport);
560
+ transport.start = async () => {
561
+ try {
562
+ await this.raceWithTimeout(start(), connectTimeoutMs, `WebSocket connection timeout for ${config.id} after ${connectTimeoutMs}ms`);
563
+ }
564
+ catch (error) {
565
+ await transport.close().catch(() => undefined);
566
+ throw error;
567
+ }
568
+ };
569
+ return { transport };
507
570
  }
508
571
  /**
509
572
  * Create HTTP transport (Streamable HTTP)
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Trust for a project's `.mcp-config.json`.
3
+ *
4
+ * A directory is not a trust boundary. Every tool-enabled `generate()` and
5
+ * `stream()` used to load `<cwd>/.mcp-config.json` and start what it named,
6
+ * so cloning a repository and running anything built on this SDK inside it
7
+ * ran that repository's commands with the user's credentials. This module
8
+ * is the one place that decides whether a project config may be loaded:
9
+ *
10
+ * - **SDK**: never implicitly. `new NeuroLink({ mcp: { configPath } })` names
11
+ * a file; `mcp: { trustProjectConfig: true }` opts into the working
12
+ * directory. `ProviderRegistry.setOptions({ enableManualMCP: true })` is the
13
+ * legacy opt-in and is honoured for the first time.
14
+ * - **CLI**: the entry point hashes the file, compares it with the accepted
15
+ * hashes in `~/.neurolink/trusted-mcp-configs.json`, and registers a hook
16
+ * here. The hook runs only when a `NeuroLink` instance is about to load
17
+ * the file, so `neurolink --version` in a hostile directory costs nothing
18
+ * and `neurolink generate` there is refused.
19
+ */
20
+ import type { ProjectMcpConfigPathResolution, ProjectMcpConfigSummary, ProjectMcpConfigTrustHook, ProjectMcpConfigTrustStatus, ProjectMcpConfigTrustStore } from "../types/index.js";
21
+ export declare const PROJECT_MCP_CONFIG_FILENAME = ".mcp-config.json";
22
+ export declare const TRUSTED_MCP_CONFIGS_FILENAME = "trusted-mcp-configs.json";
23
+ /** `1` trusts the working directory's config for this run; `0` ignores it. */
24
+ export declare const TRUST_MCP_CONFIG_ENV = "NEUROLINK_TRUST_MCP_CONFIG";
25
+ /** CLI flag with the same meaning as `NEUROLINK_TRUST_MCP_CONFIG=1`. */
26
+ export declare const TRUST_MCP_CONFIG_FLAG = "--trust-mcp-config";
27
+ export declare function getTrustedMcpConfigStorePath(): string;
28
+ export declare function hashProjectMcpConfig(content: string | Buffer): string;
29
+ /**
30
+ * Read the store. A missing, unreadable or malformed file reads as empty:
31
+ * the only consequence is that every config has to be accepted again, which
32
+ * is the safe direction.
33
+ */
34
+ export declare function readProjectMcpConfigTrustStore(storePath?: string): ProjectMcpConfigTrustStore;
35
+ export declare function getProjectMcpConfigTrustStatus(configPath: string, sha256: string, storePath?: string): ProjectMcpConfigTrustStatus;
36
+ /**
37
+ * Record an acceptance. The store directory is created `0700`, the file is
38
+ * written `0600` through a uniquely named temporary file opened with `wx`
39
+ * and renamed into place, so a concurrent writer or a pre-planted symlink
40
+ * cannot redirect the write or leave a torn file.
41
+ */
42
+ export declare function recordTrustedProjectMcpConfig(configPath: string, sha256: string, storePath?: string): void;
43
+ /**
44
+ * Read and describe a project config without evaluating any of it. Values
45
+ * under `env` and `headers` are never included — a summary is printed to a
46
+ * terminal and must not echo a secret the file happens to inline.
47
+ */
48
+ export declare function summarizeProjectMcpConfig(configPath: string): ProjectMcpConfigSummary;
49
+ /** Render a summary as the lines the CLI prints before asking for trust. */
50
+ export declare function formatProjectMcpConfigSummary(summary: ProjectMcpConfigSummary): string[];
51
+ /**
52
+ * Register (or clear, with `null`) the process-wide hook the CLI uses to hand
53
+ * its trust decision to every `NeuroLink` instance built afterwards. SDK
54
+ * hosts do not need this: they pass `mcp.configPath` or
55
+ * `mcp.trustProjectConfig` to the constructor instead.
56
+ */
57
+ export declare function setProjectMcpConfigTrustHook(hook: ProjectMcpConfigTrustHook | null): void;
58
+ export declare function getProjectMcpConfigTrustHook(): ProjectMcpConfigTrustHook | null;
59
+ /**
60
+ * Decide which project config, if any, a `NeuroLink` instance loads.
61
+ *
62
+ * Precedence: an explicit `configPath`; `trustProjectConfig` for
63
+ * `<cwd>/.mcp-config.json`; the CLI's registered hook; the legacy
64
+ * `enableManualMCP` registry option. With none of them the answer is `null`
65
+ * and nothing is read from the working directory — the SDK default.
66
+ */
67
+ export declare function resolveProjectMcpConfigPath(options: {
68
+ cwd?: string;
69
+ configPath?: string;
70
+ trustProjectConfig?: boolean;
71
+ enableManualMCP?: boolean;
72
+ }): Promise<ProjectMcpConfigPathResolution | null>;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Trust for a project's `.mcp-config.json`.
3
+ *
4
+ * A directory is not a trust boundary. Every tool-enabled `generate()` and
5
+ * `stream()` used to load `<cwd>/.mcp-config.json` and start what it named,
6
+ * so cloning a repository and running anything built on this SDK inside it
7
+ * ran that repository's commands with the user's credentials. This module
8
+ * is the one place that decides whether a project config may be loaded:
9
+ *
10
+ * - **SDK**: never implicitly. `new NeuroLink({ mcp: { configPath } })` names
11
+ * a file; `mcp: { trustProjectConfig: true }` opts into the working
12
+ * directory. `ProviderRegistry.setOptions({ enableManualMCP: true })` is the
13
+ * legacy opt-in and is honoured for the first time.
14
+ * - **CLI**: the entry point hashes the file, compares it with the accepted
15
+ * hashes in `~/.neurolink/trusted-mcp-configs.json`, and registers a hook
16
+ * here. The hook runs only when a `NeuroLink` instance is about to load
17
+ * the file, so `neurolink --version` in a hostile directory costs nothing
18
+ * and `neurolink generate` there is refused.
19
+ */
20
+ import { createHash, randomBytes } from "node:crypto";
21
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import * as path from "node:path";
24
+ import { isNonNullObject } from "../utils/typeUtils.js";
25
+ export const PROJECT_MCP_CONFIG_FILENAME = ".mcp-config.json";
26
+ export const TRUSTED_MCP_CONFIGS_FILENAME = "trusted-mcp-configs.json";
27
+ /** `1` trusts the working directory's config for this run; `0` ignores it. */
28
+ export const TRUST_MCP_CONFIG_ENV = "NEUROLINK_TRUST_MCP_CONFIG";
29
+ /** CLI flag with the same meaning as `NEUROLINK_TRUST_MCP_CONFIG=1`. */
30
+ export const TRUST_MCP_CONFIG_FLAG = "--trust-mcp-config";
31
+ const MAX_SUMMARY_ARGS_LENGTH = 160;
32
+ export function getTrustedMcpConfigStorePath() {
33
+ return path.join(homedir(), ".neurolink", TRUSTED_MCP_CONFIGS_FILENAME);
34
+ }
35
+ export function hashProjectMcpConfig(content) {
36
+ return createHash("sha256").update(content).digest("hex");
37
+ }
38
+ function emptyStore() {
39
+ return { version: 1, entries: {} };
40
+ }
41
+ function isTrustStore(value) {
42
+ if (!isNonNullObject(value)) {
43
+ return false;
44
+ }
45
+ const record = value;
46
+ if (record.version !== 1 || !isNonNullObject(record.entries)) {
47
+ return false;
48
+ }
49
+ return Object.values(record.entries).every((entry) => isNonNullObject(entry) &&
50
+ typeof entry.sha256 === "string" &&
51
+ typeof entry.trustedAt === "string");
52
+ }
53
+ /**
54
+ * Read the store. A missing, unreadable or malformed file reads as empty:
55
+ * the only consequence is that every config has to be accepted again, which
56
+ * is the safe direction.
57
+ */
58
+ export function readProjectMcpConfigTrustStore(storePath = getTrustedMcpConfigStorePath()) {
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(storePath, "utf8"));
61
+ return isTrustStore(parsed) ? parsed : emptyStore();
62
+ }
63
+ catch {
64
+ return emptyStore();
65
+ }
66
+ }
67
+ export function getProjectMcpConfigTrustStatus(configPath, sha256, storePath = getTrustedMcpConfigStorePath()) {
68
+ const entry = readProjectMcpConfigTrustStore(storePath).entries[path.resolve(configPath)];
69
+ if (!entry) {
70
+ return "untrusted";
71
+ }
72
+ return entry.sha256 === sha256 ? "trusted" : "changed";
73
+ }
74
+ /**
75
+ * Record an acceptance. The store directory is created `0700`, the file is
76
+ * written `0600` through a uniquely named temporary file opened with `wx`
77
+ * and renamed into place, so a concurrent writer or a pre-planted symlink
78
+ * cannot redirect the write or leave a torn file.
79
+ */
80
+ export function recordTrustedProjectMcpConfig(configPath, sha256, storePath = getTrustedMcpConfigStorePath()) {
81
+ const store = readProjectMcpConfigTrustStore(storePath);
82
+ store.entries[path.resolve(configPath)] = {
83
+ sha256,
84
+ trustedAt: new Date().toISOString(),
85
+ };
86
+ const dir = path.dirname(storePath);
87
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
88
+ const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
89
+ try {
90
+ writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", {
91
+ mode: 0o600,
92
+ flag: "wx",
93
+ });
94
+ renameSync(tmp, storePath);
95
+ chmodSync(storePath, 0o600);
96
+ }
97
+ catch (error) {
98
+ rmSync(tmp, { force: true });
99
+ throw error;
100
+ }
101
+ }
102
+ function summarizeServer(id, value) {
103
+ if (!isNonNullObject(value)) {
104
+ return { id, transport: "invalid" };
105
+ }
106
+ const record = value;
107
+ const transport = typeof record.transport === "string"
108
+ ? record.transport
109
+ : typeof record.url === "string"
110
+ ? "http"
111
+ : "stdio";
112
+ const summary = { id, transport };
113
+ if (typeof record.command === "string") {
114
+ summary.command = record.command;
115
+ }
116
+ if (Array.isArray(record.args)) {
117
+ summary.args = record.args.map((arg) => String(arg));
118
+ }
119
+ if (typeof record.url === "string") {
120
+ summary.url = record.url;
121
+ }
122
+ if (isNonNullObject(record.env)) {
123
+ summary.envKeys = Object.keys(record.env);
124
+ }
125
+ return summary;
126
+ }
127
+ /**
128
+ * Read and describe a project config without evaluating any of it. Values
129
+ * under `env` and `headers` are never included — a summary is printed to a
130
+ * terminal and must not echo a secret the file happens to inline.
131
+ */
132
+ export function summarizeProjectMcpConfig(configPath) {
133
+ const absolute = path.resolve(configPath);
134
+ const bytes = readFileSync(absolute);
135
+ const summary = {
136
+ path: absolute,
137
+ sha256: hashProjectMcpConfig(bytes),
138
+ servers: [],
139
+ };
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(bytes.toString("utf8"));
143
+ }
144
+ catch (error) {
145
+ summary.parseError = `not valid JSON (${error instanceof Error ? error.message : String(error)})`;
146
+ return summary;
147
+ }
148
+ if (!isNonNullObject(parsed) ||
149
+ !isNonNullObject(parsed.mcpServers)) {
150
+ summary.parseError = 'no "mcpServers" object';
151
+ return summary;
152
+ }
153
+ const servers = parsed.mcpServers;
154
+ for (const [id, value] of Object.entries(servers)) {
155
+ summary.servers.push(summarizeServer(id, value));
156
+ }
157
+ return summary;
158
+ }
159
+ /** Render a summary as the lines the CLI prints before asking for trust. */
160
+ export function formatProjectMcpConfigSummary(summary) {
161
+ const lines = [`${summary.path} (sha256 ${summary.sha256.slice(0, 16)}…)`];
162
+ if (summary.parseError) {
163
+ lines.push(` ${summary.parseError}`);
164
+ return lines;
165
+ }
166
+ if (summary.servers.length === 0) {
167
+ lines.push(" declares no servers");
168
+ return lines;
169
+ }
170
+ lines.push(` declares ${summary.servers.length} server(s):`);
171
+ for (const server of summary.servers) {
172
+ let what;
173
+ if (server.command !== undefined) {
174
+ const argv = [server.command, ...(server.args ?? [])].join(" ");
175
+ what =
176
+ argv.length > MAX_SUMMARY_ARGS_LENGTH
177
+ ? `${argv.slice(0, MAX_SUMMARY_ARGS_LENGTH)}…`
178
+ : argv;
179
+ }
180
+ else if (server.url !== undefined) {
181
+ what = server.url;
182
+ }
183
+ else {
184
+ what = "(neither command nor url)";
185
+ }
186
+ const env = server.envKeys && server.envKeys.length > 0
187
+ ? ` env: ${server.envKeys.join(", ")}`
188
+ : "";
189
+ lines.push(` - ${server.id} [${server.transport}] ${what}${env}`);
190
+ }
191
+ return lines;
192
+ }
193
+ let trustHook = null;
194
+ /**
195
+ * Register (or clear, with `null`) the process-wide hook the CLI uses to hand
196
+ * its trust decision to every `NeuroLink` instance built afterwards. SDK
197
+ * hosts do not need this: they pass `mcp.configPath` or
198
+ * `mcp.trustProjectConfig` to the constructor instead.
199
+ */
200
+ export function setProjectMcpConfigTrustHook(hook) {
201
+ trustHook = hook;
202
+ }
203
+ export function getProjectMcpConfigTrustHook() {
204
+ return trustHook;
205
+ }
206
+ /**
207
+ * Decide which project config, if any, a `NeuroLink` instance loads.
208
+ *
209
+ * Precedence: an explicit `configPath`; `trustProjectConfig` for
210
+ * `<cwd>/.mcp-config.json`; the CLI's registered hook; the legacy
211
+ * `enableManualMCP` registry option. With none of them the answer is `null`
212
+ * and nothing is read from the working directory — the SDK default.
213
+ */
214
+ export async function resolveProjectMcpConfigPath(options) {
215
+ const cwd = options.cwd ?? process.cwd();
216
+ if (options.configPath !== undefined && options.configPath.length > 0) {
217
+ return {
218
+ path: path.resolve(cwd, options.configPath),
219
+ source: "configPath",
220
+ };
221
+ }
222
+ const candidate = path.join(cwd, PROJECT_MCP_CONFIG_FILENAME);
223
+ if (options.trustProjectConfig === true) {
224
+ return { path: candidate, source: "trustProjectConfig" };
225
+ }
226
+ if (trustHook) {
227
+ if (!existsSync(candidate)) {
228
+ return null;
229
+ }
230
+ const accepted = await trustHook(candidate);
231
+ return accepted ? { path: accepted, source: "cliTrust" } : null;
232
+ }
233
+ if (options.enableManualMCP === true) {
234
+ return { path: candidate, source: "enableManualMCP" };
235
+ }
236
+ return null;
237
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * URL policy for the network MCP transports (http, sse, websocket).
3
+ *
4
+ * An MCP server URL names where every tool call, and every configured
5
+ * header, is sent. Until this policy existed, nothing checked it: a project
6
+ * config could point a server at `http://169.254.169.254/` or at an
7
+ * attacker's plaintext host and the SDK would connect. The rules, in order:
8
+ *
9
+ * 1. The scheme is one of `https:`, `wss:`, `http:`, `ws:`.
10
+ * 2. A loopback host (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`) may
11
+ * use any of the four. Local development servers are the common case
12
+ * for plaintext and they never leave the machine.
13
+ * 3. Otherwise plaintext is refused, and a host that is — or resolves to —
14
+ * a private, link-local, CGNAT, multicast or cloud-metadata address is
15
+ * refused too, so a tool call cannot be turned into a request against
16
+ * the machine's own network.
17
+ * 4. `allowPrivateNetwork: true` on that server's config lifts rule 3 for
18
+ * private addresses, and lets plaintext reach them. A public host still
19
+ * needs TLS.
20
+ *
21
+ * `utils/ssrfGuard.ts` is deliberately not reused: it is HTTPS-only and treats
22
+ * loopback as blocked, which is right for a server fetching an untrusted URL
23
+ * and wrong for a client talking to a local MCP server.
24
+ */
25
+ import type { McpTransportAddressClass, McpTransportUrlPolicyOptions } from "../types/index.js";
26
+ /**
27
+ * Classify one IP literal. Throws when `ip` is not an IP address at all, so a
28
+ * caller never silently treats a host name as public.
29
+ */
30
+ export declare function classifyMcpTransportAddress(ip: string): McpTransportAddressClass;
31
+ /**
32
+ * Enforce the policy described in this module's header on `rawUrl`.
33
+ *
34
+ * Rejection is an `Error` whose message names the rule and, where one
35
+ * applies, the opt-out (`allowPrivateNetwork`). The URL itself is quoted so
36
+ * an operator can find the offending server entry; it never carries
37
+ * credentials, because `headers` are not part of the URL.
38
+ */
39
+ export declare function assertMcpTransportUrlAllowed(rawUrl: string, options: McpTransportUrlPolicyOptions): Promise<void>;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * URL policy for the network MCP transports (http, sse, websocket).
3
+ *
4
+ * An MCP server URL names where every tool call, and every configured
5
+ * header, is sent. Until this policy existed, nothing checked it: a project
6
+ * config could point a server at `http://169.254.169.254/` or at an
7
+ * attacker's plaintext host and the SDK would connect. The rules, in order:
8
+ *
9
+ * 1. The scheme is one of `https:`, `wss:`, `http:`, `ws:`.
10
+ * 2. A loopback host (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`) may
11
+ * use any of the four. Local development servers are the common case
12
+ * for plaintext and they never leave the machine.
13
+ * 3. Otherwise plaintext is refused, and a host that is — or resolves to —
14
+ * a private, link-local, CGNAT, multicast or cloud-metadata address is
15
+ * refused too, so a tool call cannot be turned into a request against
16
+ * the machine's own network.
17
+ * 4. `allowPrivateNetwork: true` on that server's config lifts rule 3 for
18
+ * private addresses, and lets plaintext reach them. A public host still
19
+ * needs TLS.
20
+ *
21
+ * `utils/ssrfGuard.ts` is deliberately not reused: it is HTTPS-only and treats
22
+ * loopback as blocked, which is right for a server fetching an untrusted URL
23
+ * and wrong for a client talking to a local MCP server.
24
+ */
25
+ import { lookup } from "node:dns/promises";
26
+ import { BlockList, isIP } from "node:net";
27
+ const PLAINTEXT_SCHEMES = new Set(["http:", "ws:"]);
28
+ const TLS_SCHEMES = new Set(["https:", "wss:"]);
29
+ const LOOPBACK_ADDRESSES = new BlockList();
30
+ LOOPBACK_ADDRESSES.addSubnet("127.0.0.0", 8, "ipv4");
31
+ LOOPBACK_ADDRESSES.addAddress("::1", "ipv6");
32
+ /**
33
+ * Non-public ranges: RFC 1918, link-local (which holds the AWS/GCP/Azure
34
+ * metadata endpoint), CGNAT (which holds Alibaba's), "this network",
35
+ * protocol assignments, benchmarking, multicast, reserved, and the IPv6
36
+ * unspecified, unique-local and link-local ranges. `BlockList` checks an
37
+ * IPv4-mapped IPv6 address against the IPv4 rules on its own.
38
+ */
39
+ const PRIVATE_ADDRESSES = new BlockList();
40
+ PRIVATE_ADDRESSES.addSubnet("0.0.0.0", 8, "ipv4");
41
+ PRIVATE_ADDRESSES.addSubnet("10.0.0.0", 8, "ipv4");
42
+ PRIVATE_ADDRESSES.addSubnet("100.64.0.0", 10, "ipv4");
43
+ PRIVATE_ADDRESSES.addSubnet("169.254.0.0", 16, "ipv4");
44
+ PRIVATE_ADDRESSES.addSubnet("172.16.0.0", 12, "ipv4");
45
+ PRIVATE_ADDRESSES.addSubnet("192.0.0.0", 24, "ipv4");
46
+ PRIVATE_ADDRESSES.addSubnet("192.168.0.0", 16, "ipv4");
47
+ PRIVATE_ADDRESSES.addSubnet("198.18.0.0", 15, "ipv4");
48
+ PRIVATE_ADDRESSES.addSubnet("224.0.0.0", 4, "ipv4");
49
+ PRIVATE_ADDRESSES.addSubnet("240.0.0.0", 4, "ipv4");
50
+ PRIVATE_ADDRESSES.addAddress("::", "ipv6");
51
+ PRIVATE_ADDRESSES.addSubnet("fc00::", 7, "ipv6");
52
+ PRIVATE_ADDRESSES.addSubnet("fe80::", 10, "ipv6");
53
+ function stripBrackets(host) {
54
+ return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
55
+ }
56
+ function isLoopbackName(host) {
57
+ const name = host.endsWith(".") ? host.slice(0, -1) : host;
58
+ return name === "localhost" || name.endsWith(".localhost");
59
+ }
60
+ /**
61
+ * Classify one IP literal. Throws when `ip` is not an IP address at all, so a
62
+ * caller never silently treats a host name as public.
63
+ */
64
+ export function classifyMcpTransportAddress(ip) {
65
+ const family = isIP(ip);
66
+ if (family === 0) {
67
+ throw new Error(`"${ip}" is not an IP address`);
68
+ }
69
+ const type = family === 4 ? "ipv4" : "ipv6";
70
+ if (LOOPBACK_ADDRESSES.check(ip, type)) {
71
+ return "loopback";
72
+ }
73
+ if (PRIVATE_ADDRESSES.check(ip, type)) {
74
+ return "private";
75
+ }
76
+ return "public";
77
+ }
78
+ function describe(options) {
79
+ return options.serverId
80
+ ? `MCP server "${options.serverId}" (${options.transport})`
81
+ : `MCP ${options.transport} transport`;
82
+ }
83
+ async function resolveHostAddresses(host) {
84
+ const records = await lookup(host, { all: true, verbatim: true });
85
+ return records.map((record) => record.address);
86
+ }
87
+ /**
88
+ * Enforce the policy described in this module's header on `rawUrl`.
89
+ *
90
+ * Rejection is an `Error` whose message names the rule and, where one
91
+ * applies, the opt-out (`allowPrivateNetwork`). The URL itself is quoted so
92
+ * an operator can find the offending server entry; it never carries
93
+ * credentials, because `headers` are not part of the URL.
94
+ */
95
+ export async function assertMcpTransportUrlAllowed(rawUrl, options) {
96
+ let url;
97
+ try {
98
+ url = new URL(rawUrl);
99
+ }
100
+ catch {
101
+ throw new Error(`${describe(options)}: "${rawUrl}" is not a valid URL`);
102
+ }
103
+ const scheme = url.protocol;
104
+ const tls = TLS_SCHEMES.has(scheme);
105
+ if (!tls && !PLAINTEXT_SCHEMES.has(scheme)) {
106
+ throw new Error(`${describe(options)}: scheme "${scheme}" is not allowed (use https:, wss:, or http:/ws: for a loopback host)`);
107
+ }
108
+ const host = stripBrackets(url.hostname).toLowerCase();
109
+ if (host.length === 0) {
110
+ throw new Error(`${describe(options)}: "${rawUrl}" has no host`);
111
+ }
112
+ // A host-supplied fetch/dispatcher owns the connection (the egress suites
113
+ // pin DNS through exactly such a fetch); judging the hostname here would
114
+ // refuse names only that resolver knows. The scheme check above still ran.
115
+ if (options.hostOwnedEgress === true) {
116
+ return;
117
+ }
118
+ const allowPrivate = options.allowPrivateNetwork === true;
119
+ const literalClass = isLoopbackName(host)
120
+ ? "loopback"
121
+ : isIP(host) !== 0
122
+ ? classifyMcpTransportAddress(host)
123
+ : undefined;
124
+ if (literalClass !== undefined) {
125
+ if (literalClass === "loopback") {
126
+ return;
127
+ }
128
+ if (!allowPrivate) {
129
+ if (literalClass === "private") {
130
+ throw new Error(`${describe(options)}: "${rawUrl}" points at a private, link-local or metadata address; set "allowPrivateNetwork": true on this server if that is intended`);
131
+ }
132
+ if (!tls) {
133
+ throw new Error(`${describe(options)}: "${rawUrl}" uses plaintext ${scheme} to a non-loopback host; use ${scheme === "ws:" ? "wss:" : "https:"}`);
134
+ }
135
+ return;
136
+ }
137
+ if (literalClass === "public" && !tls) {
138
+ throw new Error(`${describe(options)}: "${rawUrl}" uses plaintext ${scheme} to a public host; allowPrivateNetwork only permits plaintext to private addresses`);
139
+ }
140
+ return;
141
+ }
142
+ // A host name. Plaintext to anything that is not a loopback literal or
143
+ // `localhost` is refused before any lookup, so a hostile config cannot
144
+ // make the decision depend on what its own DNS answers.
145
+ if (!tls && !allowPrivate) {
146
+ throw new Error(`${describe(options)}: "${rawUrl}" uses plaintext ${scheme} to a non-loopback host; use ${scheme === "ws:" ? "wss:" : "https:"}, or set "allowPrivateNetwork": true for a server on your own network`);
147
+ }
148
+ let addresses;
149
+ try {
150
+ addresses = await resolveHostAddresses(host);
151
+ }
152
+ catch (error) {
153
+ throw new Error(`${describe(options)}: host "${host}" could not be resolved (${error instanceof Error ? error.message : String(error)})`, { cause: error });
154
+ }
155
+ if (addresses.length === 0) {
156
+ throw new Error(`${describe(options)}: host "${host}" resolved to no addresses`);
157
+ }
158
+ const classes = addresses.map((address) => ({
159
+ address,
160
+ cls: classifyMcpTransportAddress(address),
161
+ }));
162
+ if (!allowPrivate) {
163
+ const nonPublic = classes.find((entry) => entry.cls !== "public");
164
+ if (nonPublic) {
165
+ throw new Error(`${describe(options)}: host "${host}" resolves to ${nonPublic.address}, a ${nonPublic.cls} address; set "allowPrivateNetwork": true on this server if that is intended`);
166
+ }
167
+ return;
168
+ }
169
+ if (!tls && classes.some((entry) => entry.cls === "public")) {
170
+ throw new Error(`${describe(options)}: "${rawUrl}" uses plaintext ${scheme} to a host that resolves to a public address; allowPrivateNetwork only permits plaintext to private addresses`);
171
+ }
172
+ }