@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.
- package/CHANGELOG.md +2 -2
- package/README.md +1 -1
- package/dist/action/actionExecutor.d.ts +33 -3
- package/dist/action/actionExecutor.js +71 -15
- package/dist/action/actionInputs.js +4 -1
- package/dist/action/cliVersion.d.ts +25 -0
- package/dist/action/cliVersion.js +26 -0
- package/dist/action/index.d.ts +2 -1
- package/dist/action/index.js +2 -1
- package/dist/adapters/video/directorPipeline.js +46 -36
- package/dist/adapters/video/ffmpegAdapter.d.ts +17 -0
- package/dist/adapters/video/ffmpegAdapter.js +32 -5
- package/dist/adapters/video/frameExtractor.js +1 -1
- package/dist/adapters/video/videoMerger.js +5 -2
- package/dist/agent/arithmeticEvaluator.d.ts +32 -0
- package/dist/agent/arithmeticEvaluator.js +349 -0
- package/dist/agent/directTools.d.ts +6 -18
- package/dist/agent/directTools.js +17 -56
- package/dist/artifacts/artifactStore.d.ts +22 -1
- package/dist/artifacts/artifactStore.js +66 -6
- package/dist/auth/anthropicOAuth.d.ts +26 -1
- package/dist/auth/anthropicOAuth.js +81 -5
- package/dist/auth/secureFiles.d.ts +37 -0
- package/dist/auth/secureFiles.js +95 -0
- package/dist/auth/tokenStore.d.ts +43 -6
- package/dist/auth/tokenStore.js +260 -79
- package/dist/avatar/providers/DIDAvatar.js +49 -45
- package/dist/avatar/providers/ReplicateAvatar.js +33 -40
- package/dist/browser/neurolink.min.js +485 -558
- package/dist/cli/commands/auth.js +112 -70
- package/dist/cli/commands/proxy.d.ts +4 -0
- package/dist/cli/commands/proxy.js +128 -13
- package/dist/cli/commands/serve.d.ts +10 -1
- package/dist/cli/commands/serve.js +98 -17
- package/dist/cli/commands/server.js +17 -5
- package/dist/cli/index.js +146 -5
- package/dist/cli/loop/optionsSchema.d.ts +1 -1
- package/dist/cli/utils/envManager.js +17 -3
- package/dist/cli/utils/serverUtils.d.ts +39 -3
- package/dist/cli/utils/serverUtils.js +63 -6
- package/dist/core/baseProvider.js +7 -1
- package/dist/core/modules/MessageBuilder.js +5 -0
- package/dist/core/modules/ToolsManager.d.ts +46 -1
- package/dist/core/modules/ToolsManager.js +190 -43
- package/dist/files/fileReferenceRegistry.d.ts +2 -23
- package/dist/files/fileReferenceRegistry.js +55 -5
- package/dist/hitl/hitlManager.js +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +6 -1
- package/dist/mcp/auth/tokenStorage.d.ts +7 -0
- package/dist/mcp/auth/tokenStorage.js +23 -4
- package/dist/mcp/externalServerManager.d.ts +13 -3
- package/dist/mcp/externalServerManager.js +154 -136
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp/mcpClientFactory.d.ts +14 -0
- package/dist/mcp/mcpClientFactory.js +70 -7
- package/dist/mcp/projectConfigTrust.d.ts +72 -0
- package/dist/mcp/projectConfigTrust.js +237 -0
- package/dist/mcp/transportUrlPolicy.d.ts +39 -0
- package/dist/mcp/transportUrlPolicy.js +172 -0
- package/dist/music/providers/BeatovenMusic.js +35 -46
- package/dist/music/providers/ReplicateMusic.js +33 -40
- package/dist/neurolink.d.ts +16 -1
- package/dist/neurolink.js +64 -11
- package/dist/processors/archive/ArchiveProcessor.js +15 -1
- package/dist/processors/archive/zipEntryIndex.d.ts +22 -0
- package/dist/processors/archive/zipEntryIndex.js +33 -0
- package/dist/processors/base/BaseFileProcessor.d.ts +9 -21
- package/dist/processors/base/BaseFileProcessor.js +40 -111
- package/dist/processors/data/YamlProcessor.d.ts +13 -1
- package/dist/processors/data/YamlProcessor.js +93 -2
- package/dist/processors/document/OpenDocumentProcessor.js +7 -2
- package/dist/processors/document/PptxProcessor.js +12 -7
- package/dist/processors/media/VideoProcessor.js +93 -15
- package/dist/proxy/inboundAuth.d.ts +89 -0
- package/dist/proxy/inboundAuth.js +219 -0
- package/dist/proxy/tokenRefresh.d.ts +6 -0
- package/dist/proxy/tokenRefresh.js +13 -1
- package/dist/proxy/updateState.js +4 -4
- package/dist/rag/document/loaders.d.ts +0 -6
- package/dist/rag/document/loaders.js +16 -10
- package/dist/rag/ragIntegration.d.ts +2 -2
- package/dist/rag/ragIntegration.js +35 -10
- package/dist/rag/retrieval/vectorQueryTool.d.ts +7 -0
- package/dist/rag/retrieval/vectorQueryTool.js +54 -17
- package/dist/server/abstract/baseServerAdapter.d.ts +43 -0
- package/dist/server/abstract/baseServerAdapter.js +119 -6
- package/dist/server/adapters/expressAdapter.d.ts +3 -0
- package/dist/server/adapters/expressAdapter.js +83 -8
- package/dist/server/adapters/fastifyAdapter.d.ts +3 -0
- package/dist/server/adapters/fastifyAdapter.js +165 -70
- package/dist/server/adapters/honoAdapter.d.ts +9 -1
- package/dist/server/adapters/honoAdapter.js +99 -21
- package/dist/server/adapters/koaAdapter.d.ts +1 -0
- package/dist/server/adapters/koaAdapter.js +153 -74
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +4 -0
- package/dist/server/middleware/auth.js +6 -2
- package/dist/server/middleware/cache.js +6 -1
- package/dist/server/middleware/rateLimit.js +29 -4
- package/dist/server/routes/agentRoutes.js +24 -11
- package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/server/routes/claudeProxyRoutes.js +273 -84
- package/dist/server/routes/codexProxyRoutes.d.ts +2 -2
- package/dist/server/routes/codexProxyRoutes.js +125 -40
- package/dist/server/routes/index.d.ts +4 -0
- package/dist/server/routes/index.js +35 -4
- package/dist/server/routes/mcpRoutes.d.ts +6 -2
- package/dist/server/routes/mcpRoutes.js +21 -7
- package/dist/server/routes/memoryRoutes.js +7 -6
- package/dist/server/routes/toolRoutes.d.ts +12 -2
- package/dist/server/routes/toolRoutes.js +33 -3
- package/dist/server/routes/transcribeRoutes.js +60 -13
- package/dist/server/utils/clientAddress.d.ts +23 -0
- package/dist/server/utils/clientAddress.js +65 -0
- package/dist/server/utils/clientError.d.ts +35 -0
- package/dist/server/utils/clientError.js +129 -0
- package/dist/server/utils/expressBody.d.ts +33 -0
- package/dist/server/utils/expressBody.js +353 -0
- package/dist/server/utils/pathMatch.d.ts +21 -0
- package/dist/server/utils/pathMatch.js +41 -0
- package/dist/server/utils/validation.d.ts +9 -0
- package/dist/server/utils/validation.js +9 -0
- package/dist/server/websocket/WebSocketHandler.js +22 -3
- package/dist/server/websocket/frameQueue.d.ts +19 -0
- package/dist/server/websocket/frameQueue.js +41 -3
- package/dist/server/websocket/transcribeWebSocket.d.ts +8 -1
- package/dist/server/websocket/transcribeWebSocket.js +223 -34
- package/dist/skills/skillStores.js +5 -0
- package/dist/tasks/store/fileTaskStore.d.ts +2 -0
- package/dist/tasks/store/fileTaskStore.js +11 -4
- package/dist/types/attachments.d.ts +117 -0
- package/dist/types/attachments.js +15 -0
- package/dist/types/auth.d.ts +6 -0
- package/dist/types/cli.d.ts +4 -0
- package/dist/types/config.d.ts +56 -0
- package/dist/types/file.d.ts +8 -0
- package/dist/types/hitl.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/mcp.d.ts +98 -0
- package/dist/types/multimodal.js +0 -41
- package/dist/types/processor.d.ts +14 -1
- package/dist/types/proxy.d.ts +62 -3
- package/dist/types/rag.d.ts +9 -0
- package/dist/types/safeFetch.d.ts +40 -0
- package/dist/types/server.d.ts +255 -10
- package/dist/types/tools.d.ts +57 -0
- package/dist/types/transcribe.d.ts +6 -0
- package/dist/utils/attachmentPolicy.d.ts +89 -0
- package/dist/utils/attachmentPolicy.js +327 -0
- package/dist/utils/dotenvBootstrap.d.ts +48 -19
- package/dist/utils/dotenvBootstrap.js +172 -31
- package/dist/utils/fileDetector.js +115 -65
- package/dist/utils/json/coerce.js +11 -1
- package/dist/utils/logSanitize.d.ts +5 -0
- package/dist/utils/logSanitize.js +8 -0
- package/dist/utils/logger.js +73 -1
- package/dist/utils/messageBuilder.d.ts +2 -2
- package/dist/utils/messageBuilder.js +118 -105
- package/dist/utils/multimodalOptionsBuilder.d.ts +1 -0
- package/dist/utils/multimodalOptionsBuilder.js +2 -0
- package/dist/utils/redis.d.ts +12 -0
- package/dist/utils/redis.js +72 -0
- package/dist/utils/safeFetch.d.ts +22 -1
- package/dist/utils/safeFetch.js +132 -38
- package/dist/utils/safeStoreId.d.ts +19 -0
- package/dist/utils/safeStoreId.js +29 -0
- package/dist/utils/schemaConversion.d.ts +30 -3
- package/dist/utils/schemaConversion.js +457 -84
- package/dist/utils/sizeGuard.js +2 -2
- package/dist/utils/ssrfGuard.d.ts +3 -3
- package/dist/utils/ssrfGuard.js +71 -39
- package/dist/voice/eval/sttEvalStore.d.ts +26 -3
- package/dist/voice/eval/sttEvalStore.js +77 -12
- package/dist/voice/livekit/agentDefinition.d.ts +18 -0
- package/dist/voice/livekit/agentDefinition.js +22 -0
- package/dist/voice/livekit/realtimeMcpTools.d.ts +1 -1
- package/dist/voice/livekit/realtimeMcpTools.js +8 -7
- package/dist/voice/livekit/realtimeVoiceAgent.d.ts +2 -4
- package/dist/voice/livekit/realtimeVoiceAgent.js +18 -7
- package/dist/voice/livekit/voiceAgent.d.ts +2 -2
- package/dist/voice/livekit/voiceAgent.js +2 -1
- package/dist/voice/livekit/xaiSessionWatch.d.ts +0 -5
- package/dist/voice/livekit/xaiSessionWatch.js +30 -0
- package/dist/voice/providers/GeminiLive.js +21 -6
- package/dist/voice/providers/GoogleSTT.js +8 -5
- package/dist/voice/transcribe.d.ts +2 -2
- package/dist/voice/transcribe.js +34 -18
- package/dist/voice/whistle/audio.js +11 -5
- package/docs-site/static/search-index.json +75 -52
- package/package.json +171 -156
- 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
|
-
...
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
+
}
|