diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index 37aa94021..691db5fb7 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -144,6 +144,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - Do not centralize, relocate, or otherwise refactor dashboard E2E API route access gates during cleanup; keep test-only access checks local to each route unless iza explicitly asks for that change. - Integration catalog logos: use filled Simple Icons SVG path data (or equivalent filled brand SVG), store the path on each item as `iconPath`, render it through a shared logo tile with `bg-secondary/60`, `border-border/70`, `text-foreground`, and `fill="currentColor"`, then use brand color only as a small accent bar (`accent` or `accentClassName: "bg-foreground/70"` for black/near-black brands). Avoid raw brand-black icons or mixed line/filled icon sets that disappear in dark mode. - Organization integrations settings should stay list-first and operational: coming-soon integrations are static rows, Slack is the only expandable row for now, and connected integrations need obvious lifecycle controls such as uninstall/disconnect in the row details. +- MCP setup UI should mirror the governed write metadata in `packages/ai/src/ai/mcp/tools.ts`: default to `read:data`, then expose explicit action bundles for workspace actions, feature flags, and short links with their required scopes and confirmation behavior. - Dashboard UI must use `apps/dashboard/components/ds` primitives exactly; feature code must not use raw form/control elements (`button`, `input`, `select`, `textarea`, native dialogs), Base UI/Radix primitives, or ad hoc styled controls directly. If a variant is missing, add or extend the DS component first. For menu-style folder/status/filter/sort/action pickers, use `components/ds/dropdown-menu.tsx`; use `Select` only when the established pattern is explicitly a select/combobox. Read `apps/dashboard/components/ds/README.md` before creating new dashboard UI. - `DropdownMenu.GroupLabel` must be rendered inside `DropdownMenu.Group`; Base UI throws `MenuGroupRootContext is missing` when labels are placed directly under `DropdownMenu.Content`. - Traffic Trends chart annotations should use a chart-adjacent annotation rail for dense data; avoid in-plot labels, tall lines, or floating dots that compete with the chart tooltip/data layer. diff --git a/apps/api/package.json b/apps/api/package.json index 7808ababe..b16522474 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -28,7 +28,6 @@ "@databuddy/validation": "workspace:*", "@elysiajs/cors": "^1.4.1", "@elysiajs/server-timing": "^1.4.0", - "@modelcontextprotocol/sdk": "^1.26.0", "@opentelemetry/resources": "^2.4.0", "@opentelemetry/sdk-node": "0.219.0", "@opentelemetry/semantic-conventions": "^1.29.0", diff --git a/apps/api/src/http/cors.test.ts b/apps/api/src/http/cors.test.ts new file mode 100644 index 000000000..b5b2e1fb2 --- /dev/null +++ b/apps/api/src/http/cors.test.ts @@ -0,0 +1,46 @@ +import cors from "@elysiajs/cors"; +import { Elysia } from "elysia"; +import { describe, expect, it } from "vitest"; +import { + isAllowedApiOrigin, + rejectInvalidMcpOrigin, + rejectUnsupportedMcpMethod, +} from "./cors"; + +describe("MCP CORS", () => { + it("rejects an invalid MCP preflight before CORS short-circuits it", async () => { + const app = new Elysia() + .onRequest(({ request }) => rejectInvalidMcpOrigin(request)) + .use(cors({ credentials: true, origin: isAllowedApiOrigin })); + + const response = await app.handle( + new Request("https://api.databuddy.test/v1/mcp", { + method: "OPTIONS", + headers: { + "access-control-request-method": "POST", + origin: "https://attacker.example", + }, + }) + ); + + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ + error: { message: "Forbidden Origin" }, + id: null, + jsonrpc: "2.0", + }); + }); + + it("limits the MCP method guard to MCP transport routes", () => { + const discoveryResponse = rejectUnsupportedMcpMethod( + new Request("https://api.databuddy.test/.well-known/mcp") + ); + const mcpResponse = rejectUnsupportedMcpMethod( + new Request("https://api.databuddy.test/v1/mcp") + ); + + expect(discoveryResponse).toBeUndefined(); + expect(mcpResponse?.status).toBe(405); + expect(mcpResponse?.headers.get("allow")).toBe("POST"); + }); +}); diff --git a/apps/api/src/http/cors.ts b/apps/api/src/http/cors.ts index befdeca81..4a9b0e78d 100644 --- a/apps/api/src/http/cors.ts +++ b/apps/api/src/http/cors.ts @@ -2,6 +2,11 @@ import { config } from "@databuddy/env/app"; const DATABUDDY_HOST_RE = /(?:^|\.)databuddy\.cc$/; const allowedApiOrigins = new Set(config.cors.apiOrigins); +const MCP_PATHS = new Set(["/v1/mcp", "/v1/mcp/", "/mcp", "/mcp/"]); + +export function isMcpRequest(request: Request): boolean { + return MCP_PATHS.has(new URL(request.url).pathname); +} export function isAllowedApiOrigin(request: Request): boolean { const origin = request.headers.get("Origin"); @@ -18,3 +23,35 @@ export function isAllowedApiOrigin(request: Request): boolean { return false; } } + +export function rejectInvalidMcpOrigin(request: Request): Response | undefined { + if ( + !(isMcpRequest(request) && request.headers.has("origin")) || + isAllowedApiOrigin(request) + ) { + return; + } + + // policy-ignore http/no-custom-json-error-response: MCP transport errors must use a JSON-RPC envelope. + return Response.json( + { + jsonrpc: "2.0", + error: { code: -32_000, message: "Forbidden Origin" }, + id: null, + }, + { status: 403 } + ); +} + +export function rejectUnsupportedMcpMethod( + request: Request +): Response | undefined { + if ( + !isMcpRequest(request) || + request.method === "POST" || + request.method === "OPTIONS" + ) { + return; + } + return new Response(null, { status: 405, headers: { Allow: "POST" } }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 203d23755..f82edb9a5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -12,7 +12,7 @@ import { registerShutdownHooks, warmPostgresConnection, } from "@/bootstrap/shutdown"; -import { isAllowedApiOrigin } from "@/http/cors"; +import { isAllowedApiOrigin, rejectInvalidMcpOrigin } from "@/http/cors"; import { handleAppError } from "@/http/errors"; import { getRequestId } from "@/http/request-id"; import { AUTUMN_API_PREFIX } from "@/lib/autumn-mount"; @@ -106,6 +106,7 @@ const app = new Elysia({ precompile: true }) }) ) .onBeforeHandle(({ request }) => enrichRequestAuthWideEvent(request)) + .onRequest(({ request }) => rejectInvalidMcpOrigin(request)) .use( cors({ credentials: true, diff --git a/apps/api/src/middleware/website-auth.ts b/apps/api/src/middleware/website-auth.ts index 5d7000709..0199fcbbd 100644 --- a/apps/api/src/middleware/website-auth.ts +++ b/apps/api/src/middleware/website-auth.ts @@ -1,6 +1,6 @@ import { getApiKeyFromHeader, - hasWebsiteScope, + hasWebsiteScopeForOrganization, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; import { auth } from "@databuddy/auth"; @@ -125,7 +125,7 @@ function isPreflight(request: Request): boolean { } async function checkWebsiteAuth( - websiteId: string, + _websiteId: string, sessionUser: SessionUser | null, website: Awaited> | null, apiKey: Awaited> | null, @@ -183,7 +183,7 @@ async function checkWebsiteAuth( code: "AUTH_REQUIRED", }); } - const ok = await hasWebsiteScope(apiKey, websiteId, "read:data"); + const ok = hasWebsiteScopeForOrganization(apiKey, website, "read:data"); if (!ok) { return json(403, { success: false, diff --git a/apps/api/src/routes/discovery.ts b/apps/api/src/routes/discovery.ts index c9e97c94f..8fdacf1f1 100644 --- a/apps/api/src/routes/discovery.ts +++ b/apps/api/src/routes/discovery.ts @@ -28,7 +28,7 @@ const discoveryUrls = { dashboardUrl: config.urls.dashboard, openapiSpecUrl: `${SITE_URL}/openapi.json`, apiOpenapiSpecUrl: `${API_URL}/openapi.json`, - mcpServerUrl: `${API_URL}/v1/mcp/`, + mcpServerUrl: config.urls.mcp, mcpManifestUrl: `${SITE_URL}/.well-known/mcp.json`, apiCatalogUrl: `${API_URL}/.well-known/api-catalog`, protectedResourceMetadataUrl: `${API_URL}/.well-known/oauth-protected-resource`, diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index 752556cc1..ca178dc7a 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,8 +1,5 @@ import { - getAccessibleWebsiteIds, getApiKeyFromHeader, - hasKeyScope, - hasWebsiteScope, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; import { @@ -10,23 +7,14 @@ import { handleDatabuddyMcpRequest, } from "@databuddy/ai/mcp/http"; import { auth } from "@databuddy/auth"; -import { config } from "@databuddy/env/app"; import { Elysia } from "elysia"; +import { + rejectInvalidMcpOrigin, + rejectUnsupportedMcpMethod, +} from "@/http/cors"; +import { getResolvedAuth } from "@/lib/auth-wide-event"; -const PROTECTED_RESOURCE_METADATA_URL = `${config.urls.api}/.well-known/oauth-protected-resource`; - -function canReadMcp( - apiKey: NonNullable>> -) { - return ( - hasKeyScope(apiKey, "read:data") || - getAccessibleWebsiteIds(apiKey).some((websiteId) => - hasWebsiteScope(apiKey, websiteId, "read:data") - ) - ); -} - -async function handleMcpRequest({ +function handleMcpRequest({ request, user, apiKey, @@ -37,7 +25,7 @@ async function handleMcpRequest({ request: Request; user: { id: string } | null; }) { - return await handleDatabuddyMcpRequest({ + return handleDatabuddyMcpRequest({ request, requestHeaders: request.headers, userId: user?.id ?? null, @@ -47,23 +35,23 @@ async function handleMcpRequest({ } export const mcp = new Elysia({ name: "mcp" }) + .onRequest( + ({ request }) => + rejectInvalidMcpOrigin(request) ?? rejectUnsupportedMcpMethod(request) + ) .derive(async ({ request }) => { + const preResolved = getResolvedAuth(request.headers); const hasApiKey = isApiKeyPresent(request.headers); const apiKey = hasApiKey - ? await getApiKeyFromHeader(request.headers) + ? preResolved + ? (preResolved.apiKeyResult?.key ?? null) + : await getApiKeyFromHeader(request.headers) : null; const session = hasApiKey ? null - : await auth.api.getSession({ headers: request.headers }); - - if (hasApiKey && !(apiKey && canReadMcp(apiKey))) { - return { - user: null, - apiKey: null, - isAuthenticated: false, - organizationId: null, - }; - } + : preResolved + ? preResolved.session + : await auth.api.getSession({ headers: request.headers }); const user = session?.user ?? null; return { @@ -74,36 +62,13 @@ export const mcp = new Elysia({ name: "mcp" }) apiKey?.organizationId ?? session?.session.activeOrganizationId ?? null, }; }) - .onBeforeHandle(async ({ request, isAuthenticated, set }) => { + .onBeforeHandle(({ isAuthenticated, set }) => { if (!isAuthenticated) { set.status = 401; - return await createMcpUnauthorizedResponse(request, { - resourceMetadataUrl: PROTECTED_RESOURCE_METADATA_URL, - }); + return createMcpUnauthorizedResponse(); } }) - .all( - "/v1/mcp", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/v1/mcp/", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/mcp", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/mcp/", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ) - .all( - "/.well-known/mcp", - async ({ request, user, apiKey, organizationId }) => - await handleMcpRequest({ request, user, apiKey, organizationId }) - ); + .all("/v1/mcp", handleMcpRequest) + .all("/v1/mcp/", handleMcpRequest) + .all("/mcp", handleMcpRequest) + .all("/mcp/", handleMcpRequest); diff --git a/apps/api/src/routes/query.ts b/apps/api/src/routes/query.ts index 618b9f7ca..83f43aab4 100644 --- a/apps/api/src/routes/query.ts +++ b/apps/api/src/routes/query.ts @@ -5,6 +5,7 @@ import { getApiKeyFromHeader, hasGlobalAccess, hasKeyScope, + hasWebsiteScopeForOrganization, isApiKeyPresent, } from "@databuddy/api-keys/resolve"; import { and, db, eq, inArray } from "@databuddy/db"; @@ -552,21 +553,17 @@ async function verifyWebsiteAccess( } if (ctx.apiKey) { - if (hasGlobalAccess(ctx.apiKey)) { - if (!ctx.apiKey.organizationId) { - mergeWideEvent({ access_result: "api_key_no_org" }); - return false; - } - const granted = website.organizationId === ctx.apiKey.organizationId; - mergeWideEvent({ - access_result: granted ? "api_key_global" : "api_key_denied", - }); - return granted; - } - - const granted = getAccessibleWebsiteIds(ctx.apiKey).includes(websiteId); + const granted = hasWebsiteScopeForOrganization( + ctx.apiKey, + website, + "read:data" + ); mergeWideEvent({ - access_result: granted ? "api_key_scoped" : "api_key_denied", + access_result: granted + ? hasGlobalAccess(ctx.apiKey) + ? "api_key_global" + : "api_key_scoped" + : "api_key_denied", }); return granted; } diff --git a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx index d2a1c7c53..0fff4114c 100644 --- a/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx +++ b/apps/dashboard/app/(main)/organizations/components/integrations-settings.tsx @@ -7,6 +7,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { ApiKeySheet } from "@/components/organizations/api-key-sheet"; +import type { ApiKeyListItem } from "@/components/organizations/api-key-types"; +import { + McpConnectionDetails, + McpSetupSheet, +} from "@/components/organizations/mcp-setup-sheet"; import { TopBar } from "@/components/layout/top-bar"; import type { Organization } from "@/hooks/use-organizations"; import { orpc } from "@/lib/orpc"; @@ -79,6 +85,18 @@ const SLACK_ITEM: IntegrationCatalogItem = { name: "Slack", }; +const MCP_ITEM: IntegrationCatalogItem = { + accent: "#111827", + accentClassName: "bg-foreground/70", + category: "AI agent", + description: + "Ask Claude, Cursor, Windsurf, or another AI client about your Databuddy analytics.", + iconPath: + "M6 2a2 2 0 0 0-2 2v5a2 2 0 1 0 2 0V4h5a2 2 0 1 0-2-2H6Zm12 0a2 2 0 0 0-2 2v5H11a2 2 0 1 0 0 2h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2ZM6 15a2 2 0 1 0 0 4v1a2 2 0 1 0 2 0v-1h5a2 2 0 1 0-2-2H8v-1a2 2 0 0 0-2-1Zm12 0a2 2 0 1 0 0 4h-5a2 2 0 1 0 0-2h5v-1a2 2 0 0 0-2-1Z", + id: "mcp", + name: "Databuddy MCP", +}; + const GITHUB_ITEM: IntegrationCatalogItem = { accent: "#181717", category: "Deployments", @@ -356,6 +374,8 @@ export function IntegrationsSettings({ + + (null); + const [manageOpen, setManageOpen] = useState(false); + + const keysQuery = useQuery({ + ...orpc.apikeys.list.queryOptions({ input: { organizationId } }), + }); + + const mcpKeys = ((keysQuery.data ?? []) as ApiKeyListItem[]).filter( + (key) => + key.type === "automation" && + (key.tags ?? []).some((tag) => tag.toLowerCase() === "mcp") + ); + const activeMcpKeys = mcpKeys.filter((key) => { + if (!key.enabled || key.revokedAt) { + return false; + } + return !key.expiresAt || dayjs(key.expiresAt).isAfter(dayjs()); + }); + + const statusBadge = keysQuery.isLoading ? ( + + Checking + + ) : activeMcpKeys.length > 0 ? ( + + {activeMcpKeys.length} connected + + ) : mcpKeys.length > 0 ? ( + + Needs attention + + ) : ( + + Not connected + + ); + + const openKey = (key: ApiKeyListItem) => { + setSelectedKey(key); + setManageOpen(true); + }; + + return ( + <> + setSetupOpen(true)} + size="sm" + variant="secondary" + > + + {mcpKeys.length > 0 ? "Add connection" : "Set up MCP"} + + } + badge={statusBadge} + defaultOpen={activeMcpKeys.length > 0} + item={MCP_ITEM} + > + + + + + queryClient.invalidateQueries({ + queryKey: orpc.apikeys.list.key(), + }) + } + onOpenChangeAction={setSetupOpen} + open={setupOpen} + organizationId={organizationId} + /> + + {selectedKey && ( + { + setManageOpen(open); + if (!open) { + setSelectedKey(null); + queryClient.invalidateQueries({ + queryKey: orpc.apikeys.list.key(), + }); + } + }} + open={manageOpen} + organizationId={organizationId} + /> + )} + + ); +} + function SlackIntegrationRow({ integrations, isLoading, @@ -927,7 +1044,10 @@ function IntegrationListRow({ if (!children) { return ( -
+
{header}
@@ -939,7 +1059,10 @@ function IntegrationListRow({ } return ( -
+
diff --git a/apps/dashboard/components/layout/mobile-sidebar.tsx b/apps/dashboard/components/layout/mobile-sidebar.tsx index 04c618b07..c15b50093 100644 --- a/apps/dashboard/components/layout/mobile-sidebar.tsx +++ b/apps/dashboard/components/layout/mobile-sidebar.tsx @@ -17,6 +17,7 @@ import { MagnifyingGlassIcon, MonitorIcon, MoonIcon, + PlugIcon, SignOutIcon, SunIcon, } from "@databuddy/ui/icons"; @@ -342,6 +343,18 @@ export function MobileSidebar() {
+ {!isDemo && ( + + )} + )} +
+ ); + })} +
+ ); +} + +export function McpSetupSheet({ + organizationId, + open, + onCreated, + onOpenChangeAction, +}: { + organizationId: string; + open: boolean; + onCreated?: () => void; + onOpenChangeAction: (open: boolean) => void; +}) { + const queryClient = useQueryClient(); + const [client, setClient] = useState("cursor"); + const [name, setName] = useState(() => defaultConnectionName("cursor")); + const [selectedActions, setSelectedActions] = useState([]); + const [selectedWebsiteIds, setSelectedWebsiteIds] = useState([]); + const [allowOrganizationWideLinks, setAllowOrganizationWideLinks] = + useState(false); + const [expiry, setExpiry] = useState("90d"); + const [newSecret, setNewSecret] = useState(null); + const [useEnvironmentVariable, setUseEnvironmentVariable] = useState(false); + + const websitesQuery = useQuery({ + ...orpc.websites.list.queryOptions({ + input: { organizationId }, + }), + enabled: open && !newSecret, + }); + + const createMutation = useMutation({ + ...orpc.apikeys.create.mutationOptions(), + onSuccess: (result) => { + setNewSecret(result.secret); + queryClient.invalidateQueries({ queryKey: orpc.apikeys.list.key() }); + onCreated?.(); + toast.success("MCP connection created"); + }, + onError: (error: Error) => { + toast.error( + getUserFacingErrorMessage(error, "Could not create the MCP connection.") + ); + }, + }); + + useEffect(() => { + if (!open) { + return; + } + setClient("cursor"); + setName(defaultConnectionName("cursor")); + setSelectedActions([]); + setSelectedWebsiteIds([]); + setAllowOrganizationWideLinks(false); + setExpiry("90d"); + setNewSecret(null); + setUseEnvironmentVariable(false); + }, [open]); + + const selectedScopes = useMemo( + () => getMcpScopes(selectedActions), + [selectedActions] + ); + const selectedWebsiteSet = useMemo( + () => new Set(selectedWebsiteIds), + [selectedWebsiteIds] + ); + const needsOrganizationWideLinkAcknowledgment = + selectedActions.includes("links") && selectedWebsiteIds.length > 0; + + const config = newSecret + ? createMcpConfig(newSecret, useEnvironmentVariable) + : ""; + + const handleClientChange = (nextClient: McpClient) => { + const previousDefault = defaultConnectionName(client); + if (name === previousDefault) { + setName(defaultConnectionName(nextClient)); + } + setClient(nextClient); + }; + + const toggleWebsite = (websiteId: string) => { + setSelectedWebsiteIds((current) => + current.includes(websiteId) + ? current.filter((id) => id !== websiteId) + : [...current, websiteId] + ); + }; + + const toggleAction = (action: McpAction) => { + setSelectedActions((current) => + current.includes(action) + ? current.filter((value) => value !== action) + : [...current, action] + ); + if (action === "links") { + setAllowOrganizationWideLinks(false); + } + }; + + const handleCreate = () => { + const trimmedName = name.trim(); + if (!trimmedName) { + toast.error("Give this connection a name first."); + return; + } + if ( + needsOrganizationWideLinkAcknowledgment && + !allowOrganizationWideLinks + ) { + toast.error("Confirm organization-wide Short links access first."); + return; + } + + const grant = getMcpScopeGrant(selectedActions, selectedWebsiteIds); + + createMutation.mutate({ + name: trimmedName, + description: `Databuddy MCP connection for ${CLIENT_LABELS[client]}`, + organizationId, + type: "automation", + scopes: grant.scopes, + resources: grant.resources, + tags: ["MCP", CLIENT_LABELS[client]], + expiresAt: + expiry === "90d" ? dayjs().add(90, "day").toISOString() : undefined, + ratelimit: { enabled: true }, + }); + }; + + const handleClose = () => { + if (createMutation.isPending) { + return; + } + onOpenChangeAction(false); + }; + + return ( + + + +
+
+ +
+
+ + {newSecret ? "MCP is ready" : "Connect Databuddy MCP"} + + + {newSecret + ? "Copy the config into your AI client, then ask it to list your websites." + : "Give your AI tools a safe, scoped connection to Databuddy analytics."} + +
+
+
+ + + {newSecret ? ( + + ) : ( + <> + + Connection name + setName(event.target.value)} + value={name} + /> + + Use one connection per client or environment so each key can + be rotated independently. + + + +
+ AI client + ({ + label: option.label, + value: option.value, + }))} + size="sm" + value={client} + /> + + { + CLIENT_OPTIONS.find((option) => option.value === client) + ?.description + } + +
+ +
+
+
+ +
+
+ Analytics access + + Read access is always included. Add only the workspace + actions you want this connection to perform. + +
+ {selectedScopes.map((scope) => ( + + {scope} + + ))} +
+
+ 0 ? "warning" : "success"} + > + {selectedActions.length > 0 + ? `${selectedActions.length} action${selectedActions.length === 1 ? "" : "s"}` + : "Read-only"} + +
+
+
+
+ Optional actions + + MCP previews every change first and requires explicit + approval before applying it. + +
+
+ {MCP_ACTION_OPTIONS.map((option) => ( +
+ toggleAction(option.value)} + /> +
+ ))} +
+
+
+
+ + + + + Website access + + {selectedWebsiteIds.length === 0 + ? "All websites" + : `${selectedWebsiteIds.length} selected`} + + + +
+ + Leave all websites unselected for organization-wide + access, or choose specific websites for a least-privilege + connection. + + {needsOrganizationWideLinkAcknowledgment ? ( +
+ + setAllowOrganizationWideLinks(checked === true) + } + /> +
+ ) : null} + {websitesQuery.isLoading ? ( +
+ + + Loading websites… + +
+ ) : websitesQuery.data && websitesQuery.data.length > 0 ? ( +
+ {websitesQuery.data.map((website) => ( +
+ toggleWebsite(website.id)} + /> +
+ ))} +
+ ) : ( +
+ + + No websites in this organization yet. + +
+ )} +
+
+
+ +
+ Key expiry + + + Keys are shown once and can be rotated or revoked from API + Keys. + +
+ +
+ + + The generated key authenticates an external AI client. Keep it + out of Git, screenshots, and shared prompts. + +
+ + )} +
+ + + {newSecret ? ( + + ) : ( + <> + + + + )} + +
+
+ ); +} + +function ConnectionCreated({ + client, + config, + onEnvironmentVariableChange, + secret, + useEnvironmentVariable, +}: { + client: McpClient; + config: string; + onEnvironmentVariableChange: (value: boolean) => void; + secret: string; + useEnvironmentVariable: boolean; +}) { + const clientDescription = CLIENT_OPTIONS.find( + (option) => option.value === client + )?.description; + + return ( +
+
+
+ +
+ + Connection created + + + {clientDescription} + +
+
+
+ +
+
+
+ Secret key + + Copy this now. It will not be shown again. + +
+ +
+
+ + + {secret} + +
+
+ +
+
+
+ Configuration + + Copy this into your client’s MCP settings. + +
+ +
+
+
+						{config}
+					
+ +
+
+ +
+ + onEnvironmentVariableChange(checked === true) + } + /> + + {MCP_ENV_VAR} + +
+ +
+ Test it +
+ + + List my Databuddy websites. + + +
+ + If the client returns a 401 or 403, rotate the key or check its access + in Organization Settings → API Keys. + +
+ +
+
+ + Server endpoint +
+
+ + {MCP_SERVER_URL} + + +
+
+
+ ); +} diff --git a/apps/docs/content/docs/api/mcp.mdx b/apps/docs/content/docs/api/mcp.mdx index b6e1c153d..b83b1a3aa 100644 --- a/apps/docs/content/docs/api/mcp.mdx +++ b/apps/docs/content/docs/api/mcp.mdx @@ -51,9 +51,15 @@ Pass an API key with the `read:data` scope: /> - Get your API key from [Dashboard → Organization Settings → API Keys](https://app.databuddy.cc/organizations/settings#api-keys). The key needs at least the `read:data` scope. Add `manage:websites` to reply to investigations. + The quickest setup is [Dashboard → Organization Settings → Integrations](https://app.databuddy.cc/organizations/settings/integrations): choose **Databuddy MCP**, select the client, capabilities, and website access you want, then copy the generated config. The secret is shown only once. You can also create and manage keys from [API Keys](https://app.databuddy.cc/organizations/settings#api-keys). The key needs at least the `read:data` scope. Add `manage:websites` for workspace actions such as goals, funnels, annotations, and investigation replies; add `manage:flags` for feature-flag mutations; and add `read:links` plus `write:links` for the full short-link workflow. +### Dashboard setup + +The dashboard creates a dedicated automation key tagged `MCP` rather than requiring you to share a personal API key. The setup sheet defaults to read-only analytics, then lets you enable **Workspace actions** (goals, funnels, annotations, and investigation replies), **Feature flags**, and **Short links**. Each capability maps to the narrowest scopes currently supported by the MCP tools. You can create separate connections for Cursor, Claude, Windsurf, or another MCP client, scope a connection to specific websites, choose a 90-day expiry or no expiry, and rotate or revoke it later from **Organization Settings → API Keys**. + +For clients that support environment-variable interpolation in remote MCP headers, enable the environment-variable option in the setup sheet and set `DATABUDDY_API_KEY` before launching the client. Otherwise, paste the generated one-time config with the secret in the `x-api-key` header. + ## Client Setup ### Claude Code / Claude Desktop @@ -121,8 +127,22 @@ Add to your MCP settings (typically `.cursor/mcp.json` or workspace settings): |------|-------------| | `list_funnels` | List configured funnels. | | `get_funnel_analytics` | Per-step conversion analytics for a funnel. | +| `get_funnel_analytics_by_referrer` | Break down funnel performance by referrer/source. | +| `create_funnel` | Create a funnel after confirmation. | | `list_goals` | List configured goals. | | `get_goal_analytics` | Goal completion analytics. | +| `create_goal` | Create a conversion goal after confirmation. | +| `update_goal` | Update a goal after confirmation. | +| `delete_goal` | Delete a goal after confirmation. | + +### Annotations + +| Tool | Description | +|------|-------------| +| `list_annotations` | List annotations for a website. | +| `create_annotation` | Create an annotation after confirmation. | +| `update_annotation` | Update an annotation after confirmation. | +| `delete_annotation` | Delete an annotation after confirmation. | ### Feature Flags @@ -131,6 +151,7 @@ Add to your MCP settings (typically `.cursor/mcp.json` or workspace settings): | `list_flags` | List active feature flags. | | `create_flag` | Create a new feature flag (requires confirmation). | | `update_flag` | Update flag configuration (requires confirmation). | +| `add_users_to_flag` | Add users to a feature flag target group (requires confirmation). | ### Links @@ -139,6 +160,9 @@ Add to your MCP settings (typically `.cursor/mcp.json` or workspace settings): | `list_links` | List short links. | | `search_links` | Search links by keyword. | | `list_link_folders` | List link folders with usage counts. | +| `create_link` | Create a short link after confirmation. | +| `update_link` | Update a short link after confirmation. | +| `delete_link` | Delete a short link after confirmation. | ## Conventions @@ -199,8 +223,10 @@ Tools are filtered based on your API key's scopes: | Scope | Tools | |-------|-------| | `read:data` | Analytics, investigations, schema, and read-only tools | -| `manage:websites` | Reply to investigations; create goals, funnels, and annotations | -| `manage:flags` + `manage:websites` | Feature flag mutations | -| `write:links` | Link mutations | +| `manage:websites` | Investigation replies; create, update, and delete goals and annotations; create funnels | +| `manage:flags` | Feature flag mutations | +| `read:links` | Read short links, folders, and link search results | +| `read:data` + `write:links` | Update or delete short links for an accessible website | +| `read:data` + `read:links` + `write:links` | Create short links | Session-authenticated users (via the dashboard) get access based on their organization role instead. diff --git a/apps/docs/lib/agent-discovery.test.ts b/apps/docs/lib/agent-discovery.test.ts index 50cbc9944..0664a21a9 100644 --- a/apps/docs/lib/agent-discovery.test.ts +++ b/apps/docs/lib/agent-discovery.test.ts @@ -32,6 +32,12 @@ describe("agent discovery resources", () => { expect(manifest.server.url).toBe("https://api.databuddy.cc/v1/mcp/"); expect(manifest.server.transport).toBe("streamable-http"); expect(manifest.authentication.name).toBe("x-api-key"); + expect( + Object.hasOwn( + manifest.authentication, + "protected_resource_metadata_url" + ) + ).toBe(false); expect(manifest.openapi_url).toBe("https://www.databuddy.cc/openapi.json"); }); @@ -44,6 +50,9 @@ describe("agent discovery resources", () => { expect(agent.endpoints.auth_md).toBe("https://www.databuddy.cc/auth.md"); expect(serverCard.serverUrl).toBe("https://api.databuddy.cc/v1/mcp/"); + expect( + Object.hasOwn(serverCard.authentication, "protectedResourceMetadataUrl") + ).toBe(false); expect(catalog.linkset[0]["service-desc"][0].href).toBe( "https://www.databuddy.cc/openapi.json" ); diff --git a/apps/docs/package.json b/apps/docs/package.json index 55be8b127..c7dc3c081 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -57,7 +57,7 @@ "fast-glob": "^3.3.3", "fumadocs-core": "15.5.0", "fumadocs-docgen": "^2.1.0", - "fumadocs-mdx": "14.3.0", + "fumadocs-mdx": "15.2.3", "fumadocs-ui": "15.5.0", "gray-matter": "^4.0.3", "input-otp": "^1.4.2", diff --git a/packages/ai/src/ai/mcp/build-batch-query-requests.test.ts b/packages/ai/src/ai/mcp/build-batch-query-requests.test.ts index 64b039d16..de6059b3b 100644 --- a/packages/ai/src/ai/mcp/build-batch-query-requests.test.ts +++ b/packages/ai/src/ai/mcp/build-batch-query-requests.test.ts @@ -208,4 +208,54 @@ describe("buildBatchQueryRequests", () => { { field: "trait:plan", op: "eq", value: "pro" }, ]); }); + + it("rejects ambiguous or invalid date ranges instead of silently changing them", () => { + const plan = buildBatchQueryRequests( + [ + { + type: "summary_metrics", + preset: "last_7d", + from: "2020-01-01", + to: "2020-01-02", + }, + { + type: "summary_metrics", + from: "2026-02-30", + to: "2026-03-02", + }, + { + type: "summary_metrics", + from: "2026-08-02", + to: "2026-08-01", + }, + ], + "website-1", + "UTC" + ); + + expect(plan.requests).toHaveLength(0); + expect(plan.invalid.map((item) => item.error)).toEqual([ + expect.stringContaining("either a preset or explicit dates"), + expect.stringContaining("valid YYYY-MM-DD"), + expect.stringContaining("must not be after"), + ]); + }); + + it("returns an invalid query for a bad timezone instead of throwing", () => { + expect(() => + buildBatchQueryRequests( + [{ type: "summary_metrics", preset: "last_7d" }], + "website-1", + "not/a-timezone" + ) + ).not.toThrow(); + + const plan = buildBatchQueryRequests( + [{ type: "summary_metrics", preset: "last_7d" }], + "website-1", + "not/a-timezone" + ); + expect(plan.requests).toHaveLength(0); + expect(plan.invalid[0]?.error).toContain("Invalid timezone"); + }); }); diff --git a/packages/ai/src/ai/mcp/define-tool.ts b/packages/ai/src/ai/mcp/define-tool.ts index 4c78d3c44..ddd601a51 100644 --- a/packages/ai/src/ai/mcp/define-tool.ts +++ b/packages/ai/src/ai/mcp/define-tool.ts @@ -1,5 +1,11 @@ +import { + apiKeyScopeTargetForResource, + requiredScopesForResource, + type ApiKeyScopeTarget, +} from "@databuddy/api-keys/scopes"; import type { ApiKeyRow } from "@databuddy/api-keys/resolve"; import { getRateLimitHeaders, ratelimit } from "@databuddy/redis/rate-limit"; +import type { ApiScope } from "@databuddy/shared/api-scopes"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { ORPCError } from "@orpc/server"; import type { z } from "zod"; @@ -20,39 +26,6 @@ function stripAnsi(text: string): string { return text.replace(ANSI_RE, ""); } -function coerceMcpInput(input: unknown): unknown { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return input; - } - const out: Record = {}; - for (const [key, value] of Object.entries(input as Record)) { - if (typeof value === "string") { - const trimmed = value.trim(); - if (trimmed === "true") { - out[key] = true; - continue; - } - if (trimmed === "false") { - out[key] = false; - continue; - } - if (trimmed.startsWith("[") || trimmed.startsWith("{")) { - try { - const parsed = JSON.parse(trimmed); - if (typeof parsed === "object" && parsed !== null) { - out[key] = parsed; - continue; - } - } catch { - // intentionally empty - } - } - } - out[key] = value; - } - return out; -} - export type McpErrorCode = | "invalid_input" | "unauthorized" @@ -91,25 +64,54 @@ export interface McpHandlerContext extends McpRequestContext { websiteId?: string; } -type McpToolCapability = "analytics" | "workspace"; type McpToolMutationKind = "read" | "write"; interface McpToolAccess { - confirmation?: "none" | "recommended" | "required"; + globalScopes: ApiScope[]; kind: McpToolMutationKind; - scopes?: string[]; + scopes: ApiScope[]; +} + +interface McpToolAccessInput { + kind?: McpToolMutationKind; + scopes?: ApiScope[]; + scopeTarget?: ApiKeyScopeTarget; } export interface McpToolMetadata { access: McpToolAccess; - capability: McpToolCapability; - evlogAction?: string; +} + +export interface McpToolMetadataInput { + access?: McpToolAccessInput; +} + +/** + * Derive MCP tool access metadata from the API-key scope source of truth. + * Keep this beside `defineMcpTool` so every tool module gets identical + * organization-vs-website scope behavior. + */ +export function metadataForResource( + resource: string, + permissions: readonly string[] +): McpToolMetadataInput { + return { + access: { + kind: permissions.every( + (permission) => permission === "read" || permission === "view_analytics" + ) + ? "read" + : "write", + scopeTarget: apiKeyScopeTargetForResource(resource), + scopes: requiredScopesForResource(resource, permissions), + }, + }; } export interface McpToolMeta { description: string; inputSchema: S; - metadata?: Partial; + metadata?: McpToolMetadataInput; name: string; /** * Optional Zod schema describing the successful response shape. @@ -117,7 +119,8 @@ export interface McpToolMeta { * it as `structuredContent` (MCP 2025-06-18 Tool Output Schemas), letting * clients consume native typed data instead of parsing JSON text. * The schema MUST validate an object — per MCP spec, `structuredContent` - * is an object. Prefer `z.object({...})` or `z.record(...)`. + * is an object. Prefer `z.object({...})` or `z.object({}).passthrough()`. + * Root `z.record(...)` schemas are not compatible with the installed MCP SDK. */ outputSchema?: z.ZodType>; ratelimit?: { limit: number; windowSec: number }; @@ -245,7 +248,10 @@ export function defineMcpTool( ); } - const metadata = normalizeToolMetadata(meta.metadata); + const metadata = normalizeToolMetadata( + meta.metadata, + Boolean(meta.resolveWebsite) + ); const hasOutputSchema = meta.outputSchema !== undefined; const build = (ctx: McpRequestContext): RegisteredMcpTool => ({ @@ -264,9 +270,7 @@ export function defineMcpTool( }); try { - const parseResult = meta.inputSchema.safeParse( - coerceMcpInput(rawInput ?? {}) - ); + const parseResult = meta.inputSchema.safeParse(rawInput ?? {}); if (!parseResult.success) { const issue = parseResult.error.issues[0]; const path = issue?.path.join(".") ?? "input"; @@ -328,15 +332,7 @@ export function defineMcpTool( const result = await handler(input, handlerCtx); - trackAgentEvent("agent_activity", { - action: metadata.evlogAction ?? "tool_completed", - source: "mcp", - tool: meta.name, - success: true, - tool_access_kind: metadata.access.kind, - tool_capability: metadata.capability, - ...attribution, - }); + trackMcpToolEvent(metadata, meta.name, true, attribution); mergeWideEvent({ mcp_status: "ok", mcp_duration_ms: Date.now() - start, @@ -358,15 +354,7 @@ export function defineMcpTool( captureError(err, { mcp_tool: meta.name }); } - trackAgentEvent("agent_activity", { - action: metadata.evlogAction ?? "tool_completed", - source: "mcp", - tool: meta.name, - success: false, - tool_access_kind: metadata.access.kind, - tool_capability: metadata.capability, - ...attribution, - }); + trackMcpToolEvent(metadata, meta.name, false, attribution); mergeWideEvent({ mcp_status: "error", mcp_error_code: toolError.code, @@ -381,15 +369,38 @@ export function defineMcpTool( } function normalizeToolMetadata( - metadata: Partial | undefined + metadata: McpToolMetadataInput | undefined, + resolvesWebsite: boolean ): McpToolMetadata { + const configuredScopes = metadata?.access?.scopes ?? []; + const scopes: ApiScope[] = [ + ...(resolvesWebsite ? (["read:data"] as const) : []), + ...configuredScopes, + ]; return { access: { - confirmation: metadata?.access?.confirmation ?? "none", + globalScopes: + metadata?.access?.scopeTarget === "global" ? configuredScopes : [], kind: metadata?.access?.kind ?? "read", - scopes: metadata?.access?.scopes ?? [], + scopes: [...new Set(scopes)], }, - capability: metadata?.capability ?? "analytics", - evlogAction: metadata?.evlogAction, }; } + +function trackMcpToolEvent( + metadata: McpToolMetadata, + tool: string, + success: boolean, + attribution: ReturnType +): void { + const kind = metadata.access.kind; + trackAgentEvent("agent_activity", { + action: kind === "write" ? "tool_mutation" : "tool_completed", + source: "mcp", + tool, + success, + tool_access_kind: kind, + tool_capability: kind === "write" ? "workspace" : "analytics", + ...attribution, + }); +} diff --git a/packages/ai/src/ai/mcp/mcp-utils.ts b/packages/ai/src/ai/mcp/mcp-utils.ts index 4283ba5c9..14e51b5ce 100644 --- a/packages/ai/src/ai/mcp/mcp-utils.ts +++ b/packages/ai/src/ai/mcp/mcp-utils.ts @@ -111,6 +111,16 @@ export interface McpQueryResult { } const AGENT_RESULT_ROW_LIMIT = 20; +const DateOnlySchema = z.iso.date(); + +function timezoneError(timezone: string): string | null { + try { + Intl.DateTimeFormat("en-US", { timeZone: timezone }); + return null; + } catch { + return `Invalid timezone: ${timezone}. Use an IANA timezone such as UTC.`; + } +} function querySummary(input: { filters?: Filter[]; @@ -141,6 +151,7 @@ export function buildBatchQueryRequests( ): McpBatchQueryPlan { const requests: IndexedQueryRequest[] = []; const invalid: InvalidBatchQuery[] = []; + const invalidTimezone = timezoneError(timezone); for (const [inputIndex, q] of items.entries()) { const resolvedType = resolveQueryType(q.type); let from = q.from; @@ -172,14 +183,28 @@ export function buildBatchQueryRequests( reject(message, q.type); continue; } - if (!q.preset && Boolean(from) !== Boolean(to)) { + if (invalidTimezone) { + reject(invalidTimezone); + continue; + } + const hasFrom = q.from !== undefined; + const hasTo = q.to !== undefined; + if (q.preset && (hasFrom || hasTo)) { + reject("Use either a preset or explicit dates, not both."); + continue; + } + if (!q.preset && hasFrom !== hasTo) { reject( `Both 'from' and 'to' are required when one is provided. Got from=${q.from ?? "(unset)"}, to=${q.to ?? "(unset)"}. Use a 'preset' (e.g. last_7d) or pass both dates as YYYY-MM-DD.` ); continue; } - const preset = q.preset ?? (from && to ? undefined : "last_7d"); - if (preset && MCP_DATE_PRESETS.includes(preset as DatePreset)) { + const preset = q.preset ?? (hasFrom ? undefined : "last_7d"); + if (preset && !MCP_DATE_PRESETS.includes(preset as DatePreset)) { + reject(`Unknown date preset: ${preset}.`); + continue; + } + if (preset) { const resolved = resolveDatePreset(preset as DatePreset, timezone); from = resolved.from; to = resolved.to; @@ -188,10 +213,20 @@ export function buildBatchQueryRequests( reject("Either preset or both from and to required"); continue; } - const filterError = invalidFilterFieldError( - resolvedType, - q.filters as Filter[] | undefined - ); + if ( + !( + DateOnlySchema.safeParse(from).success && + DateOnlySchema.safeParse(to).success + ) + ) { + reject("from and to must be valid YYYY-MM-DD dates."); + continue; + } + if (from > to) { + reject("from must not be after to."); + continue; + } + const filterError = invalidFilterFieldError(resolvedType, q.filters); if (filterError) { reject(filterError); continue; diff --git a/packages/ai/src/ai/mcp/run-agent.ts b/packages/ai/src/ai/mcp/run-agent.ts index 87089ec24..8fe400ccb 100644 --- a/packages/ai/src/ai/mcp/run-agent.ts +++ b/packages/ai/src/ai/mcp/run-agent.ts @@ -72,10 +72,7 @@ export async function runMcpAgent( abortSignal: abort.signal, }); - const usage = (result as { usage?: LanguageModelUsage }).usage; - if (usage) { - await trackPreparedUsage(prepared, usage); - } + await trackPreparedUsage(prepared, result.totalUsage); const answer = result.text ?? "No response generated."; if (options.storeMemory !== false) { diff --git a/packages/ai/src/ai/mcp/tool-context.ts b/packages/ai/src/ai/mcp/tool-context.ts index 66ebdcca5..267784b19 100644 --- a/packages/ai/src/ai/mcp/tool-context.ts +++ b/packages/ai/src/ai/mcp/tool-context.ts @@ -5,7 +5,7 @@ import { import { type ApiKeyRow, hasKeyScope, - hasWebsiteScope, + hasWebsiteScopeForOrganization, } from "@databuddy/api-keys/resolve"; import { websitesApi } from "@databuddy/auth"; import { getRedisCache } from "@databuddy/redis"; @@ -14,7 +14,7 @@ import { getCachedWebsite, validateWebsite } from "../../lib/website-utils"; const PROTOCOL_RE = /^https?:\/\//; const ACCESSIBLE_WEBSITES_TTL_SEC = 30; -const ACCESSIBLE_WEBSITES_KEY_PREFIX = "mcp:accessible_websites:"; +const ACCESSIBLE_WEBSITES_KEY_PREFIX = "mcp:accessible_websites:v2:"; export interface WebsiteSelectorInput { websiteDomain?: string; @@ -40,10 +40,11 @@ export async function ensureWebsiteAccess( const { website } = validation; if (apiKey) { - const hasWebsiteAccess = - hasWebsiteScope(apiKey, websiteId, "read:data") || - (hasKeyScope(apiKey, "read:data") && - apiKey.organizationId === website.organizationId); + const hasWebsiteAccess = hasWebsiteScopeForOrganization( + apiKey, + website, + "read:data" + ); if (!hasWebsiteAccess) { return new Error("Access denied to this website"); } @@ -77,7 +78,7 @@ function accessibleWebsitesCacheKey( const organizationId = principal.organizationId ?? principal.apiKey?.organizationId; if (principal.apiKey) { - return `apikey:${(principal.apiKey as { id: string }).id}:org:${organizationId ?? "none"}`; + return `apikey:${principal.apiKey.id}:org:${organizationId ?? "none"}`; } if (principal.userId && organizationId) { return `user:${principal.userId}:org:${organizationId}`; diff --git a/packages/ai/src/ai/mcp/tool-contracts.ts b/packages/ai/src/ai/mcp/tool-contracts.ts new file mode 100644 index 000000000..d2968c624 --- /dev/null +++ b/packages/ai/src/ai/mcp/tool-contracts.ts @@ -0,0 +1,66 @@ +import { analyticsDateRangeSchema } from "@databuddy/validation"; +import { z } from "zod"; +import { McpToolError, type McpHandlerContext } from "./define-tool"; + +const DateOnlySchema = z.iso.date(); + +export const McpDateRangeSchema = z + .object({ + from: DateOnlySchema.optional().describe( + "Start date YYYY-MM-DD (defaults to 30 days ago)" + ), + to: DateOnlySchema.optional().describe( + "End date YYYY-MM-DD (defaults to today)" + ), + }) + .superRefine((input, context) => { + const result = analyticsDateRangeSchema.safeParse({ + startDate: input.from, + endDate: input.to, + }); + for (const issue of result.error?.issues ?? []) { + if (issue.code === "custom") { + context.addIssue({ + code: "custom", + message: issue.message, + path: [issue.path[0] === "startDate" ? "from" : "to"], + }); + } + } + }); + +export const WebsiteSelectorSchema = { + websiteId: z.string().optional().describe("Website ID from list_websites"), + websiteName: z + .string() + .optional() + .describe("Website name. Alternative to websiteId."), + websiteDomain: z + .string() + .optional() + .describe("Website domain. Alternative to websiteId."), +} as const; + +export const WorkflowFilterSchema = z.object({ + field: z.string(), + operator: z.enum(["equals", "contains", "not_equals", "in", "not_in"]), + value: z.union([z.string(), z.array(z.string())]), +}); + +export const ConfirmedSchema = z.boolean().optional().default(false); +export const DynamicObjectSchema = z.object({}).passthrough(); +export const MutationResultSchema = z + .object({ + confirmationRequired: z.boolean().optional(), + message: z.string(), + preview: z.boolean().optional(), + success: z.boolean().optional(), + }) + .passthrough(); + +export function getResolvedWebsiteId(ctx: McpHandlerContext): string { + if (!ctx.websiteId) { + throw new McpToolError("internal", "Website was not resolved."); + } + return ctx.websiteId; +} diff --git a/packages/ai/src/ai/mcp/tools.test.ts b/packages/ai/src/ai/mcp/tools.test.ts index 066483b54..4f9ef068c 100644 --- a/packages/ai/src/ai/mcp/tools.test.ts +++ b/packages/ai/src/ai/mcp/tools.test.ts @@ -1,8 +1,16 @@ import { createInternalPrincipal } from "@databuddy/rpc"; +import type { ApiScope } from "@databuddy/shared/api-scopes"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { AnySchema } from "@modelcontextprotocol/sdk/server/zod-compat.js"; import { describe, expect, test } from "bun:test"; import { z } from "zod"; -import { handleDatabuddyMcpRequest } from "../../mcp/http"; -import type { McpRequestContext } from "./define-tool"; +import { + createMcpUnauthorizedResponse, + handleDatabuddyMcpRequest, +} from "../../mcp/http"; +import { defineMcpTool, type McpRequestContext } from "./define-tool"; import { createMcpTools } from "./tools"; const ctx: McpRequestContext = { @@ -16,33 +24,137 @@ const tools = createMcpTools(ctx); const TOOL_NAME_RE = /^[a-z][a-z0-9_]*$/; const MAX_DESCRIPTION_LEN = 240; -describe("MCP tools/list JSON Schema rendering", () => { - test("registers at least one tool", () => { - expect(tools.length).toBeGreaterThan(0); - }); +describe("MCP transport", () => { + test("keeps API-key authentication separate from unimplemented OAuth", async () => { + const response = createMcpUnauthorizedResponse(); - for (const tool of tools) { - test(`${tool.name}: inputSchema renders to JSON Schema`, () => { - expect(() => - z.toJSONSchema(tool.inputSchema, { io: "input" }) - ).not.toThrow(); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).not.toContain( + "resource_metadata" + ); + expect(await response.json()).toMatchObject({ + id: null, + jsonrpc: "2.0", }); - - if (tool.outputSchema) { - test(`${tool.name}: outputSchema renders to JSON Schema`, () => { - const outputSchema = tool.outputSchema; - if (!outputSchema) { - return; - } - expect(() => - z.toJSONSchema(outputSchema, { io: "output" }) - ).not.toThrow(); - }); - } - } + }); }); +async function listToolsForPrincipal( + principal: ReturnType +) { + const response = await handleDatabuddyMcpRequest({ + apiKey: principal.apiKey, + organizationId: "org-1", + request: new Request("https://api.databuddy.test/v1/mcp", { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "tools/list", + params: {}, + }), + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + method: "POST", + }), + requestHeaders: new Headers(), + userId: null, + }); + const body = (await response.json()) as { + result?: { + tools?: Array<{ + annotations?: Record; + name: string; + }>; + }; + }; + return { + response, + tools: body.result?.tools ?? [], + }; +} + +async function listToolsForScopes(scopes: ApiScope[]) { + return listToolsForPrincipal( + createInternalPrincipal({ organizationId: "org-1", scopes }) + ); +} + describe("MCP tool invariants", () => { + test("dynamic analytics output schemas work through the installed MCP SDK", async () => { + const dynamicTools = tools.filter((tool) => + ["get_funnel_analytics", "get_goal_analytics"].includes(tool.name) + ); + expect(dynamicTools).toHaveLength(2); + + const server = new McpServer({ name: "test", version: "1.0.0" }); + for (const tool of dynamicTools) { + server.registerTool( + tool.name, + { + inputSchema: z.object({}), + outputSchema: tool.outputSchema as AnySchema, + }, + () => ({ + content: [{ type: "text", text: '{"value":"ok"}' }], + structuredContent: { value: "ok" }, + }) + ); + } + + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "test", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + + try { + const listed = await client.listTools(); + for (const tool of dynamicTools) { + expect( + listed.tools.find((listedTool) => listedTool.name === tool.name) + ?.outputSchema + ).toBeDefined(); + const result = await client.callTool({ + arguments: {}, + name: tool.name, + }); + expect(result).not.toMatchObject({ isError: true }); + expect(result).toMatchObject({ + structuredContent: { value: "ok" }, + }); + } + } finally { + await server.close(); + } + }); + + test("preserves literal string tool arguments", async () => { + let received: { enabled: boolean; literal: string } | undefined; + const tool = defineMcpTool( + { + name: "literal_string_input", + description: "Test that literal string inputs reach the handler unchanged.", + inputSchema: z.object({ + enabled: z.boolean(), + literal: z.string(), + }), + }, + (input) => { + received = input; + return { ok: true }; + } + ).build(ctx); + + for (const literal of ["true", "false", '{"key":"value"}', "[1,2]"]) { + received = undefined; + const result = await tool.handler({ enabled: true, literal }); + expect(result).not.toMatchObject({ isError: true }); + expect(received).toEqual({ enabled: true, literal }); + } + }); + test("create_link matches the HTTP(S) and deep-link app contract", () => { const createLink = tools.find((tool) => tool.name === "create_link"); if (!createLink) { @@ -86,6 +198,67 @@ describe("MCP tool invariants", () => { ).toBe(true); }); + test("uses strict ISO dates for MCP date-only and timestamp inputs", () => { + const getFunnelAnalytics = tools.find( + (tool) => tool.name === "get_funnel_analytics" + ); + const createLink = tools.find((tool) => tool.name === "create_link"); + const createAnnotation = tools.find( + (tool) => tool.name === "create_annotation" + ); + if (!(getFunnelAnalytics && createLink && createAnnotation)) { + throw new Error("Expected date-bearing MCP tools to be registered"); + } + + expect( + getFunnelAnalytics.inputSchema.safeParse({ + funnelId: "funnel-1", + from: "2026-02-30", + to: "2026-03-02", + websiteId: "website-1", + }).success + ).toBe(false); + expect( + createLink.inputSchema.safeParse({ + confirmed: false, + expiresAt: "2026-02-30T12:00:00Z", + name: "Broken expiry", + targetUrl: "https://example.com", + websiteId: "website-1", + }).success + ).toBe(false); + expect( + createAnnotation.inputSchema.safeParse({ + annotationType: "point", + confirmed: false, + text: "Release", + websiteId: "website-1", + xValue: "2026-02-30T12:00:00Z", + }).success + ).toBe(false); + }); + + test("keeps mixed batch date errors inside the batch result", () => { + const getData = tools.find((tool) => tool.name === "get_data"); + if (!getData) { + throw new Error("Expected get_data to be registered"); + } + + expect( + getData.inputSchema.safeParse({ + queries: [ + { preset: "last_7d", type: "summary_metrics" }, + { + from: "2026-02-30", + to: "2026-03-02", + type: "summary_metrics", + }, + ], + websiteId: "website-1", + }).success + ).toBe(true); + }); + test("tool names are unique snake_case", () => { const names = tools.map((tool) => tool.name); expect(new Set(names).size).toBe(names.length); @@ -99,7 +272,6 @@ describe("MCP tool invariants", () => { expect(tool.description.length).toBeGreaterThan(0); expect(tool.description.length).toBeLessThanOrEqual(MAX_DESCRIPTION_LEN); expect(tool.metadata.access.kind).toMatch(/^(read|write)$/); - expect(tool.metadata.capability).toMatch(/^(analytics|workspace)$/); expect(typeof tool.handler).toBe("function"); } }); @@ -145,6 +317,195 @@ describe("MCP tool invariants", () => { }); describe("investigation tools", () => { + test("only advertises tools whose API-key scopes can satisfy their calls", async () => { + const readData = await listToolsForScopes(["read:data"]); + const readDataNames = new Set(readData.tools.map((tool) => tool.name)); + expect(readData.response.status).toBe(200); + expect(readDataNames.has("get_data")).toBe(true); + expect(readDataNames.has("get_funnel_analytics_by_referrer")).toBe(true); + expect(readDataNames.has("list_links")).toBe(false); + expect(readDataNames.has("create_link")).toBe(false); + expect(readDataNames.has("create_flag")).toBe(false); + + const flagManager = await listToolsForScopes([ + "read:data", + "manage:flags", + ]); + const flagManagerNames = new Set( + flagManager.tools.map((tool) => tool.name) + ); + for (const name of [ + "create_flag", + "update_flag", + "add_users_to_flag", + ]) { + expect(flagManagerNames.has(name)).toBe(true); + } + + const workspaceManager = await listToolsForScopes([ + "read:data", + "manage:websites", + ]); + const workspaceManagerNames = new Set( + workspaceManager.tools.map((tool) => tool.name) + ); + for (const name of [ + "update_goal", + "delete_goal", + "update_annotation", + "delete_annotation", + ]) { + expect(workspaceManagerNames.has(name)).toBe(true); + } + + const workspaceWriterWithoutRead = await listToolsForScopes([ + "manage:websites", + ]); + const workspaceWriterWithoutReadNames = new Set( + workspaceWriterWithoutRead.tools.map((tool) => tool.name) + ); + for (const name of [ + "update_goal", + "delete_goal", + "update_annotation", + "delete_annotation", + ]) { + expect(workspaceWriterWithoutReadNames.has(name)).toBe(false); + } + + const linkReader = await listToolsForScopes([ + "read:data", + "read:links", + ]); + expect( + new Set(linkReader.tools.map((tool) => tool.name)).has("list_links") + ).toBe(true); + expect( + new Set(linkReader.tools.map((tool) => tool.name)).has("update_link") + ).toBe(false); + + const linkWriterWithoutRead = await listToolsForScopes([ + "read:data", + "write:links", + ]); + const linkWriterWithoutReadNames = new Set( + linkWriterWithoutRead.tools.map((tool) => tool.name) + ); + for (const name of ["update_link", "delete_link"]) { + expect(linkWriterWithoutReadNames.has(name)).toBe(false); + } + + const linkWriter = await listToolsForScopes([ + "read:data", + "read:links", + "write:links", + ]); + const linkWriterNames = new Set( + linkWriter.tools.map((tool) => tool.name) + ); + for (const name of ["create_link", "update_link", "delete_link"]) { + expect(linkWriterNames.has(name)).toBe(true); + } + }); + + test("does not advertise org-wide link tools from website-only scopes", async () => { + const scopedKey = await listToolsForPrincipal( + createInternalPrincipal({ + metadata: { + resources: { + "website:site-1": [ + "read:data", + "read:links", + "write:links", + ], + }, + }, + organizationId: "org-1", + scopes: [], + }) + ); + const names = new Set(scopedKey.tools.map((tool) => tool.name)); + + for (const name of [ + "list_link_folders", + "list_links", + "search_links", + "create_link", + "update_link", + "delete_link", + ]) { + expect(names.has(name)).toBe(false); + } + }); + + test("combines global link scopes with website-scoped analytics", async () => { + const scopedKey = await listToolsForPrincipal( + createInternalPrincipal({ + metadata: { + resources: { "website:site-1": ["read:data"] }, + }, + organizationId: "org-1", + scopes: ["read:links", "write:links"], + }) + ); + const names = new Set(scopedKey.tools.map((tool) => tool.name)); + + expect(names.has("list_links")).toBe(true); + expect(names.has("create_link")).toBe(true); + }); + + test("uses conservative annotations for mutations", async () => { + const { tools: listed } = await listToolsForScopes([ + "read:data", + "read:links", + "write:links", + "manage:flags", + "manage:websites", + ]); + const byName = new Map(listed.map((tool) => [tool.name, tool])); + + expect(byName.get("get_data")?.annotations).toMatchObject({ + destructiveHint: false, + idempotentHint: true, + readOnlyHint: true, + }); + for (const name of [ + "create_link", + "update_link", + "delete_link", + "update_goal", + "delete_goal", + "update_flag", + "add_users_to_flag", + ]) { + expect(byName.get(name)?.annotations).toMatchObject({ + destructiveHint: true, + idempotentHint: false, + readOnlyHint: false, + }); + } + }); + + test("rejects unsupported standalone SSE methods", async () => { + const principal = createInternalPrincipal({ + organizationId: "org-1", + scopes: ["read:data"], + }); + const response = await handleDatabuddyMcpRequest({ + apiKey: principal.apiKey, + organizationId: "org-1", + request: new Request("https://api.databuddy.test/v1/mcp", { + headers: { accept: "text/event-stream" }, + method: "GET", + }), + requestHeaders: new Headers(), + userId: null, + }); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST"); + }); + test("publishes the investigation lifecycle to a website-scoped key", async () => { const principal = createInternalPrincipal({ metadata: { diff --git a/packages/ai/src/ai/mcp/tools.ts b/packages/ai/src/ai/mcp/tools.ts index fd9aa3537..9ca67381e 100644 --- a/packages/ai/src/ai/mcp/tools.ts +++ b/packages/ai/src/ai/mcp/tools.ts @@ -31,11 +31,10 @@ import { } from "../tools/link-catalog"; import { defineMcpTool, + metadataForResource, McpToolError, - type McpHandlerContext, type McpRequestContext, type McpToolFactory, - type McpToolMetadata, type RegisteredMcpTool, } from "./define-tool"; import { @@ -58,24 +57,27 @@ import { getOrganizationId, resolveOrganizationIds, } from "./tool-context"; +import { createMcpWorkspaceTools } from "./workspace-tools"; +import { + ConfirmedSchema, + DynamicObjectSchema, + getResolvedWebsiteId, + McpDateRangeSchema, + MutationResultSchema, + WebsiteSelectorSchema, + WorkflowFilterSchema, +} from "./tool-contracts"; const TIME_UNIT = ["minute", "hour", "day", "week", "month"] as const; - -const WebsiteSelectorSchema = { - websiteId: z.string().optional().describe("Website ID from list_websites"), - websiteName: z - .string() - .optional() - .describe("Website name. Alternative to websiteId."), - websiteDomain: z - .string() - .optional() - .describe("Website domain. Alternative to websiteId."), -} as const; +const DateTimeSchema = z.union([ + z.iso.date(), + z.iso.datetime({ offset: true }), +]); const QueryItemSchema = z.object({ type: z.string(), preset: z.enum(MCP_DATE_PRESETS as [string, ...string[]]).optional(), + // Batch queries report invalid ranges per item rather than rejecting every item. from: z.string().optional(), to: z.string().optional(), timeUnit: z.enum(TIME_UNIT).optional(), @@ -92,12 +94,6 @@ const WebsiteSummarySchema = z.object({ isPublic: z.boolean().nullable(), }); -const WorkflowFilterSchema = z.object({ - field: z.string(), - operator: z.enum(["equals", "contains", "not_equals", "in", "not_in"]), - value: z.union([z.string(), z.array(z.string())]), -}); - const FunnelStepSchema = z.object({ type: z.enum(["PAGE_VIEW", "EVENT", "CUSTOM"]), target: z.string().min(1), @@ -129,41 +125,6 @@ const FlagVariantSchema = variantSchema; const FlagStatusSchema = z.enum(["active", "inactive", "archived"]); const FlagTypeSchema = z.enum(["boolean", "rollout", "multivariant"]); -const ConfirmedSchema = z.boolean().optional().default(false); - -const MutationResultSchema = z - .object({ - confirmationRequired: z.boolean().optional(), - message: z.string(), - preview: z.boolean().optional(), - success: z.boolean().optional(), - }) - .passthrough(); - -const WRITE_METADATA = { - capability: "workspace", - access: { - confirmation: "recommended", - kind: "write", - }, - evlogAction: "tool_mutation", -} satisfies Partial; - -function writeMetadata(scopes: string[]): Partial { - return { - ...WRITE_METADATA, - access: { - ...WRITE_METADATA.access, - scopes, - }, - }; -} - -function assertValidDate(value: string | undefined, field: string): void { - if (value && !dayjs(value).isValid()) { - throw new McpToolError("invalid_input", `${field} must be a valid date`); - } -} function createChartContext(input: { from?: string; @@ -188,13 +149,6 @@ function asRecord(value: unknown): Record { : {}; } -function getResolvedWebsiteId(ctx: McpHandlerContext): string { - if (!ctx.websiteId) { - throw new McpToolError("internal", "Website was not resolved."); - } - return ctx.websiteId; -} - function createFlagUserRule( matchBy: "email" | "user_id", values: string[] @@ -213,12 +167,13 @@ const listWebsitesTool = defineMcpTool( { name: "list_websites", description: - "List websites the caller can access. Use only when the user hasn't named one — every other tool accepts websiteId, websiteName, or websiteDomain.", + "List accessible websites when the user hasn't named one. Most website-scoped tools accept websiteId, websiteName, or websiteDomain.", inputSchema: z.object({}), outputSchema: z.object({ websites: z.array(WebsiteSummarySchema), total: z.number(), }), + metadata: metadataForResource("organization", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (_input, ctx) => { @@ -250,10 +205,7 @@ const listInsightsTool = defineMcpTool( hasMore: z.boolean(), insights: z.array(insightBriefItemSchema), }), - metadata: { - access: { kind: "read", scopes: ["read:data"] }, - capability: "analytics", - }, + metadata: metadataForResource("website", ["read"]), resolveWebsite: "optional", ratelimit: { limit: 60, windowSec: 60 }, }, @@ -300,10 +252,7 @@ const listInvestigationsTool = defineMcpTool( hasMore: z.boolean(), investigations: z.array(historyInsightSchema), }), - metadata: { - access: { kind: "read", scopes: ["read:data"] }, - capability: "analytics", - }, + metadata: metadataForResource("website", ["read"]), resolveWebsite: "optional", ratelimit: { limit: 60, windowSec: 60 }, }, @@ -349,10 +298,7 @@ const getInvestigationTool = defineMcpTool( investigation: historyInsightSchema.nullable(), timeline: z.array(insightTimelineItemSchema), }), - metadata: { - access: { kind: "read", scopes: ["read:data"] }, - capability: "analytics", - }, + metadata: metadataForResource("website", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (input, ctx) => { @@ -392,7 +338,7 @@ const replyToInvestigationTool = defineMcpTool( ), }), outputSchema: z.object({ reply: insightTimelineReplySchema }), - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -756,27 +702,17 @@ const getFunnelAnalyticsTool = defineMcpTool( name: "get_funnel_analytics", description: "Return per-step conversion, drop-off, and timing for one funnel. Use after list_funnels to analyze a specific funnelId.", - inputSchema: z.object({ + inputSchema: McpDateRangeSchema.safeExtend({ ...WebsiteSelectorSchema, funnelId: z.string().describe("Funnel ID from list_funnels"), - from: z - .string() - .optional() - .describe("Start date YYYY-MM-DD (defaults to 30 days ago)"), - to: z - .string() - .optional() - .describe("End date YYYY-MM-DD (defaults to today)"), }), // Passthrough from RPC — shape varies by funnel. Permissive by design. - outputSchema: z.record(z.string(), z.unknown()), + outputSchema: DynamicObjectSchema, resolveWebsite: true, ratelimit: { limit: 60, windowSec: 60 }, }, - async (input, ctx) => { - assertValidDate(input.from, "from"); - assertValidDate(input.to, "to"); - return await callRPCProcedure( + async (input, ctx) => + await callRPCProcedure( "funnels", "getAnalytics", { @@ -786,8 +722,7 @@ const getFunnelAnalyticsTool = defineMcpTool( endDate: input.to, }, buildRpcContext(ctx) - ); - } + ) ); const createFunnelTool = defineMcpTool( @@ -806,7 +741,7 @@ const createFunnelTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 10, windowSec: 60 }, }, async (input, ctx) => { @@ -888,27 +823,17 @@ const getGoalAnalyticsTool = defineMcpTool( name: "get_goal_analytics", description: "Return entered/completed counts and conversion rate for one goalId. Use after list_goals.", - inputSchema: z.object({ + inputSchema: McpDateRangeSchema.safeExtend({ ...WebsiteSelectorSchema, goalId: z.string().describe("Goal ID from list_goals"), - from: z - .string() - .optional() - .describe("Start date YYYY-MM-DD (defaults to 30 days ago)"), - to: z - .string() - .optional() - .describe("End date YYYY-MM-DD (defaults to today)"), }), // Passthrough from RPC — shape varies by goal. Permissive by design. - outputSchema: z.record(z.string(), z.unknown()), + outputSchema: DynamicObjectSchema, resolveWebsite: true, ratelimit: { limit: 60, windowSec: 60 }, }, - async (input, ctx) => { - assertValidDate(input.from, "from"); - assertValidDate(input.to, "to"); - return await callRPCProcedure( + async (input, ctx) => + await callRPCProcedure( "goals", "getAnalytics", { @@ -918,8 +843,7 @@ const getGoalAnalyticsTool = defineMcpTool( endDate: input.to, }, buildRpcContext(ctx) - ); - } + ) ); const createGoalTool = defineMcpTool( @@ -939,7 +863,7 @@ const createGoalTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 10, windowSec: 60 }, }, async (input, ctx) => { @@ -997,6 +921,7 @@ const listLinkFoldersTool = defineMcpTool( hint: z.string(), }), resolveWebsite: true, + metadata: metadataForResource("link", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (_input, ctx) => { @@ -1039,6 +964,7 @@ const listLinksTool = defineMcpTool( hint: z.string().optional(), }), resolveWebsite: true, + metadata: metadataForResource("link", ["read"]), ratelimit: { limit: 60, windowSec: 60 }, }, async (_input, ctx) => { @@ -1105,6 +1031,7 @@ const searchLinksTool = defineMcpTool( hasMore: z.boolean(), }), resolveWebsite: true, + metadata: metadataForResource("link", ["read"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1150,7 +1077,7 @@ const createLinkTool = defineMcpTool( .max(50) .regex(/^[a-zA-Z0-9_-]+$/) .optional(), - expiresAt: z.string().optional(), + expiresAt: DateTimeSchema.optional(), expiredRedirectUrl: httpUrlSchema.optional(), ogTitle: z.string().max(200).optional(), ogDescription: z.string().max(500).optional(), @@ -1172,11 +1099,10 @@ const createLinkTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["write:links"]), + metadata: metadataForResource("link", ["read", "create"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { - assertValidDate(input.expiresAt, "expiresAt"); const orgId = await getOrganizationId(getResolvedWebsiteId(ctx)); if (orgId instanceof Error) { throw new McpToolError("not_found", orgId.message); @@ -1244,12 +1170,9 @@ const createLinkTool = defineMcpTool( const listAnnotationsTool = defineMcpTool( { name: "list_annotations", - description: - "List chart annotations for a website over a date range. Defaults to the last 30 days.", + description: "List chart annotations for a website.", inputSchema: z.object({ ...WebsiteSelectorSchema, - from: z.string().optional(), - to: z.string().optional(), granularity: z.enum(["hourly", "daily", "weekly", "monthly"]).optional(), metrics: z.array(z.string()).optional(), chartContext: ChartContextSchema.optional(), @@ -1262,8 +1185,6 @@ const listAnnotationsTool = defineMcpTool( ratelimit: { limit: 60, windowSec: 60 }, }, async (input, ctx) => { - assertValidDate(input.from, "from"); - assertValidDate(input.to, "to"); const result = await callRPCProcedure( "annotations", "list", @@ -1284,27 +1205,35 @@ const createAnnotationTool = defineMcpTool( name: "create_annotation", description: "Create a chart annotation. Call with confirmed=false for preview before writing.", - inputSchema: z.object({ - ...WebsiteSelectorSchema, - chartContext: ChartContextSchema.optional(), - annotationType: z.enum(["point", "line", "range"]), - xValue: z.string(), - xEndValue: z.string().optional(), - yValue: z.number().optional(), - text: z.string().min(1).max(500), - tags: z.array(z.string()).optional(), - color: z.string().optional(), - isPublic: z.boolean().optional(), - confirmed: ConfirmedSchema, - }), + inputSchema: z + .object({ + ...WebsiteSelectorSchema, + chartContext: ChartContextSchema.optional(), + annotationType: z.enum(["point", "line", "range"]), + xValue: DateTimeSchema, + xEndValue: DateTimeSchema.optional(), + yValue: z.number().optional(), + text: z.string().min(1).max(500), + tags: z.array(z.string()).optional(), + color: z.string().optional(), + isPublic: z.boolean().optional(), + confirmed: ConfirmedSchema, + }) + .refine( + (input) => + !input.xEndValue || + new Date(input.xEndValue) >= new Date(input.xValue), + { + message: "xEndValue must be on or after xValue.", + path: ["xEndValue"], + } + ), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:websites"]), + metadata: metadataForResource("website", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { - assertValidDate(input.xValue, "xValue"); - assertValidDate(input.xEndValue, "xEndValue"); if (input.annotationType === "range" && !input.xEndValue) { throw new McpToolError( "invalid_input", @@ -1421,7 +1350,7 @@ const createFlagTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:flags", "manage:websites"]), + metadata: metadataForResource("flag", ["create"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1500,7 +1429,7 @@ const updateFlagTool = defineMcpTool( confirmed: ConfirmedSchema, }), outputSchema: MutationResultSchema, - metadata: writeMetadata(["manage:flags", "manage:websites"]), + metadata: metadataForResource("flag", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1547,7 +1476,7 @@ const addUsersToFlagTool = defineMcpTool( }), outputSchema: MutationResultSchema, resolveWebsite: true, - metadata: writeMetadata(["manage:flags", "manage:websites"]), + metadata: metadataForResource("flag", ["update"]), ratelimit: { limit: 20, windowSec: 60 }, }, async (input, ctx) => { @@ -1605,6 +1534,7 @@ const addUsersToFlagTool = defineMcpTool( ); const TOOL_FACTORIES = [ + ...createMcpWorkspaceTools(), listWebsitesTool, listInsightsTool, listInvestigationsTool, diff --git a/packages/ai/src/ai/mcp/workspace-tools.ts b/packages/ai/src/ai/mcp/workspace-tools.ts new file mode 100644 index 000000000..ece4407d8 --- /dev/null +++ b/packages/ai/src/ai/mcp/workspace-tools.ts @@ -0,0 +1,424 @@ +import { + DEEP_LINK_APP_IDS, + isDeepLinkTarget, +} from "@databuddy/shared/constants/deep-link-apps"; +import { LINK_SLUG_REGEX } from "@databuddy/shared/constants/links"; +import { httpUrlSchema } from "@databuddy/validation"; +import { z } from "zod"; +import { callRPCProcedure } from "../tools/utils"; +import { + LinkFolderSelectorSchema, + hasLinkFolderSelector, + listLinkFolders, + parseLinkRow, + resolveLinkFolderFromList, + summarizeLink, + summarizeLinkFolder, +} from "../tools/link-catalog"; +import { + defineMcpTool, + metadataForResource, + McpToolError, + type McpToolFactory, +} from "./define-tool"; +import { buildRpcContext, getOrganizationId } from "./tool-context"; +import { + ConfirmedSchema, + DynamicObjectSchema, + getResolvedWebsiteId, + McpDateRangeSchema, + MutationResultSchema, + WebsiteSelectorSchema, + WorkflowFilterSchema, +} from "./tool-contracts"; + +function omitUndefined( + input: Record +): Record { + return Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined) + ); +} + +const getFunnelAnalyticsByReferrerTool = defineMcpTool( + { + name: "get_funnel_analytics_by_referrer", + description: + "Return funnel conversion analytics broken down by referrer/source. Use after list_funnels to see which sources convert best.", + inputSchema: McpDateRangeSchema.safeExtend({ + ...WebsiteSelectorSchema, + funnelId: z.string().describe("Funnel ID from list_funnels"), + }), + outputSchema: DynamicObjectSchema, + resolveWebsite: true, + ratelimit: { limit: 60, windowSec: 60 }, + }, + async (input, ctx) => + await callRPCProcedure( + "funnels", + "getAnalyticsByReferrer", + { + funnelId: input.funnelId, + websiteId: getResolvedWebsiteId(ctx), + startDate: input.from, + endDate: input.to, + }, + buildRpcContext(ctx) + ) +); + +const updateGoalTool = defineMcpTool( + { + name: "update_goal", + description: + "Update a conversion goal. Call with confirmed=false to preview changes, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + id: z.string(), + type: z.enum(["PAGE_VIEW", "EVENT", "CUSTOM"]).optional(), + target: z.string().min(1).optional(), + name: z.string().min(1).max(100).optional(), + description: z.string().nullable().optional(), + filters: z.array(WorkflowFilterSchema).optional(), + ignoreHistoricData: z.boolean().optional(), + isActive: z.boolean().optional(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current goal before an update, so read:data is required too. + metadata: metadataForResource("website", ["read", "update"]), + ratelimit: { limit: 20, windowSec: 60 }, + }, + async ({ confirmed, id, ...input }, ctx) => { + const updates = omitUndefined(input); + const rpcContext = buildRpcContext(ctx); + const current = await callRPCProcedure( + "goals", + "getById", + { id }, + rpcContext + ); + + if (!confirmed) { + return { + preview: true, + message: + Object.keys(updates).length > 0 + ? "Review this goal update before applying it." + : "No changes detected. The goal will remain unchanged.", + confirmationRequired: Object.keys(updates).length > 0, + current, + updates, + }; + } + + if (Object.keys(updates).length === 0) { + return { + preview: true, + message: "No changes detected. The goal will remain unchanged.", + confirmationRequired: false, + current, + }; + } + + const goal = await callRPCProcedure( + "goals", + "update", + { id, ...updates }, + rpcContext + ); + return { success: true, message: "Goal updated successfully.", goal }; + } +); + +const deleteGoalTool = defineMcpTool( + { + name: "delete_goal", + description: + "Delete a conversion goal. Call with confirmed=false to preview, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + id: z.string(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current goal before deletion, so read:data is required too. + metadata: metadataForResource("website", ["read", "delete"]), + ratelimit: { limit: 10, windowSec: 60 }, + }, + async ({ confirmed, id }, ctx) => { + const rpcContext = buildRpcContext(ctx); + const goal = await callRPCProcedure("goals", "getById", { id }, rpcContext); + if (!confirmed) { + return { + preview: true, + message: "Review this goal deletion before applying it.", + confirmationRequired: true, + goal, + }; + } + + await callRPCProcedure("goals", "delete", { id }, rpcContext); + return { success: true, message: "Goal deleted successfully." }; + } +); + +const updateAnnotationTool = defineMcpTool( + { + name: "update_annotation", + description: + "Update annotation text, tags, color, or visibility. Preview changes before applying them.", + inputSchema: z.object({ + id: z.string(), + text: z.string().min(1).max(500).optional(), + tags: z.array(z.string()).optional(), + color: z.string().optional(), + isPublic: z.boolean().optional(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current annotation before an update, so read:data is required too. + metadata: metadataForResource("website", ["read", "update"]), + ratelimit: { limit: 20, windowSec: 60 }, + }, + async ({ confirmed, id, ...input }, ctx) => { + const updates = omitUndefined(input); + const rpcContext = buildRpcContext(ctx); + const current = await callRPCProcedure( + "annotations", + "getById", + { id }, + rpcContext + ); + + if (!confirmed) { + return { + preview: true, + message: + Object.keys(updates).length > 0 + ? "Review this annotation update before applying it." + : "No changes detected. The annotation will remain unchanged.", + confirmationRequired: Object.keys(updates).length > 0, + current, + updates, + }; + } + + if (Object.keys(updates).length === 0) { + return { + preview: true, + message: "No changes detected. The annotation will remain unchanged.", + confirmationRequired: false, + current, + }; + } + + const annotation = await callRPCProcedure( + "annotations", + "update", + { id, ...updates }, + rpcContext + ); + return { + success: true, + message: "Annotation updated successfully.", + annotation, + }; + } +); + +const deleteAnnotationTool = defineMcpTool( + { + name: "delete_annotation", + description: + "Delete a chart annotation. Call with confirmed=false to preview, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + id: z.string(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + // Preview loads the current annotation before deletion, so read:data is required too. + metadata: metadataForResource("website", ["read", "delete"]), + ratelimit: { limit: 10, windowSec: 60 }, + }, + async ({ confirmed, id }, ctx) => { + const rpcContext = buildRpcContext(ctx); + const annotation = await callRPCProcedure( + "annotations", + "getById", + { id }, + rpcContext + ); + if (!confirmed) { + return { + preview: true, + message: "Review this annotation deletion before applying it.", + confirmationRequired: true, + annotation, + }; + } + + await callRPCProcedure("annotations", "delete", { id }, rpcContext); + return { success: true, message: "Annotation deleted successfully." }; + } +); + +const linkUpdateFields = { + name: z.string().min(1).max(255).optional(), + targetUrl: httpUrlSchema.optional(), + slug: z.string().min(3).max(50).regex(LINK_SLUG_REGEX).optional(), + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + expiredRedirectUrl: httpUrlSchema.nullable().optional(), + ogTitle: z.string().max(200).nullable().optional(), + ogDescription: z.string().max(500).nullable().optional(), + ogImageUrl: httpUrlSchema.nullable().optional(), + externalId: z.string().max(255).nullable().optional(), + ...LinkFolderSelectorSchema.shape, + deepLinkApp: z.enum(DEEP_LINK_APP_IDS).nullable().optional(), +}; + +const updateLinkTool = defineMcpTool( + { + name: "update_link", + description: + "Update a short link. Call with confirmed=false to preview changes, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + ...WebsiteSelectorSchema, + id: z.string(), + ...linkUpdateFields, + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + resolveWebsite: true, + metadata: metadataForResource("link", ["read", "update"]), + ratelimit: { limit: 20, windowSec: 60 }, + }, + async ({ confirmed, id, folderId, folderSlug, ...input }, ctx) => { + const organizationId = await getOrganizationId(getResolvedWebsiteId(ctx)); + if (organizationId instanceof Error) { + throw new McpToolError("not_found", organizationId.message); + } + + const rpcContext = buildRpcContext(ctx); + const [current, folders] = await Promise.all([ + callRPCProcedure("links", "get", { id, organizationId }, rpcContext).then( + parseLinkRow + ), + listLinkFolders(rpcContext, organizationId), + ]); + const folderSelection = resolveLinkFolderFromList(folders, { + folderId, + folderSlug, + }); + if (!folderSelection.ok) { + throw new McpToolError("invalid_input", folderSelection.message); + } + + const effectiveDeepLinkApp = + input.deepLinkApp === undefined ? current.deepLinkApp : input.deepLinkApp; + const effectiveTargetUrl = input.targetUrl ?? current.targetUrl; + if ( + effectiveDeepLinkApp && + !isDeepLinkTarget(effectiveDeepLinkApp, effectiveTargetUrl) + ) { + throw new McpToolError( + "invalid_input", + "Deep link URLs must use HTTPS and match the selected app." + ); + } + + const updates = omitUndefined({ + ...input, + ...(hasLinkFolderSelector({ folderId, folderSlug }) + ? { folderId: folderSelection.folderId } + : {}), + }); + + if (!confirmed) { + return { + preview: true, + message: + Object.keys(updates).length > 0 + ? "Review this short-link update before applying it." + : "No changes detected. The short link will remain unchanged.", + confirmationRequired: Object.keys(updates).length > 0, + current: summarizeLink(current, folders), + updates, + availableFolders: folderSelection.folders.map(summarizeLinkFolder), + }; + } + + if (Object.keys(updates).length === 0) { + return { + preview: true, + message: "No changes detected. The short link will remain unchanged.", + confirmationRequired: false, + current: summarizeLink(current, folders), + }; + } + + const link = parseLinkRow( + await callRPCProcedure("links", "update", { id, ...updates }, rpcContext) + ); + return { + success: true, + message: `Short link "${link.name}" updated successfully.`, + link: summarizeLink(link, folderSelection.folders), + }; + } +); + +const deleteLinkTool = defineMcpTool( + { + name: "delete_link", + description: + "Delete a short link. Call with confirmed=false to preview, then confirmed=true after explicit user approval.", + inputSchema: z.object({ + ...WebsiteSelectorSchema, + id: z.string(), + confirmed: ConfirmedSchema, + }), + outputSchema: MutationResultSchema, + resolveWebsite: true, + metadata: metadataForResource("link", ["read", "delete"]), + ratelimit: { limit: 10, windowSec: 60 }, + }, + async ({ confirmed, id }, ctx) => { + const organizationId = await getOrganizationId(getResolvedWebsiteId(ctx)); + if (organizationId instanceof Error) { + throw new McpToolError("not_found", organizationId.message); + } + + const rpcContext = buildRpcContext(ctx); + const [link, folders] = await Promise.all([ + callRPCProcedure("links", "get", { id, organizationId }, rpcContext).then( + parseLinkRow + ), + listLinkFolders(rpcContext, organizationId), + ]); + if (!confirmed) { + return { + preview: true, + message: "Review this short-link deletion before applying it.", + confirmationRequired: true, + link: summarizeLink(link, folders), + }; + } + + await callRPCProcedure("links", "delete", { id }, rpcContext); + return { + success: true, + message: `Short link "${link.name}" deleted successfully.`, + }; + } +); + +export function createMcpWorkspaceTools(): McpToolFactory[] { + return [ + getFunnelAnalyticsByReferrerTool, + updateGoalTool, + deleteGoalTool, + updateAnnotationTool, + deleteAnnotationTool, + updateLinkTool, + deleteLinkTool, + ]; +} diff --git a/packages/ai/src/lib/accessible-websites.ts b/packages/ai/src/lib/accessible-websites.ts index 6d6310861..ebd3ad67a 100644 --- a/packages/ai/src/lib/accessible-websites.ts +++ b/packages/ai/src/lib/accessible-websites.ts @@ -108,13 +108,19 @@ export async function getAccessibleWebsites( const ids = getAccessibleWebsiteIds(authCtx.apiKey).filter((id) => hasWebsiteScope(authCtx.apiKey, id, "read:data") ); - if (ids.length === 0) { + if (ids.length === 0 || !authCtx.apiKey.organizationId) { return []; } return db .select(select) .from(websites) - .where(and(inArray(websites.id, ids), isNull(websites.deletedAt))) + .where( + and( + eq(websites.organizationId, authCtx.apiKey.organizationId), + inArray(websites.id, ids), + isNull(websites.deletedAt) + ) + ) .orderBy((t) => t.createdAt); } diff --git a/packages/ai/src/lib/website-utils.ts b/packages/ai/src/lib/website-utils.ts index d6146d0fe..f5ec4632a 100644 --- a/packages/ai/src/lib/website-utils.ts +++ b/packages/ai/src/lib/website-utils.ts @@ -1,7 +1,6 @@ import { - getAccessibleWebsiteIds, getApiKeyFromHeader, - hasWebsiteScope, + hasWebsiteScopeForOrganization, isApiKeyPresent, type ApiKeyRow, } from "@databuddy/api-keys/resolve"; @@ -173,7 +172,7 @@ async function deriveWithApiKey(request: Request) { return { user: null, session: null, website: site, timezone } as const; } - const canRead = await hasWebsiteScope(key, siteId, "read:data"); + const canRead = hasWebsiteScopeForOrganization(key, site, "read:data"); if (!canRead) { if (isKnownWebsiteForKey(key, site)) { throw jsonError(403, "Insufficient permissions", "FORBIDDEN"); @@ -185,11 +184,7 @@ async function deriveWithApiKey(request: Request) { } function isKnownWebsiteForKey(key: ApiKeyRow, site: Website): boolean { - return ( - (key.organizationId != null && - key.organizationId === site.organizationId) || - getAccessibleWebsiteIds(key).includes(site.id) - ); + return key.organizationId === site.organizationId; } async function deriveWithSession(request: Request) { diff --git a/packages/ai/src/mcp/guide.ts b/packages/ai/src/mcp/guide.ts index dd3570910..46df72830 100644 --- a/packages/ai/src/mcp/guide.ts +++ b/packages/ai/src/mcp/guide.ts @@ -8,7 +8,7 @@ export const MCP_INSTRUCTIONS = `Databuddy gives agents product analytics and du - Use reply_to_investigation when a user answers a case's question or adds missing context. This resumes the same investigation. - After a queued reply, poll get_investigation and reuse the same replyId on retries. - Use capabilities only when you need to discover query types, and get_schema only when a field is uncertain. -- Every website tool accepts websiteId, websiteName, or websiteDomain. +- Most website-scoped tools accept websiteId, websiteName, or websiteDomain; tools that operate by a returned ID may not. - Use either a date preset or both from and to (YYYY-MM-DD). - Never invent a metric, cause, or action that the returned evidence does not support.`; @@ -43,5 +43,5 @@ Do not recreate an investigation with ad hoc anomaly math when a durable case al ## Mutations -Respect each tool's confirmation metadata and required API-key scopes. Read-only analytics requires \`read:data\`; replying to an investigation requires \`manage:websites\`. +Respect each tool's API-key scopes. Analytics reads require \`read:data\`; website writes and investigation replies require \`manage:websites\`; flag mutations require \`manage:flags\` (and website-scoped ones also require \`read:data\`); link catalog reads require \`read:links\`, while website-scoped link mutations require \`read:data\` plus \`write:links\` (and \`create_link\` also requires \`read:links\`). Preview goal, annotation, and link mutations with \`confirmed=false\`, then apply only after explicit approval with \`confirmed=true\`. `; diff --git a/packages/ai/src/mcp/http.ts b/packages/ai/src/mcp/http.ts index ab528723e..4a9dcfe32 100644 --- a/packages/ai/src/mcp/http.ts +++ b/packages/ai/src/mcp/http.ts @@ -1,7 +1,7 @@ import { getAccessibleWebsiteIds, - hasKeyScope, - hasWebsiteScope, + hasKeyAllScopes, + hasWebsiteAllScopes, } from "@databuddy/api-keys/resolve"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -16,63 +16,40 @@ import type { import { createMcpTools } from "../ai/mcp/tools"; import { GUIDE_MARKDOWN, GUIDE_URI, MCP_INSTRUCTIONS } from "./guide"; -const DEFAULT_MCP_SERVER_NAME = "databuddy"; -const DEFAULT_MCP_SERVER_VERSION = "1.0.0"; - export interface DatabuddyMcpHttpOptions extends McpRequestContext { request: Request; - serverName?: string; - serverVersion?: string; } -const UNAUTH_BODY_PARSE_CAP = 4096; - -export async function createMcpUnauthorizedResponse( - request: Request, - options?: { resourceMetadataUrl?: string } -): Promise { +export function createMcpUnauthorizedResponse(): Response { mergeWideEvent({ mcp_auth: "unauthorized" }); - const resourceMetadata = options?.resourceMetadataUrl - ? `, resource_metadata="${options.resourceMetadataUrl}"` - : ""; - return Response.json( { jsonrpc: "2.0", error: { code: -32_001, message: - "Authentication required. Use x-api-key or Authorization: Bearer with a key that has read:data scope.", + "Authentication required. Use x-api-key or Authorization: Bearer with a valid Databuddy API key.", }, - id: shouldReadUnauthId(request) ? await readJsonRpcId(request) : null, + id: null, }, { status: 401, headers: { - "WWW-Authenticate": `Bearer realm="databuddy", error="invalid_token", error_description="API key required (x-api-key or Authorization: Bearer)"${resourceMetadata}`, + "WWW-Authenticate": + 'Bearer realm="databuddy", error="invalid_token", error_description="API key required (x-api-key or Authorization: Bearer)"', }, } ); } -function shouldReadUnauthId(request: Request): boolean { - const contentType = request.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().includes("application/json")) { - return false; - } - const length = Number.parseInt( - request.headers.get("content-length") ?? "", - 10 - ); - return ( - Number.isFinite(length) && length > 0 && length <= UNAUTH_BODY_PARSE_CAP - ); -} - export async function handleDatabuddyMcpRequest( options: DatabuddyMcpHttpOptions ): Promise { + if (options.request.method !== "POST") { + return new Response(null, { status: 405, headers: { Allow: "POST" } }); + } + mergeWideEvent({ mcp_auth: options.userId ? "session" : "api_key", mcp_session: Boolean(options.userId), @@ -81,11 +58,10 @@ export async function handleDatabuddyMcpRequest( const server = new McpServer( { - name: options.serverName ?? DEFAULT_MCP_SERVER_NAME, - version: options.serverVersion ?? DEFAULT_MCP_SERVER_VERSION, + name: "databuddy", + version: "1.0.0", }, { - capabilities: { tools: {}, resources: {} }, instructions: MCP_INSTRUCTIONS, } ); @@ -93,9 +69,22 @@ export async function handleDatabuddyMcpRequest( registerGuideResource(server); for (const tool of createMcpTools(options)) { - if (apiKeyCanCallTool(options.apiKey, tool)) { - registerTool(server, tool); + if (!apiKeyCanCallTool(options.apiKey, tool)) { + continue; } + server.registerTool( + tool.name, + { + title: titleFromName(tool.name), + description: tool.description, + inputSchema: toMcpSchema(tool.inputSchema), + ...(tool.outputSchema && { + outputSchema: toMcpSchema(tool.outputSchema), + }), + annotations: deriveAnnotations(tool.metadata), + }, + tool.handler + ); } const transport = new WebStandardStreamableHTTPServerTransport({ @@ -126,27 +115,18 @@ function apiKeyCanCallTool( // Session-authenticated callers fall through to downstream role checks. return true; } - if (required.every((scope) => hasKeyScope(apiKey, scope))) { + const globalScopes = tool.metadata.access.globalScopes; + if (globalScopes.length && !hasKeyAllScopes(apiKey, globalScopes)) { + return false; + } + const websiteScopes = required.filter( + (scope) => !globalScopes.includes(scope) + ); + if (!websiteScopes.length || hasKeyAllScopes(apiKey, websiteScopes)) { return true; } return getAccessibleWebsiteIds(apiKey).some((websiteId) => - required.every((scope) => hasWebsiteScope(apiKey, websiteId, scope)) - ); -} - -function registerTool(server: McpServer, tool: RegisteredMcpTool): void { - server.registerTool( - tool.name, - { - title: titleFromName(tool.name), - description: tool.description, - inputSchema: toMcpSchema(tool.inputSchema), - ...(tool.outputSchema && { - outputSchema: toMcpSchema(tool.outputSchema), - }), - annotations: deriveAnnotations(tool.metadata), - }, - tool.handler + hasWebsiteAllScopes(apiKey, websiteId, websiteScopes) ); } @@ -159,12 +139,10 @@ function titleFromName(name: string): string { function deriveAnnotations(metadata: McpToolMetadata): ToolAnnotations { const isRead = metadata.access.kind === "read"; - const requiresConfirmation = metadata.access.confirmation === "required"; return { readOnlyHint: isRead, - destructiveHint: !isRead && requiresConfirmation, + destructiveHint: !isRead, idempotentHint: isRead, - openWorldHint: true, }; } @@ -194,16 +172,3 @@ function toMcpSchema(schema: RegisteredMcpTool["inputSchema"]): AnySchema { // The MCP SDK's zod-compat type targets a different Zod surface than this repo's Zod v4 types. return schema as unknown as AnySchema; } - -async function readJsonRpcId( - request: Request -): Promise { - try { - const body = (await request.clone().json()) as { id?: unknown }; - return typeof body.id === "string" || typeof body.id === "number" - ? body.id - : null; - } catch { - return null; - } -} diff --git a/packages/api-keys/src/resolve.test.ts b/packages/api-keys/src/resolve.test.ts index b3949e755..c0d3836bc 100644 --- a/packages/api-keys/src/resolve.test.ts +++ b/packages/api-keys/src/resolve.test.ts @@ -1,4 +1,5 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { ApiKeyRow } from "./resolve"; interface SqlFragment { text: string; @@ -101,6 +102,7 @@ mock.module("@databuddy/redis", () => ({ const { API_KEY_LOOKUP_TIMEOUT_MS, API_KEY_STATEMENT_TIMEOUT_MS, + hasWebsiteScopeForOrganization, resolveApiKeySecret, } = await import("./resolve"); @@ -181,3 +183,53 @@ describe("API key database deadline", () => { expect(findApiKey).toHaveBeenCalledTimes(1); }); }); + +describe("website-scoped API keys", () => { + test("cannot use a resource entry to cross an organization boundary", () => { + const key = { + organizationId: "org-a", + scopes: [], + metadata: { resources: { "website:site-b": ["read:data"] } }, + } as unknown as ApiKeyRow; + + expect( + hasWebsiteScopeForOrganization( + key, + { id: "site-b", organizationId: "org-b" }, + "read:data" + ) + ).toBe(false); + }); + + test("accepts a resource entry for the key's own organization", () => { + const key = { + organizationId: "org-a", + scopes: [], + metadata: { resources: { "website:site-a": ["read:data"] } }, + } as unknown as ApiKeyRow; + + expect( + hasWebsiteScopeForOrganization( + key, + { id: "site-a", organizationId: "org-a" }, + "read:data" + ) + ).toBe(true); + }); + + test("preserves global scopes within the key's own organization", () => { + const key = { + organizationId: "org-a", + scopes: ["read:data"], + metadata: {}, + } as unknown as ApiKeyRow; + + expect( + hasWebsiteScopeForOrganization( + key, + { id: "site-a", organizationId: "org-a" }, + "read:data" + ) + ).toBe(true); + }); +}); diff --git a/packages/api-keys/src/resolve.ts b/packages/api-keys/src/resolve.ts index 8402963d6..93a7b1366 100644 --- a/packages/api-keys/src/resolve.ts +++ b/packages/api-keys/src/resolve.ts @@ -266,6 +266,23 @@ export function hasWebsiteScope( return hasKeyScope(key, required, `website:${websiteId}`); } +/** + * Checks a website scope only after binding the website to the key's workspace. + * Resource metadata is user input, so its `website:` key is not proof of + * ownership by itself. + */ +export function hasWebsiteScopeForOrganization( + key: ApiKeyRow | null, + website: { id: string; organizationId: string | null }, + required: string +): boolean { + return Boolean( + key?.organizationId && + key.organizationId === website.organizationId && + hasWebsiteScope(key, website.id, required) + ); +} + export function hasWebsiteAnyScope( key: ApiKeyRow | null, websiteId: string, diff --git a/packages/api-keys/src/scopes.test.ts b/packages/api-keys/src/scopes.test.ts index 2a244f613..3b51d62cd 100644 --- a/packages/api-keys/src/scopes.test.ts +++ b/packages/api-keys/src/scopes.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { requiredScopesForResource } from "./scopes"; +import { + apiKeyScopeTargetForResource, + requiredScopesForResource, +} from "./scopes"; describe("requiredScopesForResource", () => { test("website read requires read:data", () => { @@ -54,6 +57,11 @@ describe("flag resource scopes", () => { }); describe("link resource scopes", () => { + test("uses global API-key scopes", () => { + expect(apiKeyScopeTargetForResource("link")).toBe("global"); + expect(apiKeyScopeTargetForResource("website")).toBe("website"); + }); + test("read requires read:links", () => { expect(requiredScopesForResource("link", ["read"])).toEqual([ "read:links", diff --git a/packages/api-keys/src/scopes.ts b/packages/api-keys/src/scopes.ts index abfedeaa2..1b83037d4 100644 --- a/packages/api-keys/src/scopes.ts +++ b/packages/api-keys/src/scopes.ts @@ -11,6 +11,8 @@ type PermissionName = | "cancel" | "manage"; +export type ApiKeyScopeTarget = "global" | "website"; + const DEFAULT_SCOPE_MAP: Record = { read: "read:data", view_analytics: "read:data", @@ -78,3 +80,11 @@ export function requiredScopesForResource( return [...scopes]; } + +/** The metadata namespace where a resource's API-key scopes are evaluated. */ +export function apiKeyScopeTargetForResource( + resource: string +): ApiKeyScopeTarget { + // Links belong to an organization, not to an individual website. + return resource === "link" ? "global" : "website"; +} diff --git a/packages/env/src/app.test.ts b/packages/env/src/app.test.ts index ca9179019..819eb7de5 100644 --- a/packages/env/src/app.test.ts +++ b/packages/env/src/app.test.ts @@ -9,6 +9,7 @@ describe("createConfig", () => { basket: "http://localhost:4000", dashboard: "http://localhost:3000", links: "http://localhost:2500", + mcp: "http://localhost:3001/v1/mcp/", status: "http://localhost:3002", }, }); @@ -21,6 +22,7 @@ describe("createConfig", () => { basket: "https://basket.databuddy.cc", dashboard: "https://app.databuddy.cc", links: "https://dby.sh", + mcp: "https://api.databuddy.cc/v1/mcp/", status: "https://status.databuddy.cc", }, }); @@ -37,6 +39,7 @@ describe("createConfig", () => { urls: { api: "https://api.example.com", dashboard: "https://app.example.com", + mcp: "https://api.example.com/v1/mcp/", }, }); }); diff --git a/packages/env/src/app.ts b/packages/env/src/app.ts index dd1ea6488..da715ca3c 100644 --- a/packages/env/src/app.ts +++ b/packages/env/src/app.ts @@ -35,6 +35,8 @@ const URLS = { }, } as const; +const MCP_SERVER_PATH = "/v1/mcp/"; + // Email sender defaults. Env fallback order works the same way as URLS. const EMAIL = { alertsFrom: { @@ -70,6 +72,7 @@ export interface Config { basket: string; dashboard: string; links: string; + mcp: string; status: string; }; } @@ -127,6 +130,7 @@ function readOrigins(values: Array): string[] { export function createConfig(env: Env = process.env): Config { const dashboardUrl = readUrl(env, URLS.dashboard); + const apiUrl = readUrl(env, URLS.api); return { cors: { @@ -145,10 +149,11 @@ export function createConfig(env: Env = process.env): Config { openAiAdsPixelId: readOptional(env, "NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID"), }, urls: { - api: readUrl(env, URLS.api), + api: apiUrl, basket: readUrl(env, URLS.basket), dashboard: dashboardUrl, links: readUrl(env, URLS.links), + mcp: new URL(MCP_SERVER_PATH, apiUrl).toString(), status: readUrl(env, URLS.status), }, }; diff --git a/packages/rpc/src/routers/apikeys.resource-ownership.test.ts b/packages/rpc/src/routers/apikeys.resource-ownership.test.ts new file mode 100644 index 000000000..c811ab578 --- /dev/null +++ b/packages/rpc/src/routers/apikeys.resource-ownership.test.ts @@ -0,0 +1,179 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { createProcedureClient } from "@orpc/server"; +import { createKeys } from "keypal"; +import type { Context } from "../orpc"; + +const ORGANIZATION_A = "org-a"; +const WEBSITE_A = "site-a"; +const WEBSITE_B = "site-b"; + +const testKeys = createKeys({ prefix: "dbdy_", length: 48 }); +const mockWithWorkspace = mock(async () => ({ + organizationId: "org-a", + role: "admin", +})); +const mockAppendRpcAuditEvent = mock(async () => undefined); + +mock.module("@databuddy/auth", () => ({ + auth: { api: { getSession: async () => null } }, +})); +mock.module("@databuddy/api-keys/resolve", () => ({ + collectScopes: (key: { scopes: string[] }) => key.scopes, + getApiKeyFromHeader: async () => null, + keys: testKeys, + markApiKeyUsed: async () => undefined, + withApiKeyCacheInvalidation: async ( + _hashes: Array, + operation: () => Promise + ) => operation(), +})); +mock.module("../procedures/with-workspace", () => ({ + withWorkspace: mockWithWorkspace, +})); +mock.module("../lib/audit", () => ({ + appendRpcAuditEvent: mockAppendRpcAuditEvent, + getAuditActor: () => ({ id: "user-a", type: "user" }), + getAuditOrganizationId: () => ORGANIZATION_A, + getAuditRequestContext: () => ({}), +})); + +const { apikeysRouter } = await import("./apikeys"); + +function call(procedure: T, context: Context) { + return createProcedureClient(procedure as never, { context }); +} + +function apiKeyRow() { + const now = new Date("2026-08-21T00:00:00.000Z"); + return { + createdAt: now, + enabled: true, + expiresAt: null, + id: "key-a", + keyHash: "hash-a", + lastUsedAt: null, + metadata: {}, + name: "Existing key", + organizationId: ORGANIZATION_A, + prefix: "dbdy", + rateLimitEnabled: true, + rateLimitMax: null, + rateLimitTimeWindow: null, + revokedAt: null, + scopes: [], + start: "dbdy_abc", + type: "user" as const, + updatedAt: now, + userId: null, + }; +} + +function contextWithMatchedWebsites(matchedWebsiteIds: string[]): Context { + const key = apiKeyRow(); + const database = { + query: { + apikey: { + findFirst: async () => key, + }, + }, + select: () => ({ + from: () => ({ + where: async () => matchedWebsiteIds.map((id) => ({ id })), + }), + }), + transaction: async ( + callback: (transaction: { + insert: () => { + values: (values: Record) => { + returning: () => Promise[]>; + }; + }; + }) => Promise + ) => + callback({ + insert: () => ({ + values: (values) => ({ + returning: async () => [values], + }), + }), + }), + }; + + return { + auditOrganizationId: undefined, + anonymousId: null, + apiKey: undefined, + db: database, + getBilling: async () => undefined, + headers: new Headers(), + organizationId: ORGANIZATION_A, + session: undefined, + sessionId: null, + user: { + email: "admin@example.com", + id: "user-a", + name: "Admin", + }, + } as Context; +} + +describe("apikeys website resource ownership", () => { + beforeEach(() => { + mockWithWorkspace.mockClear(); + mockAppendRpcAuditEvent.mockClear(); + }); + + it("rejects create when a selected organization claims another organization's website", async () => { + await expect( + call( + apikeysRouter.create, + contextWithMatchedWebsites([]) + )({ + name: "Foreign website key", + organizationId: ORGANIZATION_A, + resources: { [`website:${WEBSITE_B}`]: ["read:data"] }, + scopes: [], + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "API key website resources must belong to the selected organization", + }); + }); + + it("rejects update when an existing key claims another organization's website", async () => { + await expect( + call( + apikeysRouter.update, + contextWithMatchedWebsites([]) + )({ + id: "key-a", + resources: { [`website:${WEBSITE_B}`]: ["read:data"] }, + }) + ).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: + "API key website resources must belong to the selected organization", + }); + }); + + it("allows create for a website that belongs to the selected organization", async () => { + const result = await call( + apikeysRouter.create, + contextWithMatchedWebsites([WEBSITE_A]) + )({ + name: "Owned website key", + organizationId: ORGANIZATION_A, + resources: { [`website:${WEBSITE_A}`]: ["read:data"] }, + scopes: [], + }); + + expect(result.id).toBeString(); + expect(result.secret).toStartWith("dbdy_"); + expect(mockAppendRpcAuditEvent).toHaveBeenCalledTimes(1); + }); +}); + +afterAll(() => { + mock.restore(); +}); diff --git a/packages/rpc/src/routers/apikeys.ts b/packages/rpc/src/routers/apikeys.ts index ce6f48994..805065c66 100644 --- a/packages/rpc/src/routers/apikeys.ts +++ b/packages/rpc/src/routers/apikeys.ts @@ -6,8 +6,8 @@ import { withApiKeyCacheInvalidation, } from "@databuddy/api-keys/resolve"; import { API_SCOPES } from "@databuddy/api-keys/scopes"; -import { desc, eq } from "@databuddy/db"; -import { apikey } from "@databuddy/db/schema"; +import { and, desc, eq, inArray, isNull } from "@databuddy/db"; +import { apikey, websites } from "@databuddy/db/schema"; import { auditActions } from "@databuddy/shared/audit"; import { ApiKeyErrorCode, @@ -56,6 +56,45 @@ function assertMetadataSize(meta: Record) { } } +async function assertResourceOwnership( + ctx: Context, + organizationId: string, + resources: Record | undefined +) { + const websiteIds = Object.keys(resources ?? {}).flatMap((resource) => { + if (!resource.startsWith("website:")) { + return []; + } + const websiteId = resource.slice("website:".length); + if (!websiteId) { + throw rpcError.badRequest( + "API key website resource scopes require a website ID" + ); + } + return [websiteId]; + }); + + if (websiteIds.length === 0) { + return; + } + + const ownedWebsites = await ctx.db + .select({ id: websites.id }) + .from(websites) + .where( + and( + eq(websites.organizationId, organizationId), + inArray(websites.id, websiteIds), + isNull(websites.deletedAt) + ) + ); + if (ownedWebsites.length !== websiteIds.length) { + throw rpcError.badRequest( + "API key website resources must belong to the selected organization" + ); + } +} + const rateLimitSchema = z.object({ enabled: z.boolean().optional(), max: z.number().int().positive().nullable().optional(), @@ -305,6 +344,11 @@ export const apikeysRouter = { "Change API key scopes" ); } + await assertResourceOwnership( + context, + input.organizationId, + input.resources + ); const nextMetadata = { resources: input.resources, @@ -416,6 +460,13 @@ export const apikeysRouter = { "Change API key scopes" ); } + if (input.resources !== undefined && input.resources !== null) { + await assertResourceOwnership( + context, + key.organizationId, + input.resources + ); + } const nextMetadata = { ...meta, @@ -573,6 +624,7 @@ export const apikeysRouter = { throw rpcError.internal("Organization key required for rotate"); } await assertOrgAdmin(context, ownerId, "Rotate API keys"); + await assertResourceOwnership(context, ownerId, meta.resources); const { key: secret, record } = await keys.create({ ownerId, diff --git a/packages/rpc/src/routers/insight-generation.ts b/packages/rpc/src/routers/insight-generation.ts index 8e03d832b..c7f57043d 100644 --- a/packages/rpc/src/routers/insight-generation.ts +++ b/packages/rpc/src/routers/insight-generation.ts @@ -383,12 +383,12 @@ async function resolveOrganization( if (!organizationId) { throw rpcError.badRequest("Organization ID is required"); } - setAuditOrganization(context, organizationId); await withWorkspace(context, { organizationId, resource: "organization", permissions: [permission], }); + setAuditOrganization(context, organizationId); return organizationId; } diff --git a/packages/rpc/src/routers/insights.ts b/packages/rpc/src/routers/insights.ts index 6c6c80b66..5c924af4f 100644 --- a/packages/rpc/src/routers/insights.ts +++ b/packages/rpc/src/routers/insights.ts @@ -508,14 +508,13 @@ export async function appendInvestigationReply( if (!insight) { throw rpcError.notFound("insight", parsed.insightId); } - setAuditOrganization(context, insight.organizationId); - await withWorkspace(context, { allowCrossOrg: true, organizationId: insight.organizationId, permissions: ["update"], websiteId: insight.websiteId, }); + setAuditOrganization(context, insight.organizationId); const author = replyAuthor(context, authorName); const createdAt = new Date(); @@ -726,8 +725,6 @@ export async function applyInsightAction(input: { if (!target) { throw rpcError.notFound("insight", parsed.insightId); } - setAuditOrganization(context, target.organizationId); - const [latestObservation] = await db .select({ outcome: insightObservations.outcome, @@ -763,6 +760,7 @@ export async function applyInsightAction(input: { permissions: initialAction.operation === "delete" ? ["delete"] : ["update"], websiteId: target.websiteId, }); + setAuditOrganization(context, target.organizationId); const author = replyAuthor(context); const completed = await db.transaction(async (tx) => { @@ -1664,13 +1662,13 @@ export const insightsRouter = { if (!reply) { throw rpcError.notFound("insight reply", input.replyId); } - setAuditOrganization(context, reply.organizationId); await withWorkspace(context, { allowCrossOrg: true, organizationId: reply.organizationId, permissions: ["update"], websiteId: reply.websiteId, }); + setAuditOrganization(context, reply.organizationId); const pendingStatus = await db.transaction(async (tx) => { const insightCase = and( eq(analyticsInsights.organizationId, reply.organizationId), diff --git a/packages/sdk/src/core/flags/flags-manager.ts b/packages/sdk/src/core/flags/flags-manager.ts index e6837f0b4..b46d3915c 100644 --- a/packages/sdk/src/core/flags/flags-manager.ts +++ b/packages/sdk/src/core/flags/flags-manager.ts @@ -850,7 +850,14 @@ export class BrowserFlagsManager extends BaseFlagsManager { } protected override onFlagEvaluated(key: string, result: FlagResult): void { - const dedupeKey = `${key}:${String(result.value)}`; + let valueKey: string; + try { + valueKey = JSON.stringify(result.value) ?? String(result.value); + } catch { + // A malformed custom value should not prevent telemetry. + valueKey = String(result.value); + } + const dedupeKey = `${key}:${result.variant ?? ""}:${valueKey}`; if (this.trackedFlags.has(dedupeKey)) { return; } diff --git a/packages/shared/src/agent-discovery.test.ts b/packages/shared/src/agent-discovery.test.ts index 55aab1593..6d16116fb 100644 --- a/packages/shared/src/agent-discovery.test.ts +++ b/packages/shared/src/agent-discovery.test.ts @@ -3,6 +3,7 @@ import { API_SCOPES } from "./api-scopes"; import { type AgentDiscoveryUrls, createAuthorizationServerMetadata, + createMcpManifest, createMcpServerCard, parseNlwebAskBody, } from "./agent-discovery"; @@ -41,6 +42,21 @@ describe("agent discovery builders", () => { ]); }); + it("keeps API-key MCP discovery free of unimplemented OAuth metadata", () => { + const manifest = createMcpManifest(urls); + const card = createMcpServerCard(urls); + + expect( + Object.hasOwn( + manifest.authentication, + "protected_resource_metadata_url" + ) + ).toBe(false); + expect( + Object.hasOwn(card.authentication, "protectedResourceMetadataUrl") + ).toBe(false); + }); + it("parses NLWeb ask bodies without casts", () => { expect( parseNlwebAskBody({ diff --git a/packages/shared/src/agent-discovery.ts b/packages/shared/src/agent-discovery.ts index b1ed3f29f..783a52223 100644 --- a/packages/shared/src/agent-discovery.ts +++ b/packages/shared/src/agent-discovery.ts @@ -196,7 +196,6 @@ export function createMcpManifest(urls: AgentDiscoveryUrls) { name: "x-api-key", documentation_url: `${resolved.siteUrl}/docs/api/authentication`, auth_md_url: resolved.authMdUrl, - protected_resource_metadata_url: resolved.protectedResourceMetadataUrl, scopes: API_SCOPES, }, capabilities: { @@ -254,7 +253,6 @@ export function createMcpServerCard(urls: AgentDiscoveryUrls) { type: "api_key", header: "x-api-key", documentationUrl: resolved.authMdUrl, - protectedResourceMetadataUrl: resolved.protectedResourceMetadataUrl, scopes: API_SCOPES, }, resources: [