import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createMCPServerForRequest } from "@agent-native/core/mcp"; import { loadActionsFromStaticRegistry } from "@agent-native/core/server"; import { generateActionRegistryForProject } from "@agent-native/core/vite"; import { describe, expect, it } from "vitest"; import { ANALYTICS_MCP } from "../server/lib/analytics-mcp"; const projectRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "..", ); generateActionRegistryForProject(projectRoot); const { default: actionsRegistry } = await import( `${pathToFileURL(path.join(projectRoot, ".generated/actions-registry.ts")).href}?cacheBust=${Date.now()}` ); type ActionDefinition = { agentTool?: boolean; mcpTool?: boolean; readOnly?: boolean; needsApproval?: unknown; publicAgent?: { expose?: boolean }; tool: { description: string; parameters?: { properties?: Record }; }; }; const actions: Record = Object.fromEntries( Object.entries(actionsRegistry as Record) .filter(([, mod]) => mod?.default && typeof mod.default === "object") .map(([name, mod]) => [name, mod.default as ActionDefinition]), ); const DIRECT_MCP_READS = [ "open-traffic-dashboard", "account-deep-dive", "builder-blog-articles", "data-source-status", "estimate-dashboard-cost", "estimate-onboarding-journey-cost", "estimate-query-cost", "find-data", "query-dbt-semantic-metric", "get-analysis", "get-data-program", "get-error-issue", "get-first-party-analytics-health", "get-onboarding-journey", "get-session-replay-summary", "get-session-replay-timeline", "get-sql-dashboard", "gong-calls", "gong-native-insights", "hubspot-deals", "hubspot-metrics", "hubspot-pipelines", "hubspot-records", "list-analyses", "list-dashboard-folders", "list-data-dictionary", "list-data-programs", "list-error-issues", "list-session-recordings", "list-sql-dashboards", "match-error-issues", "search-analytics-query-catalog", "search-dashboard-references", ]; const DIRECT_MCP_WRITES = [ "run-gong-native-insight", "update-analytics-public-key", ]; // The declaration is preserved, but the shared SQL-input boundary requires // publicAgent.allowRawQueryInput before this action can be served. const SERVED_DIRECT_MCP_READS = DIRECT_MCP_READS.filter( (name) => name !== "estimate-query-cost", ); // Raw SQL and capability minting stay off every MCP catalog, including the // explicit full-catalog opt-in, which otherwise serves every undeclared action. const VETOED = [ "bigquery", "create-session-replay-agent-link", "db-admin-federated-read", "import-data-dictionary-index", "query-agent-native-analytics", "search-bigquery-schema", "list-sigma-workbooks", "inspect-sigma-workbook", "migrate-sigma-workbook", ]; // UI, bulk replay, and unapproved writes stay off the read-scoped catalog. const DEFAULT_CONNECTOR_EXCLUDED = [ "get-session-replay-events", "navigate", "save-analysis", "update-dashboard", "view-screen", ]; // The MCP SDK is a dependency of core, not of this template. const requireFromCore = createRequire( createRequire(import.meta.url).resolve("@agent-native/core"), ); const { createMcpHandler } = await import( pathToFileURL(requireFromCore.resolve("@modelcontextprotocol/server")).href ); async function listAppTools( oauthScopes: string[], requestMeta: { fullCatalog?: boolean } = {}, ): Promise { const registry = loadActionsFromStaticRegistry(actionsRegistry); const config = { name: "Analytics", appId: "analytics", description: "Agent-Native analytics agent", actions: registry, productionActions: registry, ...ANALYTICS_MCP, } as Parameters[0]; const handler = createMcpHandler( () => createMCPServerForRequest( config, { userEmail: "contract@example.com", orgDomain: undefined, orgId: null, oauthScopes, }, requestMeta, ), { legacy: "stateless", responseMode: "auto" }, ); const rpc = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }; const res = await handler.fetch( new Request("https://analytics.example.test/_agent-native/mcp", { method: "POST", headers: { accept: "application/json, text/event-stream", "content-type": "application/json", "mcp-protocol-version": "2025-06-18", }, body: JSON.stringify(rpc), }), { parsedBody: rpc }, ); const text = await res.text(); const payload = text.includes("data:") ? text .split("\n") .find((line: string) => line.startsWith("data:"))! .slice(5) : text; const tools = JSON.parse(payload).result.tools as Array<{ name: string }>; // Framework builtins such as list_apps and ask_app are not Analytics actions. return tools .map((tool) => tool.name) .filter((name) => name in actions) .sort(); } describe("Analytics direct MCP tools", () => { it("pins the actions declared as direct MCP tools", () => { const declared = Object.entries(actions) .filter(([, action]) => action.mcpTool === true) .map(([name]) => name) .sort(); expect(declared).toEqual( [...DIRECT_MCP_READS, ...DIRECT_MCP_WRITES].sort(), ); }); it("vetoes raw SQL and capability minting on every MCP catalog", () => { for (const name of VETOED) { expect(actions[name], name).toBeDefined(); expect(actions[name].mcpTool, name).toBe(false); } }); it("keeps writes, UI, and bulk replay actions off the default connector", () => { for (const name of DEFAULT_CONNECTOR_EXCLUDED) { expect(actions[name], name).toBeDefined(); expect(actions[name].mcpTool, name).not.toBe(true); } for (const [name, action] of Object.entries(actions)) { if (/^(seed|ensure|migrate|purge)-/.test(name)) { expect(action.mcpTool, name).not.toBe(true); } } expect(actions["migrate-sigma-workbook"].mcpTool).toBe(false); expect(actions["migrate-sigma-workbook"].readOnly).not.toBe(true); expect(actions["migrate-sigma-workbook"].needsApproval).toBe(true); expect(actions["migrate-sigma-workbook"].publicAgent?.expose).toBe(false); expect(actions["migrate-sigma-workbook"].agentTool).toBe(false); }); it("classifies every direct read as read-only so mcp:read tokens can call it", () => { for (const name of DIRECT_MCP_READS) { expect(actions[name].readOnly, name).toBe(true); } }); it("requires write scope, without a per-call approval, for Gong synthesis", () => { const run = actions["run-gong-native-insight"]; expect(run.readOnly).not.toBe(true); expect(run.needsApproval).toBeUndefined(); const discovery = actions["gong-native-insights"]; expect(discovery.needsApproval).toBeUndefined(); expect(Object.keys(discovery.tool.parameters?.properties ?? {})).toEqual( [], ); }); it("gives every direct tool a description an external agent can route on", () => { const terse = [...DIRECT_MCP_READS, ...DIRECT_MCP_WRITES].filter( (name) => actions[name].tool.description.length <= 80, ); expect(terse).toEqual([]); }); it("names only tools a read-scoped token receives in keyToolNames and instructions", () => { const mentioned = Object.keys(actions).filter((name) => new RegExp(`(? { it("serves only the direct reads to an mcp:read token", async () => { expect(await listAppTools(["mcp:read"])).toEqual( [...SERVED_DIRECT_MCP_READS].sort(), ); }, 60_000); it("adds only allowlisted writes for an mcp:write token", async () => { expect(await listAppTools(["mcp:read", "mcp:write"])).toEqual( [...SERVED_DIRECT_MCP_READS, ...DIRECT_MCP_WRITES].sort(), ); }, 60_000); it("keeps vetoed actions out of the full-catalog opt-in", async () => { const served = await listAppTools(["mcp:read", "mcp:write"], { fullCatalog: true, }); expect(served).toEqual( expect.arrayContaining([ ...SERVED_DIRECT_MCP_READS, ...DIRECT_MCP_WRITES, ]), ); for (const name of [...VETOED, "estimate-query-cost"]) { expect(served, name).not.toContain(name); } }, 60_000); });