@databricks/appkit 0.31.0 → 0.32.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.
- package/CLAUDE.md +1 -0
- package/NOTICE.md +1 -0
- package/dist/appkit/package.js +1 -1
- package/dist/beta.d.ts +14 -1
- package/dist/beta.js +12 -1
- package/dist/connectors/index.js +3 -0
- package/dist/connectors/mcp/client.d.ts +60 -0
- package/dist/connectors/mcp/client.d.ts.map +1 -0
- package/dist/connectors/mcp/client.js +197 -0
- package/dist/connectors/mcp/client.js.map +1 -0
- package/dist/connectors/mcp/host-policy.d.ts +51 -0
- package/dist/connectors/mcp/host-policy.d.ts.map +1 -0
- package/dist/connectors/mcp/host-policy.js +168 -0
- package/dist/connectors/mcp/host-policy.js.map +1 -0
- package/dist/connectors/mcp/index.d.ts +3 -0
- package/dist/connectors/mcp/index.js +4 -0
- package/dist/connectors/mcp/types.d.ts +16 -0
- package/dist/connectors/mcp/types.d.ts.map +1 -0
- package/dist/context/index.js +1 -1
- package/dist/core/agent/build-toolkit.d.ts +2 -0
- package/dist/core/agent/build-toolkit.js +50 -0
- package/dist/core/agent/build-toolkit.js.map +1 -0
- package/dist/core/agent/consume-adapter-stream.js +33 -0
- package/dist/core/agent/consume-adapter-stream.js.map +1 -0
- package/dist/core/agent/create-agent.d.ts +27 -0
- package/dist/core/agent/create-agent.d.ts.map +1 -0
- package/dist/core/agent/create-agent.js +50 -0
- package/dist/core/agent/create-agent.js.map +1 -0
- package/dist/core/agent/load-agents.d.ts +67 -0
- package/dist/core/agent/load-agents.d.ts.map +1 -0
- package/dist/core/agent/load-agents.js +228 -0
- package/dist/core/agent/load-agents.js.map +1 -0
- package/dist/core/agent/normalize-result.js +39 -0
- package/dist/core/agent/normalize-result.js.map +1 -0
- package/dist/core/agent/run-agent.d.ts +34 -0
- package/dist/core/agent/run-agent.d.ts.map +1 -0
- package/dist/core/agent/run-agent.js +146 -0
- package/dist/core/agent/run-agent.js.map +1 -0
- package/dist/core/agent/system-prompt.js +38 -0
- package/dist/core/agent/system-prompt.js.map +1 -0
- package/dist/core/agent/tools/define-tool.d.ts +54 -0
- package/dist/core/agent/tools/define-tool.d.ts.map +1 -0
- package/dist/core/agent/tools/define-tool.js +50 -0
- package/dist/core/agent/tools/define-tool.js.map +1 -0
- package/dist/core/agent/tools/function-tool.d.ts +27 -0
- package/dist/core/agent/tools/function-tool.d.ts.map +1 -0
- package/dist/core/agent/tools/function-tool.js +21 -0
- package/dist/core/agent/tools/function-tool.js.map +1 -0
- package/dist/core/agent/tools/hosted-tools.d.ts +47 -0
- package/dist/core/agent/tools/hosted-tools.d.ts.map +1 -0
- package/dist/core/agent/tools/hosted-tools.js +67 -0
- package/dist/core/agent/tools/hosted-tools.js.map +1 -0
- package/dist/core/agent/tools/index.d.ts +5 -0
- package/dist/core/agent/tools/index.js +7 -0
- package/dist/core/agent/tools/json-schema.js +24 -0
- package/dist/core/agent/tools/json-schema.js.map +1 -0
- package/dist/core/agent/tools/sql-policy.js +256 -0
- package/dist/core/agent/tools/sql-policy.js.map +1 -0
- package/dist/core/agent/tools/tool.d.ts +34 -0
- package/dist/core/agent/tools/tool.d.ts.map +1 -0
- package/dist/core/agent/tools/tool.js +41 -0
- package/dist/core/agent/tools/tool.js.map +1 -0
- package/dist/core/agent/types.d.ts +214 -0
- package/dist/core/agent/types.d.ts.map +1 -0
- package/dist/core/agent/types.js +12 -0
- package/dist/core/agent/types.js.map +1 -0
- package/dist/core/appkit.d.ts +1 -0
- package/dist/core/appkit.d.ts.map +1 -1
- package/dist/core/appkit.js +31 -4
- package/dist/core/appkit.js.map +1 -1
- package/dist/core/plugin-context.d.ts +133 -0
- package/dist/core/plugin-context.d.ts.map +1 -0
- package/dist/core/plugin-context.js +220 -0
- package/dist/core/plugin-context.js.map +1 -0
- package/dist/index.d.ts +11 -11
- package/dist/internal-telemetry/appkit-log.js +19 -0
- package/dist/internal-telemetry/appkit-log.js.map +1 -0
- package/dist/internal-telemetry/config.js +15 -0
- package/dist/internal-telemetry/config.js.map +1 -0
- package/dist/internal-telemetry/index.js +4 -0
- package/dist/internal-telemetry/reporter.js +132 -0
- package/dist/internal-telemetry/reporter.js.map +1 -0
- package/dist/plugin/index.d.ts +1 -1
- package/dist/plugin/plugin.d.ts +18 -3
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +26 -2
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/plugin/to-plugin.d.ts +15 -4
- package/dist/plugin/to-plugin.d.ts.map +1 -1
- package/dist/plugin/to-plugin.js +14 -4
- package/dist/plugin/to-plugin.js.map +1 -1
- package/dist/plugins/agents/agents.d.ts +4 -0
- package/dist/plugins/agents/agents.js +882 -0
- package/dist/plugins/agents/agents.js.map +1 -0
- package/dist/plugins/agents/defaults.js +13 -0
- package/dist/plugins/agents/defaults.js.map +1 -0
- package/dist/plugins/agents/event-channel.js +64 -0
- package/dist/plugins/agents/event-channel.js.map +1 -0
- package/dist/plugins/agents/event-translator.js +224 -0
- package/dist/plugins/agents/event-translator.js.map +1 -0
- package/dist/plugins/agents/index.d.ts +4 -0
- package/dist/plugins/agents/index.js +6 -0
- package/dist/plugins/agents/manifest.js +27 -0
- package/dist/plugins/agents/manifest.js.map +1 -0
- package/dist/plugins/agents/schemas.js +51 -0
- package/dist/plugins/agents/schemas.js.map +1 -0
- package/dist/plugins/agents/thread-store.js +58 -0
- package/dist/plugins/agents/thread-store.js.map +1 -0
- package/dist/plugins/agents/tool-approval-gate.js +75 -0
- package/dist/plugins/agents/tool-approval-gate.js.map +1 -0
- package/dist/plugins/analytics/analytics.d.ts +17 -2
- package/dist/plugins/analytics/analytics.d.ts.map +1 -1
- package/dist/plugins/analytics/analytics.js +33 -0
- package/dist/plugins/analytics/analytics.js.map +1 -1
- package/dist/plugins/files/plugin.d.ts +22 -3
- package/dist/plugins/files/plugin.d.ts.map +1 -1
- package/dist/plugins/files/plugin.js +102 -2
- package/dist/plugins/files/plugin.js.map +1 -1
- package/dist/plugins/genie/genie.d.ts +15 -2
- package/dist/plugins/genie/genie.d.ts.map +1 -1
- package/dist/plugins/genie/genie.js +45 -0
- package/dist/plugins/genie/genie.js.map +1 -1
- package/dist/plugins/jobs/plugin.d.ts +2 -1
- package/dist/plugins/jobs/plugin.d.ts.map +1 -1
- package/dist/plugins/jobs/plugin.js +1 -1
- package/dist/plugins/lakebase/index.d.ts +2 -2
- package/dist/plugins/lakebase/index.js +1 -1
- package/dist/plugins/lakebase/lakebase.d.ts +33 -4
- package/dist/plugins/lakebase/lakebase.d.ts.map +1 -1
- package/dist/plugins/lakebase/lakebase.js +77 -5
- package/dist/plugins/lakebase/lakebase.js.map +1 -1
- package/dist/plugins/lakebase/types.d.ts +38 -1
- package/dist/plugins/lakebase/types.d.ts.map +1 -1
- package/dist/plugins/server/index.d.ts +12 -1
- package/dist/plugins/server/index.d.ts.map +1 -1
- package/dist/plugins/server/index.js +39 -5
- package/dist/plugins/server/index.js.map +1 -1
- package/dist/plugins/server/types.d.ts +0 -3
- package/dist/plugins/server/types.d.ts.map +1 -1
- package/dist/plugins/serving/serving.d.ts +2 -1
- package/dist/plugins/serving/serving.d.ts.map +1 -1
- package/dist/shared/src/agent.d.ts +63 -1
- package/dist/shared/src/agent.d.ts.map +1 -1
- package/dist/shared/src/index.d.ts +1 -1
- package/dist/shared/src/plugin.d.ts +8 -0
- package/dist/shared/src/plugin.d.ts.map +1 -1
- package/docs/api/appkit/Class.Plugin.md +65 -23
- package/docs/api/appkit/Function.createApp.md +10 -8
- package/docs/privacy.md +41 -0
- package/llms.txt +1 -0
- package/package.json +4 -2
- package/sbom.cdx.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -29,6 +29,7 @@ npx @databricks/appkit docs <query>
|
|
|
29
29
|
- [Development](./docs/development.md): AppKit provides multiple development workflows to suit different needs: local development with hot reload, AI-assisted development with Agent Skills, and remote tunneling to deployed backends.
|
|
30
30
|
- [FAQ](./docs/faq.md): Integrations
|
|
31
31
|
- [Plugins](./docs/plugins.md): Plugins are modular extensions that add capabilities to your AppKit application. They follow a defined lifecycle and have access to shared services like caching, telemetry, and streaming.
|
|
32
|
+
- [Privacy](./docs/privacy.md): AppKit sends a small amount of anonymized usage telemetry to Databricks
|
|
32
33
|
|
|
33
34
|
## Development
|
|
34
35
|
|
package/NOTICE.md
CHANGED
|
@@ -64,6 +64,7 @@ This Software contains code from the following open source projects:
|
|
|
64
64
|
| [echarts-for-react](https://www.npmjs.com/package/echarts-for-react) | 3.0.5 | MIT | https://github.com/hustcc/echarts-for-react |
|
|
65
65
|
| [embla-carousel-react](https://www.npmjs.com/package/embla-carousel-react) | 8.6.0 | MIT | https://www.embla-carousel.com |
|
|
66
66
|
| [express](https://www.npmjs.com/package/express) | 4.22.0 | MIT | http://expressjs.com/ |
|
|
67
|
+
| [get-port](https://www.npmjs.com/package/get-port) | 7.2.0 | MIT | https://github.com/sindresorhus/get-port#readme |
|
|
67
68
|
| [input-otp](https://www.npmjs.com/package/input-otp) | 1.4.2 | MIT | https://input-otp.rodz.dev/ |
|
|
68
69
|
| [lucide-react](https://www.npmjs.com/package/lucide-react) | 0.554.0 | ISC | https://lucide.dev |
|
|
69
70
|
| [marked](https://www.npmjs.com/package/marked) | 16.4.2, 17.0.3 | MIT | https://marked.js.org |
|
package/dist/appkit/package.js
CHANGED
package/dist/beta.d.ts
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
|
+
import { AgentAdapter, AgentEvent, AgentInput, AgentRunContext, AgentToolDefinition, Message, Thread, ThreadStore, ToolAnnotations, ToolProvider } from "./shared/src/agent.js";
|
|
2
|
+
import "./shared/src/index.js";
|
|
1
3
|
import { DatabricksAdapter, parseTextToolCalls } from "./agents/databricks.js";
|
|
2
|
-
|
|
4
|
+
import { AppKitMcpClient } from "./connectors/mcp/client.js";
|
|
5
|
+
import { FunctionTool, functionToolToDefinition, isFunctionTool } from "./core/agent/tools/function-tool.js";
|
|
6
|
+
import { HostedTool, isHostedTool, mcpServer, resolveHostedTools } from "./core/agent/tools/hosted-tools.js";
|
|
7
|
+
import { AgentDefinition, AgentTool, AgentsPluginConfig, AutoInheritToolsConfig, BaseSystemPromptOption, PromptContext, RegisteredAgent, ResolvedToolEntry, ToolkitEntry, ToolkitOptions, isToolkitEntry } from "./core/agent/types.js";
|
|
8
|
+
import { createAgent } from "./core/agent/create-agent.js";
|
|
9
|
+
import { RunAgentInput, RunAgentResult, runAgent } from "./core/agent/run-agent.js";
|
|
10
|
+
import { ToolEntry, ToolRegistry, defineTool, executeFromRegistry, toolsFromRegistry } from "./core/agent/tools/define-tool.js";
|
|
11
|
+
import { ToolConfig, tool } from "./core/agent/tools/tool.js";
|
|
12
|
+
import "./core/agent/tools/index.js";
|
|
13
|
+
import { agentIdFromMarkdownPath, loadAgentFromFile, loadAgentsFromDir } from "./core/agent/load-agents.js";
|
|
14
|
+
import "./plugins/agents/index.js";
|
|
15
|
+
export { type AgentAdapter, type AgentDefinition, type AgentEvent, type AgentInput, type AgentRunContext, type AgentTool, type AgentToolDefinition, type AgentsPluginConfig, AppKitMcpClient, type AutoInheritToolsConfig, type BaseSystemPromptOption, DatabricksAdapter, type FunctionTool, type HostedTool, type Message, type PromptContext, type RegisteredAgent, type ResolvedToolEntry, type RunAgentInput, type RunAgentResult, type Thread, type ThreadStore, type ToolAnnotations, type ToolConfig, type ToolEntry, type ToolProvider, type ToolRegistry, type ToolkitEntry, type ToolkitOptions, agentIdFromMarkdownPath, createAgent, defineTool, executeFromRegistry, functionToolToDefinition, isFunctionTool, isHostedTool, isToolkitEntry, loadAgentFromFile, loadAgentsFromDir, mcpServer, parseTextToolCalls, resolveHostedTools, runAgent, tool, toolsFromRegistry };
|
package/dist/beta.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { AppKitMcpClient } from "./connectors/mcp/client.js";
|
|
2
|
+
import { tool } from "./core/agent/tools/tool.js";
|
|
3
|
+
import { defineTool, executeFromRegistry, toolsFromRegistry } from "./core/agent/tools/define-tool.js";
|
|
1
4
|
import { DatabricksAdapter, parseTextToolCalls } from "./agents/databricks.js";
|
|
5
|
+
import { createAgent } from "./core/agent/create-agent.js";
|
|
6
|
+
import { functionToolToDefinition, isFunctionTool } from "./core/agent/tools/function-tool.js";
|
|
7
|
+
import { isHostedTool, mcpServer, resolveHostedTools } from "./core/agent/tools/hosted-tools.js";
|
|
8
|
+
import { isToolkitEntry } from "./core/agent/types.js";
|
|
9
|
+
import { runAgent } from "./core/agent/run-agent.js";
|
|
10
|
+
import "./core/agent/tools/index.js";
|
|
11
|
+
import { agentIdFromMarkdownPath, loadAgentFromFile, loadAgentsFromDir } from "./core/agent/load-agents.js";
|
|
12
|
+
import "./plugins/agents/index.js";
|
|
2
13
|
|
|
3
|
-
export { DatabricksAdapter, parseTextToolCalls };
|
|
14
|
+
export { AppKitMcpClient, DatabricksAdapter, agentIdFromMarkdownPath, createAgent, defineTool, executeFromRegistry, functionToolToDefinition, isFunctionTool, isHostedTool, isToolkitEntry, loadAgentFromFile, loadAgentsFromDir, mcpServer, parseTextToolCalls, resolveHostedTools, runAgent, tool, toolsFromRegistry };
|
package/dist/connectors/index.js
CHANGED
|
@@ -7,6 +7,9 @@ import "./genie/index.js";
|
|
|
7
7
|
import { JobsConnector } from "./jobs/client.js";
|
|
8
8
|
import "./jobs/index.js";
|
|
9
9
|
import "./lakebase-v1/index.js";
|
|
10
|
+
import { buildMcpHostPolicy } from "./mcp/host-policy.js";
|
|
11
|
+
import { AppKitMcpClient } from "./mcp/client.js";
|
|
12
|
+
import "./mcp/index.js";
|
|
10
13
|
import { SQLWarehouseConnector } from "./sql-warehouse/client.js";
|
|
11
14
|
import "./sql-warehouse/index.js";
|
|
12
15
|
import "./vector-search/index.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { AgentToolDefinition } from "../../shared/src/agent.js";
|
|
2
|
+
import "../../shared/src/index.js";
|
|
3
|
+
import { DnsLookup, McpHostPolicy } from "./host-policy.js";
|
|
4
|
+
import { McpEndpointConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
//#region src/connectors/mcp/client.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* Lightweight MCP client for Databricks-hosted MCP servers.
|
|
9
|
+
*
|
|
10
|
+
* Uses raw fetch() with JSON-RPC 2.0 over HTTP — no @modelcontextprotocol/sdk
|
|
11
|
+
* or LangChain dependency. Supports the Streamable HTTP transport only
|
|
12
|
+
* (POST with JSON-RPC request, single JSON-RPC response). Implements exactly
|
|
13
|
+
* four methods: `initialize`, `notifications/initialized`, `tools/list`,
|
|
14
|
+
* `tools/call`. No prompts/resources/completion/sampling.
|
|
15
|
+
*
|
|
16
|
+
* All outbound URLs are gated by an {@link McpHostPolicy}: unallowlisted hosts
|
|
17
|
+
* are rejected before the first byte is sent, and workspace credentials are
|
|
18
|
+
* only forwarded to the same-origin workspace. See `mcp-host-policy.ts`.
|
|
19
|
+
*
|
|
20
|
+
* Rationale for hand-rolling JSON-RPC instead of `@modelcontextprotocol/sdk`:
|
|
21
|
+
* see the file-level comment at the top of this module.
|
|
22
|
+
*/
|
|
23
|
+
declare class AppKitMcpClient {
|
|
24
|
+
private workspaceHost;
|
|
25
|
+
private authenticate;
|
|
26
|
+
private policy;
|
|
27
|
+
private options;
|
|
28
|
+
private connections;
|
|
29
|
+
private sessionIds;
|
|
30
|
+
private requestId;
|
|
31
|
+
private closed;
|
|
32
|
+
constructor(workspaceHost: string, authenticate: () => Promise<Record<string, string>>, policy: McpHostPolicy, options?: {
|
|
33
|
+
dnsLookup?: DnsLookup;
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
});
|
|
36
|
+
connectAll(endpoints: McpEndpointConfig[]): Promise<void>;
|
|
37
|
+
private resolveUrl;
|
|
38
|
+
connect(endpoint: McpEndpointConfig): Promise<void>;
|
|
39
|
+
getAllToolDefinitions(): AgentToolDefinition[];
|
|
40
|
+
/**
|
|
41
|
+
* Whether the named MCP server may receive workspace-scoped auth headers
|
|
42
|
+
* (e.g., an OBO bearer token from an end-user request). Callers should gate
|
|
43
|
+
* auth-forwarding decisions on this to prevent credential exfiltration to
|
|
44
|
+
* non-workspace hosts.
|
|
45
|
+
*/
|
|
46
|
+
canForwardWorkspaceAuth(serverName: string): boolean;
|
|
47
|
+
callTool(qualifiedName: string, args: unknown, authHeaders?: Record<string, string>, callerSignal?: AbortSignal): Promise<string>;
|
|
48
|
+
close(): Promise<void>;
|
|
49
|
+
private sendRpc;
|
|
50
|
+
private sendNotification;
|
|
51
|
+
/**
|
|
52
|
+
* Return the auth headers to send on an outbound request. Workspace auth
|
|
53
|
+
* (SP or OBO) is only resolved when `forwardWorkspaceAuth` is true; for
|
|
54
|
+
* non-workspace hosts no bearer token is attached.
|
|
55
|
+
*/
|
|
56
|
+
private resolveAuthHeaders;
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { AppKitMcpClient };
|
|
60
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","names":[],"sources":["../../../src/connectors/mcp/client.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;cAyFa,eAAA;EAAA,QAOD,aAAA;EAAA,QACA,YAAA;EAAA,QACA,MAAA;EAAA,QACA,OAAA;EAAA,QATF,WAAA;EAAA,QACA,UAAA;EAAA,QACA,SAAA;EAAA,QACA,MAAA;cAGE,aAAA,UACA,YAAA,QAAoB,OAAA,CAAQ,MAAA,mBAC5B,MAAA,EAAQ,aAAA,EACR,OAAA;IAAW,SAAA,GAAY,SAAA;IAAW,SAAA,UAAmB,KAAA;EAAA;EAGzD,UAAA,CAAW,SAAA,EAAW,iBAAA,KAAsB,OAAA;EAAA,QAe1C,UAAA;EAUF,OAAA,CAAQ,QAAA,EAAU,iBAAA,GAAoB,OAAA;EAqE5C,qBAAA,CAAA,GAAyB,mBAAA;EA8BvB;;;;;;EANF,uBAAA,CAAwB,UAAA;EAIlB,QAAA,CACJ,aAAA,UACA,IAAA,WACA,WAAA,GAAc,MAAA,kBACd,YAAA,GAAe,WAAA,GACd,OAAA;EAgDG,KAAA,CAAA,GAAS,OAAA;EAAA,QAMD,OAAA;EAAA,QA8EA,gBAAA;EAkCkB;;;;;EAAA,QAAlB,kBAAA;AAAA"}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createLogger } from "../../logging/logger.js";
|
|
2
|
+
import { assertResolvedHostSafe, checkMcpUrl } from "./host-policy.js";
|
|
3
|
+
|
|
4
|
+
//#region src/connectors/mcp/client.ts
|
|
5
|
+
const logger = createLogger("connector:mcp");
|
|
6
|
+
/**
|
|
7
|
+
* Lightweight MCP client for Databricks-hosted MCP servers.
|
|
8
|
+
*
|
|
9
|
+
* Uses raw fetch() with JSON-RPC 2.0 over HTTP — no @modelcontextprotocol/sdk
|
|
10
|
+
* or LangChain dependency. Supports the Streamable HTTP transport only
|
|
11
|
+
* (POST with JSON-RPC request, single JSON-RPC response). Implements exactly
|
|
12
|
+
* four methods: `initialize`, `notifications/initialized`, `tools/list`,
|
|
13
|
+
* `tools/call`. No prompts/resources/completion/sampling.
|
|
14
|
+
*
|
|
15
|
+
* All outbound URLs are gated by an {@link McpHostPolicy}: unallowlisted hosts
|
|
16
|
+
* are rejected before the first byte is sent, and workspace credentials are
|
|
17
|
+
* only forwarded to the same-origin workspace. See `mcp-host-policy.ts`.
|
|
18
|
+
*
|
|
19
|
+
* Rationale for hand-rolling JSON-RPC instead of `@modelcontextprotocol/sdk`:
|
|
20
|
+
* see the file-level comment at the top of this module.
|
|
21
|
+
*/
|
|
22
|
+
var AppKitMcpClient = class {
|
|
23
|
+
connections = /* @__PURE__ */ new Map();
|
|
24
|
+
sessionIds = /* @__PURE__ */ new Map();
|
|
25
|
+
requestId = 0;
|
|
26
|
+
closed = false;
|
|
27
|
+
constructor(workspaceHost, authenticate, policy, options = {}) {
|
|
28
|
+
this.workspaceHost = workspaceHost;
|
|
29
|
+
this.authenticate = authenticate;
|
|
30
|
+
this.policy = policy;
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
async connectAll(endpoints) {
|
|
34
|
+
const results = await Promise.allSettled(endpoints.map((ep) => this.connect(ep)));
|
|
35
|
+
for (let i = 0; i < results.length; i++) if (results[i].status === "rejected") logger.error("Failed to connect MCP server %s: %O", endpoints[i].name, results[i].reason);
|
|
36
|
+
}
|
|
37
|
+
resolveUrl(endpoint) {
|
|
38
|
+
if (endpoint.url.startsWith("http://") || endpoint.url.startsWith("https://")) return endpoint.url;
|
|
39
|
+
return `${this.workspaceHost}${endpoint.url}`;
|
|
40
|
+
}
|
|
41
|
+
async connect(endpoint) {
|
|
42
|
+
const resolvedUrl = this.resolveUrl(endpoint);
|
|
43
|
+
const check = checkMcpUrl(resolvedUrl, this.policy);
|
|
44
|
+
if (!check.ok) throw new Error(`MCP endpoint '${endpoint.name}' refused at connect: ${check.reason}`);
|
|
45
|
+
await assertResolvedHostSafe(check.url.hostname, this.policy, this.options.dnsLookup);
|
|
46
|
+
logger.info("Connecting to MCP server: %s at %s (forwardWorkspaceAuth=%s)", endpoint.name, resolvedUrl, check.forwardWorkspaceAuth);
|
|
47
|
+
const initResponse = await this.sendRpc(resolvedUrl, "initialize", {
|
|
48
|
+
protocolVersion: "2025-03-26",
|
|
49
|
+
capabilities: {},
|
|
50
|
+
clientInfo: {
|
|
51
|
+
name: "appkit-agent",
|
|
52
|
+
version: "0.1.0"
|
|
53
|
+
}
|
|
54
|
+
}, { forwardWorkspaceAuth: check.forwardWorkspaceAuth });
|
|
55
|
+
if (initResponse.sessionId) this.sessionIds.set(endpoint.name, initResponse.sessionId);
|
|
56
|
+
const sessionId = this.sessionIds.get(endpoint.name);
|
|
57
|
+
await this.sendNotification(resolvedUrl, "notifications/initialized", {
|
|
58
|
+
sessionId,
|
|
59
|
+
forwardWorkspaceAuth: check.forwardWorkspaceAuth
|
|
60
|
+
});
|
|
61
|
+
const toolList = (await this.sendRpc(resolvedUrl, "tools/list", {}, {
|
|
62
|
+
sessionId,
|
|
63
|
+
forwardWorkspaceAuth: check.forwardWorkspaceAuth
|
|
64
|
+
})).result?.tools ?? [];
|
|
65
|
+
const tools = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const tool of toolList) tools.set(tool.name, tool);
|
|
67
|
+
this.connections.set(endpoint.name, {
|
|
68
|
+
config: endpoint,
|
|
69
|
+
resolvedUrl,
|
|
70
|
+
forwardWorkspaceAuth: check.forwardWorkspaceAuth,
|
|
71
|
+
tools
|
|
72
|
+
});
|
|
73
|
+
logger.info("Connected to MCP server %s: %d tools available", endpoint.name, tools.size);
|
|
74
|
+
}
|
|
75
|
+
getAllToolDefinitions() {
|
|
76
|
+
const defs = [];
|
|
77
|
+
for (const [serverName, conn] of this.connections) for (const [toolName, schema] of conn.tools) defs.push({
|
|
78
|
+
name: `mcp.${serverName}.${toolName}`,
|
|
79
|
+
description: schema.description ?? toolName,
|
|
80
|
+
parameters: schema.inputSchema ?? {
|
|
81
|
+
type: "object",
|
|
82
|
+
properties: {}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return defs;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether the named MCP server may receive workspace-scoped auth headers
|
|
89
|
+
* (e.g., an OBO bearer token from an end-user request). Callers should gate
|
|
90
|
+
* auth-forwarding decisions on this to prevent credential exfiltration to
|
|
91
|
+
* non-workspace hosts.
|
|
92
|
+
*/
|
|
93
|
+
canForwardWorkspaceAuth(serverName) {
|
|
94
|
+
return this.connections.get(serverName)?.forwardWorkspaceAuth ?? false;
|
|
95
|
+
}
|
|
96
|
+
async callTool(qualifiedName, args, authHeaders, callerSignal) {
|
|
97
|
+
const parts = qualifiedName.split(".");
|
|
98
|
+
if (parts.length < 3 || parts[0] !== "mcp") throw new Error(`Invalid MCP tool name: ${qualifiedName}`);
|
|
99
|
+
const serverName = parts[1];
|
|
100
|
+
const toolName = parts.slice(2).join(".");
|
|
101
|
+
const conn = this.connections.get(serverName);
|
|
102
|
+
if (!conn) throw new Error(`MCP server not connected: ${serverName}`);
|
|
103
|
+
const sessionId = this.sessionIds.get(serverName);
|
|
104
|
+
const scopedAuthOverride = conn.forwardWorkspaceAuth ? authHeaders : void 0;
|
|
105
|
+
const result = (await this.sendRpc(conn.resolvedUrl, "tools/call", {
|
|
106
|
+
name: toolName,
|
|
107
|
+
arguments: args
|
|
108
|
+
}, {
|
|
109
|
+
authOverride: scopedAuthOverride,
|
|
110
|
+
sessionId,
|
|
111
|
+
forwardWorkspaceAuth: conn.forwardWorkspaceAuth,
|
|
112
|
+
callerSignal
|
|
113
|
+
})).result;
|
|
114
|
+
if (result.isError) {
|
|
115
|
+
const errText = (result.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
116
|
+
throw new Error(errText || "MCP tool call failed");
|
|
117
|
+
}
|
|
118
|
+
return (result.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
119
|
+
}
|
|
120
|
+
async close() {
|
|
121
|
+
this.closed = true;
|
|
122
|
+
this.connections.clear();
|
|
123
|
+
this.sessionIds.clear();
|
|
124
|
+
}
|
|
125
|
+
async sendRpc(url, method, params, options) {
|
|
126
|
+
if (this.closed) throw new Error("MCP client is closed");
|
|
127
|
+
const request = {
|
|
128
|
+
jsonrpc: "2.0",
|
|
129
|
+
id: ++this.requestId,
|
|
130
|
+
method,
|
|
131
|
+
...params && { params }
|
|
132
|
+
};
|
|
133
|
+
const authHeaders = await this.resolveAuthHeaders(options);
|
|
134
|
+
const headers = {
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
Accept: "application/json, text/event-stream",
|
|
137
|
+
...authHeaders
|
|
138
|
+
};
|
|
139
|
+
if (options?.sessionId) headers["Mcp-Session-Id"] = options.sessionId;
|
|
140
|
+
const fetchImpl = this.options.fetchImpl ?? fetch;
|
|
141
|
+
const signals = [AbortSignal.timeout(3e4)];
|
|
142
|
+
if (options?.callerSignal) signals.push(options.callerSignal);
|
|
143
|
+
const response = await fetchImpl(url, {
|
|
144
|
+
method: "POST",
|
|
145
|
+
headers,
|
|
146
|
+
body: JSON.stringify(request),
|
|
147
|
+
signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0]
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) throw new Error(`MCP request to ${method} failed: ${response.status} ${response.statusText}`);
|
|
150
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
151
|
+
let json;
|
|
152
|
+
if (contentType.includes("text/event-stream")) {
|
|
153
|
+
const lastData = (await response.text()).split("\n").filter((line) => line.startsWith("data: ")).map((line) => line.slice(6)).pop();
|
|
154
|
+
if (!lastData) throw new Error(`MCP SSE response for ${method} contained no data`);
|
|
155
|
+
json = JSON.parse(lastData);
|
|
156
|
+
} else json = await response.json();
|
|
157
|
+
if (json.error) throw new Error(`MCP error (${json.error.code}): ${json.error.message}`);
|
|
158
|
+
const sid = response.headers.get("mcp-session-id") ?? void 0;
|
|
159
|
+
return {
|
|
160
|
+
result: json.result,
|
|
161
|
+
sessionId: sid
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
async sendNotification(url, method, options) {
|
|
165
|
+
if (this.closed) return;
|
|
166
|
+
const authHeaders = await this.resolveAuthHeaders(options);
|
|
167
|
+
const headers = {
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
Accept: "application/json, text/event-stream",
|
|
170
|
+
...authHeaders
|
|
171
|
+
};
|
|
172
|
+
if (options?.sessionId) headers["Mcp-Session-Id"] = options.sessionId;
|
|
173
|
+
await (this.options.fetchImpl ?? fetch)(url, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers,
|
|
176
|
+
body: JSON.stringify({
|
|
177
|
+
jsonrpc: "2.0",
|
|
178
|
+
method
|
|
179
|
+
}),
|
|
180
|
+
signal: AbortSignal.timeout(3e4)
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Return the auth headers to send on an outbound request. Workspace auth
|
|
185
|
+
* (SP or OBO) is only resolved when `forwardWorkspaceAuth` is true; for
|
|
186
|
+
* non-workspace hosts no bearer token is attached.
|
|
187
|
+
*/
|
|
188
|
+
async resolveAuthHeaders(options) {
|
|
189
|
+
if (!options?.forwardWorkspaceAuth) return {};
|
|
190
|
+
if (options.authOverride) return options.authOverride;
|
|
191
|
+
return this.authenticate();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
//#endregion
|
|
196
|
+
export { AppKitMcpClient };
|
|
197
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../../../src/connectors/mcp/client.ts"],"sourcesContent":["/**\n * Custom MCP over HTTP (Streamable) — not `@modelcontextprotocol/sdk`\n *\n * This module implements a tiny JSON-RPC 2.0 client on `fetch` for the subset\n * of MCP we need: `initialize`, `notifications/initialized`, `tools/list`,\n * `tools/call` over a single JSON request/response. We do not use the official\n * SDK because:\n *\n * - **Policy and auth are the product** — every outbound URL is checked with\n * {@link McpHostPolicy} (allowlist, DNS, private/blocked IP ranges) before\n * the first byte is sent, and workspace tokens are only forwarded when\n * `forwardWorkspaceAuth` is true for that destination. A generic transport\n * from the SDK would still need the same hooks; re-wrapping it would be\n * about as much code, with a larger third-party surface to audit.\n * - **Narrow scope** — we only target Databricks-hosted MCP over Streamable\n * HTTP, not stdio, full SSE sessions, or the rest of the protocol. A\n * hand-rolled path keeps the call graph obvious in code review.\n * - **Zero extra runtime dependency** for this path, consistent with other\n * small, security-sensitive AppKit pieces.\n *\n * Revisit if we add more transports, or if the SDK ships a first-class way to\n * inject our host policy and per-URL auth without fighting the default\n * transport.\n */\nimport type { AgentToolDefinition } from \"shared\";\nimport { createLogger } from \"../../logging/logger\";\nimport {\n assertResolvedHostSafe,\n checkMcpUrl,\n type DnsLookup,\n type McpHostPolicy,\n} from \"./host-policy\";\nimport type { McpEndpointConfig } from \"./types\";\n\nconst logger = createLogger(\"connector:mcp\");\n\ninterface JsonRpcRequest {\n jsonrpc: \"2.0\";\n id: number;\n method: string;\n params?: Record<string, unknown>;\n}\n\ninterface JsonRpcResponse {\n jsonrpc: \"2.0\";\n id: number;\n result?: unknown;\n error?: { code: number; message: string; data?: unknown };\n}\n\ninterface McpToolSchema {\n name: string;\n description?: string;\n inputSchema?: Record<string, unknown>;\n}\n\ninterface McpToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface McpServerConnection {\n config: McpEndpointConfig;\n resolvedUrl: string;\n /**\n * Whether workspace auth (SP / OBO) may be forwarded to this endpoint's URL.\n * Decided at `connect()` time via {@link McpHostPolicy} and cached for the\n * lifetime of the connection.\n */\n forwardWorkspaceAuth: boolean;\n tools: Map<string, McpToolSchema>;\n}\n\n/**\n * Lightweight MCP client for Databricks-hosted MCP servers.\n *\n * Uses raw fetch() with JSON-RPC 2.0 over HTTP — no @modelcontextprotocol/sdk\n * or LangChain dependency. Supports the Streamable HTTP transport only\n * (POST with JSON-RPC request, single JSON-RPC response). Implements exactly\n * four methods: `initialize`, `notifications/initialized`, `tools/list`,\n * `tools/call`. No prompts/resources/completion/sampling.\n *\n * All outbound URLs are gated by an {@link McpHostPolicy}: unallowlisted hosts\n * are rejected before the first byte is sent, and workspace credentials are\n * only forwarded to the same-origin workspace. See `mcp-host-policy.ts`.\n *\n * Rationale for hand-rolling JSON-RPC instead of `@modelcontextprotocol/sdk`:\n * see the file-level comment at the top of this module.\n */\nexport class AppKitMcpClient {\n private connections = new Map<string, McpServerConnection>();\n private sessionIds = new Map<string, string>();\n private requestId = 0;\n private closed = false;\n\n constructor(\n private workspaceHost: string,\n private authenticate: () => Promise<Record<string, string>>,\n private policy: McpHostPolicy,\n private options: { dnsLookup?: DnsLookup; fetchImpl?: typeof fetch } = {},\n ) {}\n\n async connectAll(endpoints: McpEndpointConfig[]): Promise<void> {\n const results = await Promise.allSettled(\n endpoints.map((ep) => this.connect(ep)),\n );\n for (let i = 0; i < results.length; i++) {\n if (results[i].status === \"rejected\") {\n logger.error(\n \"Failed to connect MCP server %s: %O\",\n endpoints[i].name,\n (results[i] as PromiseRejectedResult).reason,\n );\n }\n }\n }\n\n private resolveUrl(endpoint: McpEndpointConfig): string {\n if (\n endpoint.url.startsWith(\"http://\") ||\n endpoint.url.startsWith(\"https://\")\n ) {\n return endpoint.url;\n }\n return `${this.workspaceHost}${endpoint.url}`;\n }\n\n async connect(endpoint: McpEndpointConfig): Promise<void> {\n const resolvedUrl = this.resolveUrl(endpoint);\n const check = checkMcpUrl(resolvedUrl, this.policy);\n if (!check.ok) {\n throw new Error(\n `MCP endpoint '${endpoint.name}' refused at connect: ${check.reason}`,\n );\n }\n await assertResolvedHostSafe(\n check.url.hostname,\n this.policy,\n this.options.dnsLookup,\n );\n\n logger.info(\n \"Connecting to MCP server: %s at %s (forwardWorkspaceAuth=%s)\",\n endpoint.name,\n resolvedUrl,\n check.forwardWorkspaceAuth,\n );\n\n const initResponse = await this.sendRpc(\n resolvedUrl,\n \"initialize\",\n {\n protocolVersion: \"2025-03-26\",\n capabilities: {},\n clientInfo: { name: \"appkit-agent\", version: \"0.1.0\" },\n },\n { forwardWorkspaceAuth: check.forwardWorkspaceAuth },\n );\n\n if (initResponse.sessionId) {\n this.sessionIds.set(endpoint.name, initResponse.sessionId);\n }\n const sessionId = this.sessionIds.get(endpoint.name);\n\n await this.sendNotification(resolvedUrl, \"notifications/initialized\", {\n sessionId,\n forwardWorkspaceAuth: check.forwardWorkspaceAuth,\n });\n\n const listResponse = await this.sendRpc(\n resolvedUrl,\n \"tools/list\",\n {},\n { sessionId, forwardWorkspaceAuth: check.forwardWorkspaceAuth },\n );\n const toolList =\n (listResponse.result as { tools?: McpToolSchema[] })?.tools ?? [];\n\n const tools = new Map<string, McpToolSchema>();\n for (const tool of toolList) {\n tools.set(tool.name, tool);\n }\n\n this.connections.set(endpoint.name, {\n config: endpoint,\n resolvedUrl,\n forwardWorkspaceAuth: check.forwardWorkspaceAuth,\n tools,\n });\n logger.info(\n \"Connected to MCP server %s: %d tools available\",\n endpoint.name,\n tools.size,\n );\n }\n\n getAllToolDefinitions(): AgentToolDefinition[] {\n const defs: AgentToolDefinition[] = [];\n for (const [serverName, conn] of this.connections) {\n for (const [toolName, schema] of conn.tools) {\n defs.push({\n name: `mcp.${serverName}.${toolName}`,\n description: schema.description ?? toolName,\n parameters:\n (schema.inputSchema as AgentToolDefinition[\"parameters\"]) ?? {\n type: \"object\",\n properties: {},\n },\n });\n }\n }\n return defs;\n }\n\n /**\n * Whether the named MCP server may receive workspace-scoped auth headers\n * (e.g., an OBO bearer token from an end-user request). Callers should gate\n * auth-forwarding decisions on this to prevent credential exfiltration to\n * non-workspace hosts.\n */\n canForwardWorkspaceAuth(serverName: string): boolean {\n return this.connections.get(serverName)?.forwardWorkspaceAuth ?? false;\n }\n\n async callTool(\n qualifiedName: string,\n args: unknown,\n authHeaders?: Record<string, string>,\n callerSignal?: AbortSignal,\n ): Promise<string> {\n const parts = qualifiedName.split(\".\");\n if (parts.length < 3 || parts[0] !== \"mcp\") {\n throw new Error(`Invalid MCP tool name: ${qualifiedName}`);\n }\n const serverName = parts[1];\n const toolName = parts.slice(2).join(\".\");\n\n const conn = this.connections.get(serverName);\n if (!conn) {\n throw new Error(`MCP server not connected: ${serverName}`);\n }\n\n const sessionId = this.sessionIds.get(serverName);\n // authHeaders are caller-supplied credentials (typically the OBO token).\n // Only honor them if the destination URL was admitted with\n // forwardWorkspaceAuth=true at connect time.\n const scopedAuthOverride = conn.forwardWorkspaceAuth\n ? authHeaders\n : undefined;\n\n const rpcResult = await this.sendRpc(\n conn.resolvedUrl,\n \"tools/call\",\n { name: toolName, arguments: args },\n {\n authOverride: scopedAuthOverride,\n sessionId,\n forwardWorkspaceAuth: conn.forwardWorkspaceAuth,\n callerSignal,\n },\n );\n const result = rpcResult.result as McpToolCallResult;\n\n if (result.isError) {\n const errText = (result.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\\n\");\n throw new Error(errText || \"MCP tool call failed\");\n }\n\n return (result.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text)\n .join(\"\\n\");\n }\n\n async close(): Promise<void> {\n this.closed = true;\n this.connections.clear();\n this.sessionIds.clear();\n }\n\n private async sendRpc(\n url: string,\n method: string,\n params?: Record<string, unknown>,\n options?: {\n authOverride?: Record<string, string>;\n sessionId?: string;\n forwardWorkspaceAuth?: boolean;\n /**\n * Optional external abort signal (typically the agent's stream signal).\n * Composed with the built-in 30 s timeout so `/cancel` or agent-run\n * shutdown immediately propagates to the MCP fetch rather than waiting\n * for the remote server to respond.\n */\n callerSignal?: AbortSignal;\n },\n ): Promise<{ result: unknown; sessionId?: string }> {\n if (this.closed) throw new Error(\"MCP client is closed\");\n\n const request: JsonRpcRequest = {\n jsonrpc: \"2.0\",\n id: ++this.requestId,\n method,\n ...(params && { params }),\n };\n\n const authHeaders = await this.resolveAuthHeaders(options);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Accept: \"application/json, text/event-stream\",\n ...authHeaders,\n };\n if (options?.sessionId) {\n headers[\"Mcp-Session-Id\"] = options.sessionId;\n }\n\n const fetchImpl = this.options.fetchImpl ?? fetch;\n const signals: AbortSignal[] = [AbortSignal.timeout(30_000)];\n if (options?.callerSignal) signals.push(options.callerSignal);\n const response = await fetchImpl(url, {\n method: \"POST\",\n headers,\n body: JSON.stringify(request),\n signal: signals.length > 1 ? AbortSignal.any(signals) : signals[0],\n });\n\n if (!response.ok) {\n throw new Error(\n `MCP request to ${method} failed: ${response.status} ${response.statusText}`,\n );\n }\n\n const contentType = response.headers.get(\"content-type\") ?? \"\";\n let json: JsonRpcResponse;\n\n if (contentType.includes(\"text/event-stream\")) {\n const text = await response.text();\n const lastData = text\n .split(\"\\n\")\n .filter((line) => line.startsWith(\"data: \"))\n .map((line) => line.slice(6))\n .pop();\n if (!lastData) {\n throw new Error(`MCP SSE response for ${method} contained no data`);\n }\n json = JSON.parse(lastData) as JsonRpcResponse;\n } else {\n json = (await response.json()) as JsonRpcResponse;\n }\n\n if (json.error) {\n throw new Error(`MCP error (${json.error.code}): ${json.error.message}`);\n }\n\n const sid = response.headers.get(\"mcp-session-id\") ?? undefined;\n return { result: json.result, sessionId: sid };\n }\n\n private async sendNotification(\n url: string,\n method: string,\n options?: {\n sessionId?: string;\n forwardWorkspaceAuth?: boolean;\n },\n ): Promise<void> {\n if (this.closed) return;\n\n const authHeaders = await this.resolveAuthHeaders(options);\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n Accept: \"application/json, text/event-stream\",\n ...authHeaders,\n };\n if (options?.sessionId) {\n headers[\"Mcp-Session-Id\"] = options.sessionId;\n }\n\n const fetchImpl = this.options.fetchImpl ?? fetch;\n await fetchImpl(url, {\n method: \"POST\",\n headers,\n body: JSON.stringify({ jsonrpc: \"2.0\", method }),\n signal: AbortSignal.timeout(30_000),\n });\n }\n\n /**\n * Return the auth headers to send on an outbound request. Workspace auth\n * (SP or OBO) is only resolved when `forwardWorkspaceAuth` is true; for\n * non-workspace hosts no bearer token is attached.\n */\n private async resolveAuthHeaders(options?: {\n authOverride?: Record<string, string>;\n forwardWorkspaceAuth?: boolean;\n }): Promise<Record<string, string>> {\n if (!options?.forwardWorkspaceAuth) return {};\n if (options.authOverride) return options.authOverride;\n return this.authenticate();\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region src/connectors/mcp/host-policy.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* DNS lookup function compatible with `dns/promises.lookup(host, { all: true })`.
|
|
4
|
+
* Exposed as an injection point so callers (tests, custom DNS resolvers) can
|
|
5
|
+
* override the default resolver.
|
|
6
|
+
*/
|
|
7
|
+
type DnsLookup = (hostname: string, options: {
|
|
8
|
+
all: true;
|
|
9
|
+
}) => Promise<Array<{
|
|
10
|
+
address: string;
|
|
11
|
+
family: number;
|
|
12
|
+
}>>;
|
|
13
|
+
/**
|
|
14
|
+
* Policy that decides whether a given MCP endpoint URL is allowed and whether
|
|
15
|
+
* Databricks workspace credentials (SP or OBO) may be forwarded to it.
|
|
16
|
+
*
|
|
17
|
+
* The default posture is zero-trust: only same-origin workspace URLs receive
|
|
18
|
+
* workspace credentials, and all other destinations must be explicitly
|
|
19
|
+
* allowlisted by the application developer. Private / link-local IP ranges
|
|
20
|
+
* are blocked outright to prevent SSRF into cloud metadata services.
|
|
21
|
+
*/
|
|
22
|
+
interface McpHostPolicy {
|
|
23
|
+
/** Lowercased hostname of the Databricks workspace (same-origin target). */
|
|
24
|
+
readonly workspaceHostname: string;
|
|
25
|
+
/** Additional allowlisted hostnames (lowercased). Workspace auth is NEVER forwarded to these. */
|
|
26
|
+
readonly trustedHosts: ReadonlySet<string>;
|
|
27
|
+
/** Permit `http://localhost`, `127.0.0.1`, `::1` URLs. Typically true only in development. */
|
|
28
|
+
readonly allowLocalhost: boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Config shape accepted by {@link buildMcpHostPolicy}, matching the
|
|
32
|
+
* `mcp` field on `AgentsPluginConfig`.
|
|
33
|
+
*/
|
|
34
|
+
interface McpHostPolicyConfig {
|
|
35
|
+
/**
|
|
36
|
+
* Additional hostnames that may host custom MCP servers beyond the same-origin
|
|
37
|
+
* workspace. Compared case-insensitively; bare hostnames only (no scheme or
|
|
38
|
+
* path). Workspace credentials (SP / OBO) are never forwarded to these hosts —
|
|
39
|
+
* they must handle authentication themselves.
|
|
40
|
+
*/
|
|
41
|
+
trustedHosts?: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Allow `http://localhost`, `127.0.0.1`, and `::1` MCP URLs for local
|
|
44
|
+
* development. Defaults to `true` when `NODE_ENV !== "production"`,
|
|
45
|
+
* otherwise `false`. Workspace credentials are never forwarded to localhost.
|
|
46
|
+
*/
|
|
47
|
+
allowLocalhost?: boolean;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { DnsLookup, McpHostPolicy, McpHostPolicyConfig };
|
|
51
|
+
//# sourceMappingURL=host-policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-policy.d.ts","names":[],"sources":["../../../src/connectors/mcp/host-policy.ts"],"mappings":";;AAQA;;;;KAAY,SAAA,IACV,QAAA,UACA,OAAA;EAAW,GAAA;AAAA,MACR,OAAA,CAAQ,KAAA;EAAQ,OAAA;EAAiB,MAAA;AAAA;;;;AAWtC;;;;;;UAAiB,aAAA;EAMN;EAAA,SAJA,iBAAA;EAIc;EAAA,SAFd,YAAA,EAAc,WAAA;EASW;EAAA,SAPzB,cAAA;AAAA;;;;;UAOM,mBAAA;;;;;;;EAOf,YAAA;;;;;;EAMA,cAAA;AAAA"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP, isIPv4 } from "node:net";
|
|
3
|
+
|
|
4
|
+
//#region src/connectors/mcp/host-policy.ts
|
|
5
|
+
/** Build an {@link McpHostPolicy} from user config + the resolved workspace URL. */
|
|
6
|
+
function buildMcpHostPolicy(config, workspaceHost) {
|
|
7
|
+
const workspaceHostname = safeHostname(workspaceHost);
|
|
8
|
+
if (!workspaceHostname) throw new Error(`Invalid workspace host for MCP policy: ${JSON.stringify(workspaceHost)}`);
|
|
9
|
+
return {
|
|
10
|
+
workspaceHostname,
|
|
11
|
+
trustedHosts: new Set((config?.trustedHosts ?? []).map((h) => h.trim().toLowerCase())),
|
|
12
|
+
allowLocalhost: config?.allowLocalhost ?? process.env.NODE_ENV !== "production"
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Synchronously decide whether an MCP URL is allowed under the given policy
|
|
17
|
+
* and whether workspace credentials may be forwarded to it.
|
|
18
|
+
*
|
|
19
|
+
* Hard rejections:
|
|
20
|
+
* - Non-`http(s)` schemes.
|
|
21
|
+
* - `http://` unless the host is localhost AND `allowLocalhost` is true.
|
|
22
|
+
* - Hosts that are neither same-origin workspace, localhost (if allowed),
|
|
23
|
+
* nor in `trustedHosts`.
|
|
24
|
+
*/
|
|
25
|
+
function checkMcpUrl(rawUrl, policy) {
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(rawUrl);
|
|
29
|
+
} catch {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: `MCP URL is not a valid absolute URL: ${rawUrl}`
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return {
|
|
36
|
+
ok: false,
|
|
37
|
+
reason: `MCP URL scheme '${url.protocol}' is not allowed (http(s) only): ${rawUrl}`
|
|
38
|
+
};
|
|
39
|
+
const host = url.hostname.toLowerCase();
|
|
40
|
+
const isLoopback = isLoopbackHost(host);
|
|
41
|
+
if (url.protocol === "http:" && !(isLoopback && policy.allowLocalhost)) return {
|
|
42
|
+
ok: false,
|
|
43
|
+
reason: `MCP URL uses plaintext http:// which forwards bearer tokens in cleartext: ${rawUrl}. Use https:// or enable allowLocalhost for a localhost dev server.`
|
|
44
|
+
};
|
|
45
|
+
if (host === policy.workspaceHostname) return {
|
|
46
|
+
ok: true,
|
|
47
|
+
forwardWorkspaceAuth: true,
|
|
48
|
+
url
|
|
49
|
+
};
|
|
50
|
+
if (isLoopback) {
|
|
51
|
+
if (!policy.allowLocalhost) return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: `MCP URL points to localhost but allowLocalhost is disabled: ${rawUrl}`
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
forwardWorkspaceAuth: false,
|
|
58
|
+
url
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (policy.trustedHosts.has(host)) return {
|
|
62
|
+
ok: true,
|
|
63
|
+
forwardWorkspaceAuth: false,
|
|
64
|
+
url
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: `MCP host '${host}' is not allowed. Either use a same-origin workspace URL (${policy.workspaceHostname}) or add it to agents({ mcp: { trustedHosts: ['${host}'] } }).`
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Resolve `hostname` via DNS and assert that none of its addresses fall in a
|
|
73
|
+
* blocked IP range (loopback, RFC1918, link-local, CGNAT, cloud metadata).
|
|
74
|
+
*
|
|
75
|
+
* Throws with a descriptive error if any resolved address is blocked. Pass
|
|
76
|
+
* `allowLocalhost: true` to permit `127.0.0.1` / `::1` specifically.
|
|
77
|
+
*
|
|
78
|
+
* Note: this only guards against hosts that statically resolve to private
|
|
79
|
+
* ranges. Full SSRF protection requires socket-level IP pinning after
|
|
80
|
+
* resolution (DNS rebinding defense), which is out of scope here.
|
|
81
|
+
*/
|
|
82
|
+
async function assertResolvedHostSafe(hostname, policy, lookup$1 = lookup) {
|
|
83
|
+
const lowered = hostname.toLowerCase();
|
|
84
|
+
if (isIP(lowered)) {
|
|
85
|
+
if (isBlockedIp(lowered, policy.allowLocalhost)) throw new Error(`MCP host ${lowered} is in a blocked IP range`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (lowered === "localhost") {
|
|
89
|
+
if (!policy.allowLocalhost) throw new Error(`MCP host localhost is not allowed under the current policy`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let resolved;
|
|
93
|
+
try {
|
|
94
|
+
resolved = await lookup$1(hostname, { all: true });
|
|
95
|
+
} catch (cause) {
|
|
96
|
+
throw new Error(`MCP host ${hostname} could not be resolved via DNS: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
97
|
+
}
|
|
98
|
+
if (resolved.length === 0) throw new Error(`MCP host ${hostname} returned no DNS addresses`);
|
|
99
|
+
for (const { address } of resolved) if (isBlockedIp(address, policy.allowLocalhost)) throw new Error(`MCP host ${hostname} resolved to blocked address ${address} (private / link-local ranges are not allowed)`);
|
|
100
|
+
}
|
|
101
|
+
/** Whether a raw hostname literal is one of the recognised loopback aliases. */
|
|
102
|
+
function isLoopbackHost(host) {
|
|
103
|
+
const lowered = host.toLowerCase();
|
|
104
|
+
return lowered === "localhost" || lowered === "127.0.0.1" || lowered === "::1" || lowered === "[::1]" || lowered === "0:0:0:0:0:0:0:1";
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Check whether a resolved IP address is in a range that should never receive
|
|
108
|
+
* workspace credentials. `allowLocalhost` carves out 127.0.0.0/8 and ::1.
|
|
109
|
+
*/
|
|
110
|
+
function isBlockedIp(address, allowLocalhost) {
|
|
111
|
+
if (isIPv4(address)) return isBlockedIpv4(address, allowLocalhost);
|
|
112
|
+
if (isIP(address) === 6) return isBlockedIpv6(address, allowLocalhost);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
function isBlockedIpv4(addr, allowLocalhost) {
|
|
116
|
+
const parts = addr.split(".").map((p) => Number.parseInt(p, 10));
|
|
117
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return true;
|
|
118
|
+
const [a, b] = parts;
|
|
119
|
+
if (a === 0) return true;
|
|
120
|
+
if (a === 127) return !allowLocalhost;
|
|
121
|
+
if (a === 10) return true;
|
|
122
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
123
|
+
if (a === 192 && b === 168) return true;
|
|
124
|
+
if (a === 169 && b === 254) return true;
|
|
125
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
126
|
+
if (a >= 224) return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
function isBlockedIpv6(addr, allowLocalhost) {
|
|
130
|
+
const lowered = addr.toLowerCase().replace(/^\[|\]$/g, "");
|
|
131
|
+
if (lowered === "::") return true;
|
|
132
|
+
if (lowered === "::1" || lowered === "0:0:0:0:0:0:0:1") return !allowLocalhost;
|
|
133
|
+
if (lowered.startsWith("::ffff:")) {
|
|
134
|
+
const tail = lowered.slice(7);
|
|
135
|
+
if (isIPv4(tail)) return isBlockedIpv4(tail, allowLocalhost);
|
|
136
|
+
const hexV4 = hexPairToDottedIpv4(tail);
|
|
137
|
+
if (hexV4) return isBlockedIpv4(hexV4, allowLocalhost);
|
|
138
|
+
}
|
|
139
|
+
if (/^f[cd][0-9a-f]{2}:/.test(lowered)) return true;
|
|
140
|
+
if (/^fe[89ab][0-9a-f]:/.test(lowered)) return true;
|
|
141
|
+
if (lowered.startsWith("ff")) return true;
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse the trailing two hex groups of an IPv4-mapped IPv6 address written
|
|
146
|
+
* in colon-hex form (e.g. `a9fe:a9fe`) into the equivalent dotted-quad IPv4
|
|
147
|
+
* representation (`169.254.169.254`). Returns null for anything else.
|
|
148
|
+
*/
|
|
149
|
+
function hexPairToDottedIpv4(tail) {
|
|
150
|
+
const match = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
151
|
+
if (!match) return null;
|
|
152
|
+
const hi = Number.parseInt(match[1], 16);
|
|
153
|
+
const lo = Number.parseInt(match[2], 16);
|
|
154
|
+
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return null;
|
|
155
|
+
if (hi < 0 || hi > 65535 || lo < 0 || lo > 65535) return null;
|
|
156
|
+
return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
|
|
157
|
+
}
|
|
158
|
+
function safeHostname(rawUrl) {
|
|
159
|
+
try {
|
|
160
|
+
return new URL(rawUrl).hostname.toLowerCase();
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
//#endregion
|
|
167
|
+
export { assertResolvedHostSafe, buildMcpHostPolicy, checkMcpUrl };
|
|
168
|
+
//# sourceMappingURL=host-policy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"host-policy.js","names":["defaultLookup","lookup"],"sources":["../../../src/connectors/mcp/host-policy.ts"],"sourcesContent":["import { lookup as defaultLookup } from \"node:dns/promises\";\nimport { isIP, isIPv4 } from \"node:net\";\n\n/**\n * DNS lookup function compatible with `dns/promises.lookup(host, { all: true })`.\n * Exposed as an injection point so callers (tests, custom DNS resolvers) can\n * override the default resolver.\n */\nexport type DnsLookup = (\n hostname: string,\n options: { all: true },\n) => Promise<Array<{ address: string; family: number }>>;\n\n/**\n * Policy that decides whether a given MCP endpoint URL is allowed and whether\n * Databricks workspace credentials (SP or OBO) may be forwarded to it.\n *\n * The default posture is zero-trust: only same-origin workspace URLs receive\n * workspace credentials, and all other destinations must be explicitly\n * allowlisted by the application developer. Private / link-local IP ranges\n * are blocked outright to prevent SSRF into cloud metadata services.\n */\nexport interface McpHostPolicy {\n /** Lowercased hostname of the Databricks workspace (same-origin target). */\n readonly workspaceHostname: string;\n /** Additional allowlisted hostnames (lowercased). Workspace auth is NEVER forwarded to these. */\n readonly trustedHosts: ReadonlySet<string>;\n /** Permit `http://localhost`, `127.0.0.1`, `::1` URLs. Typically true only in development. */\n readonly allowLocalhost: boolean;\n}\n\n/**\n * Config shape accepted by {@link buildMcpHostPolicy}, matching the\n * `mcp` field on `AgentsPluginConfig`.\n */\nexport interface McpHostPolicyConfig {\n /**\n * Additional hostnames that may host custom MCP servers beyond the same-origin\n * workspace. Compared case-insensitively; bare hostnames only (no scheme or\n * path). Workspace credentials (SP / OBO) are never forwarded to these hosts —\n * they must handle authentication themselves.\n */\n trustedHosts?: string[];\n /**\n * Allow `http://localhost`, `127.0.0.1`, and `::1` MCP URLs for local\n * development. Defaults to `true` when `NODE_ENV !== \"production\"`,\n * otherwise `false`. Workspace credentials are never forwarded to localhost.\n */\n allowLocalhost?: boolean;\n}\n\n/** Build an {@link McpHostPolicy} from user config + the resolved workspace URL. */\nexport function buildMcpHostPolicy(\n config: McpHostPolicyConfig | undefined,\n workspaceHost: string,\n): McpHostPolicy {\n const workspaceHostname = safeHostname(workspaceHost);\n if (!workspaceHostname) {\n throw new Error(\n `Invalid workspace host for MCP policy: ${JSON.stringify(workspaceHost)}`,\n );\n }\n const trustedHosts = new Set(\n (config?.trustedHosts ?? []).map((h) => h.trim().toLowerCase()),\n );\n const allowLocalhost =\n config?.allowLocalhost ?? process.env.NODE_ENV !== \"production\";\n return { workspaceHostname, trustedHosts, allowLocalhost };\n}\n\ntype McpUrlCheck =\n | {\n readonly ok: true;\n /** Whether it is safe to forward workspace-scoped credentials (SP/OBO) to this URL. */\n readonly forwardWorkspaceAuth: boolean;\n /** Parsed URL for reuse by the caller. */\n readonly url: URL;\n }\n | { readonly ok: false; readonly reason: string };\n\n/**\n * Synchronously decide whether an MCP URL is allowed under the given policy\n * and whether workspace credentials may be forwarded to it.\n *\n * Hard rejections:\n * - Non-`http(s)` schemes.\n * - `http://` unless the host is localhost AND `allowLocalhost` is true.\n * - Hosts that are neither same-origin workspace, localhost (if allowed),\n * nor in `trustedHosts`.\n */\nexport function checkMcpUrl(\n rawUrl: string,\n policy: McpHostPolicy,\n): McpUrlCheck {\n let url: URL;\n try {\n url = new URL(rawUrl);\n } catch {\n return {\n ok: false,\n reason: `MCP URL is not a valid absolute URL: ${rawUrl}`,\n };\n }\n\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") {\n return {\n ok: false,\n reason: `MCP URL scheme '${url.protocol}' is not allowed (http(s) only): ${rawUrl}`,\n };\n }\n\n const host = url.hostname.toLowerCase();\n const isLoopback = isLoopbackHost(host);\n\n if (url.protocol === \"http:\" && !(isLoopback && policy.allowLocalhost)) {\n return {\n ok: false,\n reason: `MCP URL uses plaintext http:// which forwards bearer tokens in cleartext: ${rawUrl}. Use https:// or enable allowLocalhost for a localhost dev server.`,\n };\n }\n\n if (host === policy.workspaceHostname) {\n return { ok: true, forwardWorkspaceAuth: true, url };\n }\n\n if (isLoopback) {\n if (!policy.allowLocalhost) {\n return {\n ok: false,\n reason: `MCP URL points to localhost but allowLocalhost is disabled: ${rawUrl}`,\n };\n }\n return { ok: true, forwardWorkspaceAuth: false, url };\n }\n\n if (policy.trustedHosts.has(host)) {\n return { ok: true, forwardWorkspaceAuth: false, url };\n }\n\n return {\n ok: false,\n reason: `MCP host '${host}' is not allowed. Either use a same-origin workspace URL (${policy.workspaceHostname}) or add it to agents({ mcp: { trustedHosts: ['${host}'] } }).`,\n };\n}\n\n/**\n * Resolve `hostname` via DNS and assert that none of its addresses fall in a\n * blocked IP range (loopback, RFC1918, link-local, CGNAT, cloud metadata).\n *\n * Throws with a descriptive error if any resolved address is blocked. Pass\n * `allowLocalhost: true` to permit `127.0.0.1` / `::1` specifically.\n *\n * Note: this only guards against hosts that statically resolve to private\n * ranges. Full SSRF protection requires socket-level IP pinning after\n * resolution (DNS rebinding defense), which is out of scope here.\n */\nexport async function assertResolvedHostSafe(\n hostname: string,\n policy: McpHostPolicy,\n lookup: DnsLookup = defaultLookup,\n): Promise<void> {\n const lowered = hostname.toLowerCase();\n\n if (isIP(lowered)) {\n if (isBlockedIp(lowered, policy.allowLocalhost)) {\n throw new Error(`MCP host ${lowered} is in a blocked IP range`);\n }\n return;\n }\n\n if (lowered === \"localhost\") {\n if (!policy.allowLocalhost) {\n throw new Error(\n `MCP host localhost is not allowed under the current policy`,\n );\n }\n return;\n }\n\n let resolved: Array<{ address: string }>;\n try {\n resolved = await lookup(hostname, { all: true });\n } catch (cause) {\n throw new Error(\n `MCP host ${hostname} could not be resolved via DNS: ${cause instanceof Error ? cause.message : String(cause)}`,\n );\n }\n\n if (resolved.length === 0) {\n throw new Error(`MCP host ${hostname} returned no DNS addresses`);\n }\n\n for (const { address } of resolved) {\n if (isBlockedIp(address, policy.allowLocalhost)) {\n throw new Error(\n `MCP host ${hostname} resolved to blocked address ${address} (private / link-local ranges are not allowed)`,\n );\n }\n }\n}\n\n/** Whether a raw hostname literal is one of the recognised loopback aliases. */\nexport function isLoopbackHost(host: string): boolean {\n const lowered = host.toLowerCase();\n return (\n lowered === \"localhost\" ||\n lowered === \"127.0.0.1\" ||\n lowered === \"::1\" ||\n lowered === \"[::1]\" ||\n lowered === \"0:0:0:0:0:0:0:1\"\n );\n}\n\n/**\n * Check whether a resolved IP address is in a range that should never receive\n * workspace credentials. `allowLocalhost` carves out 127.0.0.0/8 and ::1.\n */\nexport function isBlockedIp(address: string, allowLocalhost: boolean): boolean {\n if (isIPv4(address)) {\n return isBlockedIpv4(address, allowLocalhost);\n }\n if (isIP(address) === 6) {\n return isBlockedIpv6(address, allowLocalhost);\n }\n // Not a recognisable IP literal — fail-closed.\n return true;\n}\n\nfunction isBlockedIpv4(addr: string, allowLocalhost: boolean): boolean {\n const parts = addr.split(\".\").map((p) => Number.parseInt(p, 10));\n if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {\n return true;\n }\n const [a, b] = parts;\n if (a === 0) return true;\n if (a === 127) return !allowLocalhost;\n if (a === 10) return true;\n if (a === 172 && b >= 16 && b <= 31) return true;\n if (a === 192 && b === 168) return true;\n if (a === 169 && b === 254) return true;\n if (a === 100 && b >= 64 && b <= 127) return true;\n if (a >= 224) return true;\n return false;\n}\n\nfunction isBlockedIpv6(addr: string, allowLocalhost: boolean): boolean {\n const lowered = addr.toLowerCase().replace(/^\\[|\\]$/g, \"\");\n\n if (lowered === \"::\") return true;\n if (lowered === \"::1\" || lowered === \"0:0:0:0:0:0:0:1\")\n return !allowLocalhost;\n\n // IPv4-mapped IPv6: `::ffff:<v4>` may be written in dotted form\n // (`::ffff:169.254.169.254`) or colon-hex form (`::ffff:a9fe:a9fe`). Both\n // route to the same destination, so we must normalise before delegating\n // to the IPv4 blocklist.\n if (lowered.startsWith(\"::ffff:\")) {\n const tail = lowered.slice(\"::ffff:\".length);\n if (isIPv4(tail)) return isBlockedIpv4(tail, allowLocalhost);\n const hexV4 = hexPairToDottedIpv4(tail);\n if (hexV4) return isBlockedIpv4(hexV4, allowLocalhost);\n }\n\n // Unique Local Addresses (fc00::/7) — `fc` and `fd` only.\n if (/^f[cd][0-9a-f]{2}:/.test(lowered)) return true;\n // Link-local fe80::/10 — the first 10 bits are 1111111010, i.e. the\n // second hex nibble must be 8-b. Matches fe80:..–febf:..\n if (/^fe[89ab][0-9a-f]:/.test(lowered)) return true;\n // Multicast ff00::/8.\n if (lowered.startsWith(\"ff\")) return true;\n return false;\n}\n\n/**\n * Parse the trailing two hex groups of an IPv4-mapped IPv6 address written\n * in colon-hex form (e.g. `a9fe:a9fe`) into the equivalent dotted-quad IPv4\n * representation (`169.254.169.254`). Returns null for anything else.\n */\nfunction hexPairToDottedIpv4(tail: string): string | null {\n const match = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);\n if (!match) return null;\n const hi = Number.parseInt(match[1], 16);\n const lo = Number.parseInt(match[2], 16);\n if (!Number.isFinite(hi) || !Number.isFinite(lo)) return null;\n if (hi < 0 || hi > 0xffff || lo < 0 || lo > 0xffff) return null;\n const a = (hi >> 8) & 0xff;\n const b = hi & 0xff;\n const c = (lo >> 8) & 0xff;\n const d = lo & 0xff;\n return `${a}.${b}.${c}.${d}`;\n}\n\nfunction safeHostname(rawUrl: string): string | null {\n try {\n return new URL(rawUrl).hostname.toLowerCase();\n } catch {\n return null;\n }\n}\n"],"mappings":"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"}
|