diff --git a/.changeset/add-dbt-mcp-preset.md b/.changeset/add-dbt-mcp-preset.md new file mode 100644 index 00000000000..ab680a506d8 --- /dev/null +++ b/.changeset/add-dbt-mcp-preset.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": patch +--- + +Add a dbt hosted MCP integration preset with organization-scoped manual setup for account-specific endpoints and service-token headers, including parity for source-ejected integration catalogs. diff --git a/packages/core/agent-native.eject.json b/packages/core/agent-native.eject.json index 1e02c983860..6cb8862856f 100644 --- a/packages/core/agent-native.eject.json +++ b/packages/core/agent-native.eject.json @@ -15,6 +15,7 @@ "catalog": "remote-mcp-presets", "catalogItems": [ "context7", + "dbt", "sentry", "notion", "granola", diff --git a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts index ef6938da081..72256c7bbdf 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.spec.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.spec.ts @@ -102,6 +102,43 @@ describe("MCP integration catalog", () => { ).toEqual(["sentry"]); }); + it("registers an editable organization-scoped dbt hosted MCP preset", () => { + const dbt = DEFAULT_MCP_INTEGRATIONS.find( + (integration) => integration.id === "dbt", + )!; + + for (const query of ["dbt", "MetricFlow", "Semantic Layer", "lineage"]) { + expect(filterMcpIntegrations(query).map((item) => item.id)).toContain( + "dbt", + ); + } + expect(dbt).toMatchObject({ + url: "https:///api/ai/v1/mcp/", + authMode: "headers", + connectionMode: "manual", + availability: "provider-setup", + verification: "restricted", + supportsOrganizationScope: true, + docsUrl: "https://docs.getdbt.com/docs/dbt-ai/mcp-quickstart-remote", + setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", + headerPlaceholder: + "Authorization: Token \nx-dbt-prod-environment-id: ", + }); + expect(createMcpIntegrationFormDefaults(dbt)).toEqual({ + name: "dbt", + url: "https:///api/ai/v1/mcp/", + description: "Explore governed dbt metadata, lineage, and metrics.", + headersText: "", + }); + expect(supportsMcpIntegrationOrganizationScope(dbt)).toBe(true); + expect(shouldOfferMcpIntegrationOrganizationScope(dbt, true, true)).toBe( + true, + ); + expect(shouldOfferMcpIntegrationOrganizationScope(dbt, true, false)).toBe( + false, + ); + }); + it("prefills form values from a selected preset without fabricating headers", () => { const sentry = DEFAULT_MCP_INTEGRATIONS.find( (integration) => integration.id === "sentry", @@ -177,11 +214,11 @@ describe("MCP integration catalog", () => { }); expect(getMcpIntegrationApiFallback(figma, "analytics")).toBeNull(); expect(getMcpIntegrationApiFallback(figma, null)).toBeNull(); - expect(DEFAULT_MCP_INTEGRATIONS).toHaveLength(35); + expect(DEFAULT_MCP_INTEGRATIONS).toHaveLength(36); expect( new Set(DEFAULT_MCP_INTEGRATIONS.map((integration) => integration.id)) .size, - ).toBe(35); + ).toBe(36); for (const integration of DEFAULT_MCP_INTEGRATIONS) { expect(integration.logoUrl).toMatch( /^data:image\/(?:png|svg\+xml|x-icon|vnd\.microsoft\.icon)(?:;base64,|,)/, diff --git a/packages/core/src/client/resources/mcp-integration-catalog.ts b/packages/core/src/client/resources/mcp-integration-catalog.ts index 2c3fbe143f7..1c49e73e334 100644 --- a/packages/core/src/client/resources/mcp-integration-catalog.ts +++ b/packages/core/src/client/resources/mcp-integration-catalog.ts @@ -478,6 +478,37 @@ export const DEFAULT_MCP_INTEGRATIONS: DefaultMcpIntegration[] = [ "Grafana Cloud", ], }, + { + id: "dbt", + name: "dbt", + provider: "dbt", + description: "Explore governed dbt metadata, lineage, and metrics.", + descriptionKey: "mcpIntegrations.catalog.dbt.description", + useCase: + "data models, sources, lineage, model health, Semantic Layer, MetricFlow", + useCaseKey: "mcpIntegrations.catalog.dbt.useCase", + url: "https:///api/ai/v1/mcp/", + authMode: "headers", + connectionMode: "manual", + availability: "provider-setup", + verification: "restricted", + logoUrl: mcpIntegrationLogo("dbt"), + supportsOrganizationScope: true, + docsUrl: "https://docs.getdbt.com/docs/dbt-ai/mcp-quickstart-remote", + setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", + headerPlaceholder: + "Authorization: Token \nx-dbt-prod-environment-id: ", + aliases: ["MetricFlow", "Semantic Layer", "lineage"], + keywords: [ + "dbt", + "MetricFlow", + "Semantic Layer", + "lineage", + "models", + "sources", + "metrics", + ], + }, { id: "gitlab", name: "GitLab", @@ -1027,6 +1058,7 @@ const MCP_LINK_HOSTS: Record = { sentry: ["sentry.io", "sentry.dev"], gong: ["gong.io"], grafana: ["grafana.com", "grafana.net"], + dbt: ["getdbt.com"], "builder-cms": ["builder.io"], notion: ["notion.so", "notion.site"], granola: ["granola.ai"], diff --git a/packages/core/src/client/resources/mcp-integration-logos.ts b/packages/core/src/client/resources/mcp-integration-logos.ts index 3b7a5e9dae1..80c41092766 100644 --- a/packages/core/src/client/resources/mcp-integration-logos.ts +++ b/packages/core/src/client/resources/mcp-integration-logos.ts @@ -1,4 +1,8 @@ const LOGOS = { + dbt: { + mime: "image/x-icon", + data: "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", + }, context7: { mime: "image/vnd.microsoft.icon", data: "iVBORw0KGgoAAAANSUhEUgAAAEoAAABLCAYAAADXjBHUAAAACXBIWXMAAAsTAAALEwEAmpwYAAABaWlDQ1BEaXNwbGF5IFAzAAB4nHWQvUvDUBTFT6tS0DqIDh0cMolD1NIKdnFoKxRFMFQFq1OafgltfCQpUnETVyn4H1jBWXCwiFRwcXAQRAcR3Zw6KbhoeN6XVNoi3sfl/Ticc7lcwBtQGSv2AijplpFMxKS11Lrke4OHnlOqZrKooiwK/v276/PR9d5PiFlNu3YQ2U9cl84ul3aeAlN//V3Vn8maGv3f1EGNGRbgkYmVbYsJ3iUeMWgp4qrgvMvHgtMunzuelWSc+JZY0gpqhrhJLKc79HwHl4plrbWD2N6f1VeXxRzqUcxhEyYYilBRgQQF4X/8044/ji1yV2BQLo8CLMpESRETssTz0KFhEjJxCEHqkLhz634PrfvJbW3vFZhtcM4v2tpCAzidoZPV29p4BBgaAG7qTDVUR+qh9uZywPsJMJgChu8os2HmwiF3e38M6Hvh/GMM8B0CdpXzryPO7RqFn4Er/QcXKWq8MSlPPgAABA5JREFUeAHtmzFME2EUgB+GQXCgGCSBpeiICRjERROKoYtG4kANC6kDasLCAgNLO+BcBxgYxIHiZhkgRJcahcgmJB1k1HaBBBLtomHTezXVu/+u9N3de03Q9yUM/cPlb7/+79279/9tap4bfwdKXZqtv2FQ6nIOFBIqioiKIqKiiKgoIiqKiIoioqKIqCgiKoqIiiKioog0Q0hil3th+cEU+GX78z5M5pZ8XZObmIX+7ij4JbGagcJhEcIQWhTS034J/FIKcE2kpTXQXHhdWDT0iKgoIqFDr3BYguK3Y0dIlKzXK7tbp16H/+OX7N4WbFm57TSS12Ou9xJkLpPQoson3+GRlZTzj9N/xqLWGy2f/IDFndfASbaO/NRIwpXDxl5mKl9kWFhCD7/lmc2sY+zZvSTErvRCo+jv7oF0POEYe/o2B4WDInDAlqMWrNVjhsWLxBREzl8AaXCO3MSMY2x9/yPM53PABWsyx7oIQ64KhmAqPgbS4BxmyM1urgAnrKIwaS58cOal6Vt3ob+rB6TALwPnsIMhx5GX7LCXB4s7bxyrCslY+UqK9EjCNVYv6QeBXRTeBbO77x1jmNQlVhWuJiwH7KAk7tWEiBScG1YiNUkODAE3nqtpj381ISKiCgclV/glB4eBmyGP8qNeQRoUEVEYfqWvR46xyPlW1roKuxbmnU5KEiL2rIePNiZ9jHmqzyowTUoCuamKmCivhHqty38vqRa4oihzciEmqlR2v+kgTbdaePWYzuSK8oLzcSYaoIEXhoaKamvhExVpkX+GtNPUPDf+E5S6aIeTiIoioqKIqCgiKooIywaoF1jneFXPXE/3yYGYa2z7y75YdS4mCh+AsWduBytnLlGpuHvHZfLVkiXqDLVZ/kXERLV5PK5whgX2vFxzClbrYqJ62jtcY1x7bEipfOSeM9IBUoiJikY6XWOYbLnw6ndFL3aCFGKivFoqXh8uKBuf3H15zn6XiYgoLA282rScOQrbzWbrtzJvRKb9IiLKqzcusde27rHbM3r1BkggImr65h3Ha876yc6qJd/c7bnfOwgSsIvCarzfaPxzHpawg+FnbuHjapY4RcMuytw+l1pNVXAL38x9KY+N0bCwisLt7UatpirVg2x2cEV5PQuGgU1U5YiP8U3iiTvJ1VQF737m6b7M6MNAJ4hrwSZqbWLGdXZyPr8GjQLnsocg7kwvJ/yff68FiyhcSfaQQ0nx5/OVsGgUONfvOf/eBTEEuY4chd6Fwbtc/kna93UYLvjB/IAHaoPc0QYX5kL/cuG/aLPoLxcaiIoiEroVXCwfB6qVpH65wDWXiW6pE9HQI6KiiKgoIiqKiIoioqKIqCgiKoqIiiKiooioKCIqiojVPWi6DUpdfgE8wFukKedIwQAAAABJRU5ErkJggg==", diff --git a/packages/core/src/eject/catalog-wrappers.spec.ts b/packages/core/src/eject/catalog-wrappers.spec.ts index 1ec0b655263..149b210be70 100644 --- a/packages/core/src/eject/catalog-wrappers.spec.ts +++ b/packages/core/src/eject/catalog-wrappers.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_MCP_INTEGRATIONS as RUNTIME_DEFAULT_MCP_INTEGRATIONS } from "../client/resources/mcp-integration-catalog.js"; import { getProviderApiConfig, listProviderApiCatalog, @@ -20,6 +21,26 @@ describe("ejected catalog wrappers", () => { vi.unstubAllGlobals(); }); + it("keeps the dbt preset identical in runtime and ejected catalogs", () => { + const runtimeDbt = RUNTIME_DEFAULT_MCP_INTEGRATIONS.find( + (integration) => integration.id === "dbt", + ); + const ejectedDbt = DEFAULT_MCP_INTEGRATIONS.find( + (integration) => integration.id === "dbt", + ); + + expect(ejectedDbt).toEqual(runtimeDbt); + expect(ejectedDbt).toMatchObject({ + authMode: "headers", + connectionMode: "manual", + availability: "provider-setup", + supportsOrganizationScope: true, + setupNoteKey: "mcpIntegrations.catalog.dbt.setupNote", + headerPlaceholder: + "Authorization: Token \nx-dbt-prod-environment-id: ", + }); + }); + it("lets caller MCP overrides win and injects the merged catalog into UI", () => { const github = DEFAULT_MCP_INTEGRATIONS.find( (integration) => integration.id === "github", diff --git a/packages/core/src/eject/remote-mcp-presets.ts b/packages/core/src/eject/remote-mcp-presets.ts index 0bf4627a1b2..f449d87bfd6 100644 --- a/packages/core/src/eject/remote-mcp-presets.ts +++ b/packages/core/src/eject/remote-mcp-presets.ts @@ -62,6 +62,7 @@ export const DEFAULT_MCP_INTEGRATIONS: DefaultMcpIntegration[] = [ preset("fullstory"), preset("notion"), preset("granola"), + preset("dbt"), preset("semgrep"), preset("linear"), preset("atlassian"), diff --git a/packages/core/src/localization/default-messages.ts b/packages/core/src/localization/default-messages.ts index 8ab7edcb961..3eb53abb8a6 100644 --- a/packages/core/src/localization/default-messages.ts +++ b/packages/core/src/localization/default-messages.ts @@ -1363,6 +1363,13 @@ const messages = { setupNote: "Grafana Cloud MCP is in public preview and requires Grafana Cloud Assistant MCP access. It is hosted Grafana Cloud only; self-hosted Grafana needs the local MCP server.", }, + dbt: { + description: "Explore governed dbt metadata, lineage, and metrics.", + useCase: + "Data models, sources, lineage, model health, Semantic Layer, MetricFlow", + setupNote: + "A workspace owner or admin must copy the account-specific MCP Endpoint URL from dbt Account settings, then add a dbt service token and production environment ID. This organization connection uses one shared dbt service identity, so access and dbt-side auditing are workspace-level rather than per-user.", + }, gitlab: { description: "Read and manage GitLab projects, issues, and merge requests.", diff --git a/templates/analytics/.agents/skills/bigquery/SKILL.md b/templates/analytics/.agents/skills/bigquery/SKILL.md index 503608e46b2..8335b090675 100644 --- a/templates/analytics/.agents/skills/bigquery/SKILL.md +++ b/templates/analytics/.agents/skills/bigquery/SKILL.md @@ -55,7 +55,7 @@ table; `BIGQUERY_PROJECT_ID` is only the default project. are deduplicated, tested, and have canonical column names. Raw tables may have duplicates, schema drift, and inconsistent naming. -**Avoid `dbt_dev.*`** — development schema, excluded globally. +**Avoid `dbt_dev.*` and `dbt_backup.*` unless explicitly requested.** These schemas hold development, testing, or archived data. Do not discover or query them unless the latest end-user request explicitly names the schema and asks to inspect or query it. Never infer permission from agent-generated SQL. ## Always Bound Queries by Date @@ -151,7 +151,7 @@ ON signups.user_id = contacts.user_id ``` IDs can be reassigned after deletes/merges. Email alone over-matches shared addresses. -Require both for exact matches; flag email-only or id-only joins as low-confidence caveats. +Require both for exact matches. When the relationship or grain is undocumented and the query proceeds with an inferred, email-only, ID-only, fuzzy, or row-multiplying join, state that the join was inferred and the result is lower confidence. Ask for clarification instead when the ambiguity could materially change the answer. Documented joins need no generic hedge. ## SQL Patterns diff --git a/templates/analytics/.agents/skills/data-querying/SKILL.md b/templates/analytics/.agents/skills/data-querying/SKILL.md index 23fff626986..48a80a26bff 100644 --- a/templates/analytics/.agents/skills/data-querying/SKILL.md +++ b/templates/analytics/.agents/skills/data-querying/SKILL.md @@ -13,7 +13,7 @@ The analytics app connects to multiple data sources. This skill covers general p 0. **Orient catalog-first** — before querying, consult what already exists: the injected `` and data-source status tell you which sources are configured and which table/columns/join paths to use. Use them to pick the one source that owns the fact instead of fanning out blind queries. 1. **Route named account health deliberately** — for a customer/org health, QBR, renewal, contract-utilization, risk, or adoption request, read `account-health` before writing SQL. It adds identity-lock and metric-definition checks that an ordinary lookup does not need. -2. **Read the relevant provider skill first** — check `.agents/skills//SKILL.md` for table names, column mappings, auth, and gotchas. For BigQuery, read `.agents/skills/bigquery/SKILL.md` and use `search-bigquery-schema` before guessing table or column names. +2. **Read the relevant source skill first** — for dbt-backed models or metrics, read `.agents/skills/dbt/SKILL.md` before deciding semantics, lineage, grain, or SQL. Otherwise check `.agents/skills//SKILL.md` for source-specific mappings and gotchas. For BigQuery, read `.agents/skills/bigquery/SKILL.md` and use `search-bigquery-schema` before guessing table or column names. 3. **Clarify if ambiguous** — if the metric definition, date range, or grain is unclear and a wrong guess would change the numbers, use the `ask-question` clarifying tool (multiple-choice) before querying. Ask at most once per turn; skip it when the dictionary or the user already answered. 4. **Use existing actions or connected provider MCP tools** — call the provider action/tool with structured arguments, then filter or aggregate the returned records in your answer 5. **Write ad-hoc scripts** — if no existing script covers the question, create one in `actions/` @@ -196,6 +196,17 @@ Keep each entry short and actionable: what to do, what not to do, and why. This is the learnings flywheel — discoveries persist across sessions and improve future analyses. +## Conditional Caveats + +Keep routine exploratory answers direct. Add a concise caveat only when the evidence or requested use triggers one: + +- **Known stale data:** warn only when authoritative source metadata explicitly reports data beyond its expected refresh window. Include the observed refresh timestamp or window when available. Query-cache age and availability of freshness tools do not establish freshness. +- **Unknown freshness:** do not claim that data is current. Mention unverified freshness only when it materially affects the answer. +- **High-stakes distribution:** when the user explicitly says the output is for a client, board, investor, QBR, or executive distribution, recommend verifying figures against the source of record before distribution. Do not add this warning to routine internal exploration. +- **Complex inferred joins:** if an undocumented relationship or grain requires an inferred, email-only, ID-only, fuzzy, or row-multiplying join, label the join as inferred and the result as lower confidence. If the ambiguity could materially change the answer, clarify instead of merely hedging. Documented joins need no generic warning. + +Combine applicable caveats into one short note rather than stacking repetitive warnings. + ## Important Notes - Always query real data — never guess or approximate. Only present numbers you actually retrieved; do not claim a figure you did not query. diff --git a/templates/analytics/.agents/skills/dbt/SKILL.md b/templates/analytics/.agents/skills/dbt/SKILL.md new file mode 100644 index 00000000000..4ed1fc58492 --- /dev/null +++ b/templates/analytics/.agents/skills/dbt/SKILL.md @@ -0,0 +1,44 @@ +--- +name: dbt +description: >- + Governed dbt model, lineage, freshness, and Semantic Layer routing for analytics. + Use when a question depends on dbt models, sources, metrics, or warehouse SQL. +scope: runtime +--- + +# dbt + +dbt is authoritative for dbt model semantics, lineage, freshness, and governed metrics. Connected dbt MCP tools are dynamic: find them with `tool-search` when needed rather than assuming they are on the initial tool surface. + +## Decision Order + +1. Reuse a current certified query or dashboard when it already answers the request with the right definition, filters, grain, and time range. +2. Before guessing a dbt table, grain, join, meaning, freshness, or lineage, use dbt Discovery. Use the exact official tools that fit the question: `get_node_details` for model semantics and relation metadata, `get_lineage` for dependencies, `get_model_health` or `get_model_performance` for health and performance, and `get_all_sources` for declared sources and freshness context. +3. Before writing SQL, verify the physical BigQuery relation and columns with `search-bigquery-schema`. dbt metadata describes governed meaning; warehouse schema proves what can be queried now. +4. Run direct SQL with the existing `bigquery` action. Never use dbt `execute_sql` or `text_to_sql`. +5. If dbt metadata does not establish a grain or relationship, keep it unknown. Do not infer a join or silently turn uncertainty into a metric. + +## Phase 2: MetricFlow / Semantic Layer + +When Semantic Layer tools are available: + +1. Call `list_metrics` to find the governed metric. +2. Inspect `get_dimensions` and `get_entities`; use `get_dimension_values` when a requested filter value needs validation. +3. Call `query_metrics` with the requested dimensions, filters, range, and time grain. +4. Call `get_metrics_compiled_sql` only when the user requests provenance or query validation. It is not a default preflight. + +Report the metric name, dimensions, filters, date range and time grain, plus environment or freshness caveats. dbt calls use the shared workspace dbt identity, not a personal warehouse identity. + +## Restricted Schemas + +`dbt_dev` and `dbt_backup` are testing or archival schemas. Do not discover or query either schema unless the latest end-user request explicitly names it and asks to inspect or query it. Never infer permission from SQL that the agent generated. + +## Freshness + +A visible dbt health or freshness capability does not mean the data is fresh. Warn that data is stale only when returned dbt source/model metadata explicitly says it is beyond the expected refresh window, and include the observed timestamp or window when available. If freshness is unknown, do not claim freshness; mention that it could not be verified only when freshness materially affects the answer. + +If MetricFlow cannot express the request, fall back to dbt metadata plus the `bigquery` action and label the result as ad hoc SQL. Missing Semantic Layer tools are a capability gap, not evidence that no metrics exist. Use Discovery metadata and BigQuery only when they can answer without inventing semantic definitions. + +## Failure Semantics + +A dbt connection or tool-list error means capability status is unreadable, not that dbt is disconnected or that no dbt models or metrics exist. Preserve the actual error, try dynamic tool discovery when appropriate, and do not replace a failed dbt lookup with guessed semantics. diff --git a/templates/analytics/AGENTS.md b/templates/analytics/AGENTS.md index 0b867c9a24b..c3b13aa645a 100644 --- a/templates/analytics/AGENTS.md +++ b/templates/analytics/AGENTS.md @@ -8,9 +8,10 @@ analyses remain readable. Read the relevant skill before deeper work: -- `data-querying` for source inspection, SQL generation, result handling, and - `/chart` embeds; `bigquery`, `hubspot`, `gong`, `prometheus` for provider - specifics. +- `data-querying` for source inspection, SQL, result handling, and `/chart` embeds. +- `dbt` for governed model semantics, lineage, freshness, MetricFlow, and dbt-backed SQL. + Read provider skills such as `bigquery`, `hubspot`, `gong`, and `prometheus` + for provider specifics. - `account-health` for named customer health, QBR, renewal, contract usage, identity, and product adoption. - `cross-source-analysis` for questions spanning sources (identity stitching, @@ -24,6 +25,9 @@ Read the relevant skill before deeper work: - `creative-context` for governed contexts and immutable dashboard revisions. - `admin-surfaces` for the `/agents` fleet flags, usage audit, and connected DBs. +Before building common workspace or agent UI, read `agent-native-toolkit` and use +`customizing-agent-native` to follow the customization ladder. + ## How To Answer A Data Question 1. **Search existing work first.** For a metric question, call @@ -47,23 +51,18 @@ Read the relevant skill before deeper work: ## Core Rules -- A sibling app sends natural-language or shaped input over A2A, never SQL; this - app owns schema, source selection, and tools. Prefer natural-language - delegation; shaped reads are stable contracts. -- Analytics owns first-party product usage, app/template events, agent-native - signups, conversions, and other curated product metrics. Answer sibling-app - delegations with the built-in source and query catalog; sibling agents should - send a natural-language question, never SQL. +- A sibling app delegates natural-language or shaped input over A2A, never SQL; + this app owns schema, source selection, and tools. - Delegated requests: choose a safe default; label partial. - Data integrity first. Never invent numbers, dimensions, filters, or source semantics; present only retrieved values with source, window, filters, row-count/sample-size, join method, and caveats. +- dbt is authoritative for dbt model semantics and lineage. When connected, + discover its dynamic tools with `tool-search`; read `dbt` before using them. - Use actions for data and sharing; don't bypass ownable-resource access checks with raw SQL. - Provider actions are bounded shortcuts, not limits. For broad or - absence-sensitive Gong work, stage raw API data and use `query-staged-dataset` - or a Data Program; see `provider-api`, `data-programs`, and `gong` for secure - provider and hosted-endpoint boundaries. + absence-sensitive work, read `provider-api` and `data-programs`. - Create dashboards, panels, or saved artifacts only when explicitly asked; suggest and wait otherwise. Scope them to the question, avoid decorative metrics, and never modify existing dashboards without a directive. @@ -80,12 +79,8 @@ Read the relevant skill before deeper work: - Never hardcode API keys, tokens, webhook URLs, secrets, private Builder data, or customer data. Use secrets/OAuth and obvious placeholders in examples. - For external integrations, inspect the workspace/provider connection catalog first; reuse its scoped resolver. -- External MCP callers should prefer a cataloged direct action for a bounded - read or explicitly requested mutation. Use `ask_app` for interpretation, - source selection, multi-step analysis, or when the requested capability is - not exposed directly. Writes remain limited to intentionally allowlisted - actions. -- Reports/alerts use SQL actions; reports cap at five recipients. +- External MCP callers use cataloged actions for bounded reads and `ask_app` for + interpretation or multi-step analysis. Writes require intentional allowlisting. ## Actions @@ -95,19 +90,12 @@ Read the relevant skill before deeper work: | `search-dashboard-references` | Find dashboards to replicate. | | `get-sql-dashboard` | Read the dashboard and exact panel SQL. | | `certify-dashboard` | Admin-only approval of its current version. | -| DB | `list-db-admin-connections`, `list-connected-database-tables`, `db-admin-federated-read`: registry, schema, bounded joins. | +| DB | Registry, schema, and bounded federated reads. | ## Application State - `navigation` exposes the current dashboard, analysis, source, chart, and - selection. `navigate` moves the user between supported Analytics surfaces, - `"sessions"`, `"monitoring"`, and `"agents"`. Use `view-screen` when the - active context is unclear. + selection. Use `view-screen` when active context is unclear. - Clicking a panel stages it as a chat context chip and writes `selected-object` with `type="dashboard-panel"`. Read `dashboard-management` for the `/dashboards` overview and folder actions. - -## Shared UI - -Before building common workspace or agent UI, read `agent-native-toolkit`; read -`customizing-agent-native` before adapting shared UI. diff --git a/templates/analytics/actions/data-source-status.spec.ts b/templates/analytics/actions/data-source-status.spec.ts index c496a169a0c..6079a423f0d 100644 --- a/templates/analytics/actions/data-source-status.spec.ts +++ b/templates/analytics/actions/data-source-status.spec.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ getGitHubAccessToken: vi.fn(), tryRequestCredentialContext: vi.fn(), resolveAnalyticsProviderCredential: vi.fn(), + readDbtMcpStatus: vi.fn(), workspaceSummary: { appId: "analytics", provider: "hubspot", @@ -71,6 +72,10 @@ vi.mock("../server/lib/credentials-context", () => ({ tryRequestCredentialContext: mocks.tryRequestCredentialContext, })); +vi.mock("../server/lib/dbt-mcp-status", () => ({ + readDbtMcpStatus: mocks.readDbtMcpStatus, +})); + vi.mock("../server/lib/github-oauth", () => ({ getGitHubAccessToken: mocks.getGitHubAccessToken, })); @@ -87,6 +92,23 @@ describe("data-source-status", () => { mocks.getGitHubAccessToken.mockReset(); mocks.tryRequestCredentialContext.mockReset(); mocks.resolveAnalyticsProviderCredential.mockReset(); + mocks.readDbtMcpStatus.mockReset(); + mocks.readDbtMcpStatus.mockResolvedValue({ + available: true, + configured: false, + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: false, + intentionallyUnused: true, + }, + toolCount: 0, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); mocks.tryRequestCredentialContext.mockReturnValue({ userEmail: "ada@example.com", orgId: "org-1", @@ -141,6 +163,39 @@ describe("data-source-status", () => { }); }); + it("keeps external source status unknown when dbt MCP tools are unreadable", async () => { + mocks.readDbtMcpStatus.mockResolvedValue({ + available: false, + configured: null, + error: "MCP client is not configured.", + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: false, + intentionallyUnused: true, + }, + toolCount: 0, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + + const result = (await dataSourceStatus.run({ key: "dbt" })) as any; + + expect(result).toMatchObject({ + hasConnectedExternalDataSources: null, + connectedExternalDataSourceCount: 0, + dbt: { + available: false, + configured: null, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }, + }); + expect(result.error).toBeUndefined(); + }); + it("uses the canonical PostgreSQL source id in focused setup links", async () => { const result = (await dataSourceStatus.run({ key: "postgres" })) as any; const postgresql = result.providers.find( diff --git a/templates/analytics/actions/data-source-status.ts b/templates/analytics/actions/data-source-status.ts index b15cfd325a7..61876c0cf64 100644 --- a/templates/analytics/actions/data-source-status.ts +++ b/templates/analytics/actions/data-source-status.ts @@ -19,6 +19,7 @@ import { } from "../server/lib/credential-keys"; import { hasCredential } from "../server/lib/credentials"; import { tryRequestCredentialContext } from "../server/lib/credentials-context"; +import { readDbtMcpStatus } from "../server/lib/dbt-mcp-status"; import { getGitHubAccessToken } from "../server/lib/github-oauth"; import { resolveAnalyticsProviderCredential } from "../server/lib/provider-credentials"; @@ -86,7 +87,7 @@ async function listWorkspaceConnectionsForStatus(): Promise<{ export default defineAction({ description: - "List which analytics data sources are available without revealing secret values. This always includes the built-in first-party Analytics event store, which is queried with `query-agent-native-analytics`; it also reports configured credentials and granted workspace connections. The result includes `hasConnectedExternalDataSources`, `connectedExternalDataSourceCount`, and `dataSourcesSetupLink`; each provider also includes a focused `setupLink`. When a requested provider is unavailable, use its focused link for contextual setup guidance. The `key` arg accepts exact credential names like JIRA_API_TOKEN and provider aliases like jira, pylon, bigquery, github, hubspot, gong, or slack.", + "List which analytics data sources are available without revealing secret values. This always includes the built-in first-party Analytics event store, which is queried with `query-agent-native-analytics`; it also reports configured credentials, granted workspace connections, and the authenticated dbt MCP capability status. The result includes `hasConnectedExternalDataSources`, `connectedExternalDataSourceCount`, and `dataSourcesSetupLink`; each credential provider also includes a focused `setupLink`. When a requested provider is unavailable, use its focused link for contextual setup guidance. The `key` arg accepts exact credential names like JIRA_API_TOKEN and provider aliases like dbt, jira, pylon, bigquery, github, hubspot, gong, or slack.", schema: z.object({ key: z .string() @@ -106,12 +107,18 @@ export default defineAction({ }); } - const { configs, known } = resolveCredentialConfigs(args.key); + const requestedDbt = args.key?.trim().toLowerCase() === "dbt"; + const { configs, known } = requestedDbt + ? { configs: [], known: true } + : resolveCredentialConfigs(args.key); if (args.key && !known) { return { error: `Unknown credential key: ${args.key}` }; } - const workspace = await listWorkspaceConnectionsForStatus(); + const [workspace, dbt] = await Promise.all([ + listWorkspaceConnectionsForStatus(), + readDbtMcpStatus(), + ]); const workspaceCatalog = listWorkspaceConnectionProvidersForTemplate(APP_ID); const workspaceProviderIds = [ @@ -228,6 +235,15 @@ export default defineAction({ via: "built-in", queryAction: BUILT_IN_FIRST_PARTY_PROVIDER.queryAction, }, + ...(dbt.configured + ? [ + { + provider: "dbt", + label: "dbt", + via: "mcp", + }, + ] + : []), ...providers .filter((provider) => provider.configured) .map((provider) => ({ @@ -245,9 +261,9 @@ export default defineAction({ const connectedExternalDataSources = configuredDataSources.filter( (source) => source.provider !== BUILT_IN_FIRST_PARTY_PROVIDER.provider, ); - const hasUnknownProviderStatus = providers.some( - (provider) => provider.configured === null, - ); + const hasUnknownProviderStatus = + dbt.configured === null || + providers.some((provider) => provider.configured === null); return { // Keep a compact, explicit summary first so models do not infer source // availability from the much larger per-credential list below. @@ -262,6 +278,7 @@ export default defineAction({ : false, connectedExternalDataSourceCount: connectedExternalDataSources.length, dataSourcesSetupLink: DATA_SOURCES_SETUP_LINK, + dbt, credentials: results, providers: [BUILT_IN_FIRST_PARTY_PROVIDER, ...providers], total: results.length, diff --git a/templates/analytics/actions/navigate.spec.ts b/templates/analytics/actions/navigate.spec.ts index a6b475366f3..bdb5ffee98c 100644 --- a/templates/analytics/actions/navigate.spec.ts +++ b/templates/analytics/actions/navigate.spec.ts @@ -68,6 +68,20 @@ describe("navigate action", () => { expect(writeAppStateForCurrentTab).not.toHaveBeenCalled(); }); + it("opens a focused data source with a same-origin encoded path", async () => { + const result = await navigateAction.run({ + view: "data-sources", + dataSourceId: "dbt/core", + } as never); + + expect(writeAppStateForCurrentTab).toHaveBeenCalledWith("navigate", { + view: "data-sources", + dataSourceId: "dbt/core", + path: "/data-sources?source=dbt%2Fcore", + }); + expect(result).toBe("Navigating to data-sources data-source:dbt/core"); + }); + it("routes a monitoring subview to the monitoring tab", async () => { await navigateAction.run({ monitoringView: "errors" } as never); expect(writeAppStateForCurrentTab).toHaveBeenCalledWith("navigate", { diff --git a/templates/analytics/actions/navigate.ts b/templates/analytics/actions/navigate.ts index c0d17777a86..c0dbb30cfca 100644 --- a/templates/analytics/actions/navigate.ts +++ b/templates/analytics/actions/navigate.ts @@ -67,6 +67,10 @@ export default defineAction({ .string() .optional() .describe("Analysis ID to open (used with view=analyses)"), + dataSourceId: z + .string() + .optional() + .describe("Data source ID to focus (used with view=data-sources)"), extensionId: z .string() .optional() @@ -119,6 +123,7 @@ export default defineAction({ !args.dashboardId && !args.dashboardName && !args.analysisId && + !args.dataSourceId && !args.extensionId && !args.recordingId && !args.agentsView && @@ -129,7 +134,7 @@ export default defineAction({ !args.errorIssueId ) { throw new Error( - "At least --view, --dashboardId, --dashboardName, --analysisId, --extensionId, --recordingId, --agentsView, --dbAdminConnectionId, --monitoringView, --monitorId, --statusPageId, or --errorIssueId is required.", + "At least --view, --dashboardId, --dashboardName, --analysisId, --dataSourceId, --extensionId, --recordingId, --agentsView, --dbAdminConnectionId, --monitoringView, --monitorId, --statusPageId, or --errorIssueId is required.", ); } const dashboardId = @@ -147,6 +152,14 @@ export default defineAction({ nav.analysisId = args.analysisId; if (!args.view) nav.view = "analyses"; } + if (args.dataSourceId) { + nav.dataSourceId = args.dataSourceId; + if (!args.view) nav.view = "data-sources"; + } + if (nav.view === "data-sources" && nav.dataSourceId) { + const params = new URLSearchParams({ source: nav.dataSourceId }); + nav.path = `/data-sources?${params.toString()}`; + } if (args.extensionId) { nav.extensionId = args.extensionId; if (!args.view) nav.view = "extensions"; @@ -192,6 +205,7 @@ export default defineAction({ if (nav.view) parts.push(nav.view); if (nav.dashboardId) parts.push(`dashboard:${nav.dashboardId}`); if (nav.analysisId) parts.push(`analysis:${nav.analysisId}`); + if (nav.dataSourceId) parts.push(`data-source:${nav.dataSourceId}`); if (nav.extensionId) parts.push(`extension:${nav.extensionId}`); if (nav.recordingId) parts.push(`recording:${nav.recordingId}`); if (nav.agentsView) parts.push(`agents:${nav.agentsView}`); diff --git a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx new file mode 100644 index 00000000000..87bee5c3aaa --- /dev/null +++ b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.spec.tsx @@ -0,0 +1,208 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + reconnect: vi.fn(), +})); + +vi.mock("@agent-native/core/client/i18n", () => ({ + useT: () => (key: string) => key, +})); + +vi.mock("@agent-native/core/client/resources", () => ({ + formatMcpServersLoadError: (error: unknown) => String(error), + getDefaultMcpIntegrations: () => [ + { + id: "dbt", + name: "dbt", + logoUrl: "", + availability: "provider-setup", + connectionMode: "manual", + supportsOrganizationScope: true, + }, + ], + McpIntegrationDialog: ({ + open, + defaultScope, + integrations, + }: { + open: boolean; + defaultScope: string; + integrations: Array<{ + availability?: string; + connectionMode?: string; + supportsOrganizationScope?: boolean; + }>; + }) => + open ? ( +
+ ) : null, + McpIntegrationLogo: () => null, + useCreateMcpServer: () => ({ mutateAsync: vi.fn() }), + useMcpServers: () => ({ + data: { + user: [], + org: [ + { + id: "stored-dbt", + mergedId: "org_workspace_stored-dbt", + scope: "org", + }, + ], + }, + error: null, + }), + useReconnectMcpServer: () => ({ + mutate: mocks.reconnect, + isPending: false, + error: null, + }), +})); + +import { DbtMcpDataSourceCard } from "./DbtMcpDataSourceCard"; + +describe("DbtMcpDataSourceCard", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.reconnect.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("reconnects the existing organization server instead of opening create", async () => { + await act(async () => { + root.render( + , + ); + }); + + const reconnectButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent === "dataSources.reconnect"); + expect(reconnectButton).toBeTruthy(); + + await act(async () => reconnectButton?.click()); + + expect(mocks.reconnect).toHaveBeenCalledWith( + { id: "stored-dbt", scope: "org" }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ); + expect( + container.querySelector('[data-testid="mcp-create-dialog"]'), + ).toBeNull(); + }); + + it("opens the dbt manual form with organization scope enforced", async () => { + await act(async () => { + root.render( + , + ); + }); + + const connectButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "dataSources.connect", + ); + expect(connectButton).toBeTruthy(); + + await act(async () => connectButton?.click()); + + const dialog = container.querySelector('[data-testid="mcp-create-dialog"]'); + expect(dialog?.getAttribute("data-scope")).toBe("org"); + expect(dialog?.getAttribute("data-availability")).toBe("ready"); + expect(dialog?.getAttribute("data-connection-mode")).toBe("direct"); + expect(dialog?.getAttribute("data-supports-org-scope")).toBe("false"); + }); + + it("shows connected state without mutation controls to non-admin members", async () => { + await act(async () => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("dataSources.dbtConnectedTools"); + expect(container.querySelector("button")).toBeNull(); + expect(mocks.reconnect).not.toHaveBeenCalled(); + }); +}); diff --git a/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx new file mode 100644 index 00000000000..787aeaf94c1 --- /dev/null +++ b/templates/analytics/app/components/data-sources/DbtMcpDataSourceCard.tsx @@ -0,0 +1,209 @@ +import { useT } from "@agent-native/core/client/i18n"; +import { + formatMcpServersLoadError, + getDefaultMcpIntegrations, + McpIntegrationDialog, + McpIntegrationLogo, + useCreateMcpServer, + useMcpServers, + useReconnectMcpServer, +} from "@agent-native/core/client/resources"; +import { + IconAlertCircle, + IconCheck, + IconCircle, + IconLoader2, +} from "@tabler/icons-react"; +import { useState } from "react"; +import { Link } from "react-router"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { DbtMcpStatus } from "@/lib/data-source-status"; + +const dbtIntegration = getDefaultMcpIntegrations().find( + (integration) => integration.id === "dbt", +); +// This card owns provider setup and organization scope, so open the manual +// connection form directly and enforce the shared scope again at create time. +const dbtDialogIntegration = dbtIntegration + ? { + ...dbtIntegration, + availability: "ready" as const, + connectionMode: "direct" as const, + supportsOrganizationScope: false, + } + : undefined; + +export function DbtMcpDataSourceCard({ + status, + isLoading, + canManageOrg, + hasOrg, + focused, + showAskContinuation, + onSaved, +}: { + status: DbtMcpStatus | undefined; + isLoading: boolean; + canManageOrg: boolean; + hasOrg: boolean; + focused: boolean; + showAskContinuation: boolean; + onSaved: () => void; +}) { + const t = useT(); + const [dialogOpen, setDialogOpen] = useState(false); + const serversQuery = useMcpServers(); + const createServer = useCreateMcpServer(); + const reconnectServer = useReconnectMcpServer(); + const connected = status?.configured === true; + const notConfigured = status?.configured === false && status.available; + const hasError = + !isLoading && + (status?.configured === null || status?.available === false || !status); + const dbtServer = status?.serverId + ? [ + ...(serversQuery.data?.org ?? []), + ...(serversQuery.data?.user ?? []), + ].find( + (server) => + server.id === status.serverId || server.mergedId === status.serverId, + ) + : undefined; + const errorMessage = reconnectServer.error + ? reconnectServer.error instanceof Error && reconnectServer.error.message + ? reconnectServer.error.message + : t("dataSources.connectionFailed") + : serversQuery.error + ? formatMcpServersLoadError(serversQuery.error) + : hasError && status?.error + ? formatMcpServersLoadError(status.error) + : null; + + const handlePrimaryAction = () => { + if (notConfigured) { + setDialogOpen(true); + return; + } + if (!dbtServer) return; + reconnectServer.mutate( + { id: dbtServer.id, scope: dbtServer.scope }, + { onSuccess: onSaved }, + ); + }; + + return ( + + +
+
+
+ {dbtIntegration ? ( + + ) : null} +
+ + dbt + + {t("dataSources.dbtSharedIdentity")} + + +
+ +
+ {isLoading ? ( + + ) : hasError ? ( + + + {t("dataSources.connectionFailed")} + + ) : connected ? ( + + + {t("dataSources.dbtConnectedTools", { + count: status.toolCount, + })} + + ) : ( + + + {t("dataSources.notConfigured")} + + )} + + {!isLoading && canManageOrg && hasOrg ? ( + + ) : null} +
+
+
+ + {(errorMessage || (focused && connected && showAskContinuation)) && ( + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + {focused && connected && showAskContinuation ? ( +
+ + + {t("dataSources.connectionSuccessful")} + + +
+ ) : null} +
+ )} + + {canManageOrg && hasOrg && dbtDialogIntegration ? ( + + createServer.mutateAsync({ ...args, scope: "org" }) + } + onCreated={onSaved} + integrations={[dbtDialogIntegration]} + /> + ) : null} +
+ ); +} diff --git a/templates/analytics/app/hooks/use-navigation-state.spec.ts b/templates/analytics/app/hooks/use-navigation-state.spec.ts index 27b630bed4f..dfd2784216c 100644 --- a/templates/analytics/app/hooks/use-navigation-state.spec.ts +++ b/templates/analytics/app/hooks/use-navigation-state.spec.ts @@ -1,6 +1,39 @@ import { describe, expect, it } from "vitest"; -import { preserveActiveDashboardTab } from "./use-navigation-state"; +import { + commandPathForNavigation, + dataSourcesNavigationState, + preserveActiveDashboardTab, +} from "./use-navigation-state"; + +describe("data source navigation", () => { + it("extracts only the focused source as semantic navigation state", () => { + expect( + dataSourcesNavigationState( + new URLSearchParams("source=dbt&returnTo=ask&metadata=ignored"), + ), + ).toEqual({ view: "data-sources", dataSourceId: "dbt" }); + }); + + it("builds an encoded command path for the focused source", () => { + expect( + commandPathForNavigation({ + view: "data-sources", + dataSourceId: "dbt/core", + }), + ).toBe("/data-sources?source=dbt%2Fcore"); + }); + + it("preserves an existing command path including returnTo", () => { + expect( + commandPathForNavigation({ + view: "data-sources", + dataSourceId: "dbt", + path: "/data-sources?source=dbt&returnTo=ask", + }), + ).toBe("/data-sources?source=dbt&returnTo=ask"); + }); +}); describe("preserveActiveDashboardTab", () => { it("keeps the active tab when an agent reopens the current dashboard", () => { diff --git a/templates/analytics/app/hooks/use-navigation-state.ts b/templates/analytics/app/hooks/use-navigation-state.ts index 4cbd136112f..f0081a37174 100644 --- a/templates/analytics/app/hooks/use-navigation-state.ts +++ b/templates/analytics/app/hooks/use-navigation-state.ts @@ -7,6 +7,7 @@ import { TAB_ID } from "@/lib/tab-id"; interface NavigationState { view: string; dashboardId?: string; + dataSourceId?: string; analysisId?: string; extensionId?: string; recordingId?: string; @@ -19,13 +20,17 @@ interface NavigationState { filters?: Record; } +interface NavigateCommand extends NavigationState { + path?: string; +} + const SESSION_FILTER_KEYS = ["range", "app", "q"] as const; const DASHBOARD_PATH_RE = /^\/(?:adhoc|dashboards)\/([^/]+)\/?$/; export function useNavigationState() { const location = useLocation(); - useAgentRouteState({ + useAgentRouteState({ browserTabId: TAB_ID, getNavigationState: ({ pathname, searchParams }) => { const state: NavigationState = { view: "ask" }; @@ -99,7 +104,7 @@ export function useNavigationState() { } } } else if (pathname === "/data-sources") { - state.view = "data-sources"; + Object.assign(state, dataSourcesNavigationState(searchParams)); } else if (pathname === "/data-dictionary") { state.view = "data-dictionary"; } else if ( @@ -120,7 +125,8 @@ export function useNavigationState() { }); } -function commandPathForNavigation(cmd: NavigationState): string { +export function commandPathForNavigation(cmd: NavigateCommand): string { + if (cmd.path) return cmd.path; if (cmd.view === "adhoc" && cmd.dashboardId) return `/dashboards/${cmd.dashboardId}`; if (cmd.view === "analyses" && cmd.analysisId) @@ -158,6 +164,10 @@ function commandPathForNavigation(cmd: NavigationState): string { const qs = params.toString(); return qs ? `/monitoring?${qs}` : "/monitoring"; } + if (cmd.view === "data-sources" && cmd.dataSourceId) { + const params = new URLSearchParams({ source: cmd.dataSourceId }); + return `/data-sources?${params.toString()}`; + } if (cmd.view === "data-sources") return "/data-sources"; if (cmd.view === "data-dictionary") return "/data-dictionary"; if (cmd.view === "ask") return "/ask"; @@ -193,6 +203,15 @@ function dashboardIdFromPath(pathname: string): string | undefined { return pathname.match(DASHBOARD_PATH_RE)?.[1]; } +export function dataSourcesNavigationState( + searchParams: URLSearchParams, +): NavigationState { + const state: NavigationState = { view: "data-sources" }; + const dataSourceId = searchParams.get("source"); + if (dataSourceId) state.dataSourceId = dataSourceId; + return state; +} + function sessionFilters( searchParams?: URLSearchParams | Record, ): Record | undefined { diff --git a/templates/analytics/app/i18n-data.ts b/templates/analytics/app/i18n-data.ts index c86b2aa849c..bbb124432f0 100644 --- a/templates/analytics/app/i18n-data.ts +++ b/templates/analytics/app/i18n-data.ts @@ -889,6 +889,8 @@ const enUS = { opening: "Opening...", reconnect: "Reconnect", connect: "Connect", + dbtSharedIdentity: "Shared workspace service identity", + dbtConnectedTools: "Connected · {{count}} tools", connectedAs: "Connected as {{viewer}}", githubConnected: "GitHub is connected", githubReconnectNeeded: "Saved GitHub token needs to be reconnected.", @@ -1464,6 +1466,9 @@ const analyticsSliceTranslations: { opening: "开幕...", reconnect: "重新连接", connect: "连接", + + dbtSharedIdentity: "工作区共享服务身份", + dbtConnectedTools: "已连接 · {{count}} 个工具", connectedAs: "连接为 {{viewer}}", githubConnected: "GitHub已连接", githubReconnectNeeded: "已保存的GitHub令牌需要重新连接。", @@ -1844,6 +1849,9 @@ const analyticsSliceTranslations: { opening: "Apertura...", reconnect: "Reconectar", connect: "Conectar", + dbtSharedIdentity: + "Identidad de servicio compartida del espacio de trabajo", + dbtConnectedTools: "Conectado · {{count}} herramientas", connectedAs: "Conectado como {{viewer}}", githubConnected: "GitHub está conectado", githubReconnectNeeded: @@ -2251,6 +2259,8 @@ const analyticsSliceTranslations: { opening: "Ouverture...", reconnect: "Reconnecter", connect: "Connecter", + dbtSharedIdentity: "Identité de service partagée de l’espace de travail", + dbtConnectedTools: "Connecté · {{count}} outils", connectedAs: "Connecté en tant que {{viewer}}", githubConnected: "Le GitHub est connecté", githubReconnectNeeded: "Le jeton GitHub enregistré doit être reconnecté.", @@ -2654,6 +2664,8 @@ const analyticsSliceTranslations: { opening: "Öffnung...", reconnect: "Wieder verbinden", connect: "Verbinden", + dbtSharedIdentity: "Geteilte Arbeitsbereich-Serviceidentität", + dbtConnectedTools: "Verbunden · {{count}} Tools", connectedAs: "Verbunden als {{viewer}}", githubConnected: "GitHub ist angeschlossen", githubReconnectNeeded: @@ -3057,6 +3069,9 @@ const analyticsSliceTranslations: { opening: "オープニング...", reconnect: "再接続", connect: "接続する", + + dbtSharedIdentity: "ワークスペース共有サービス ID", + dbtConnectedTools: "接続済み · {{count}} 個のツール", connectedAs: "{{viewer}}として接続", githubConnected: "GitHubを接続しました", githubReconnectNeeded: @@ -3452,6 +3467,8 @@ const analyticsSliceTranslations: { opening: "열기...", reconnect: "다시 연결", connect: "연결하다", + dbtSharedIdentity: "워크스페이스 공유 서비스 ID", + dbtConnectedTools: "연결됨 · 도구 {{count}}개", connectedAs: "{{viewer}}로 연결됨", githubConnected: "GitHub가 연결되었습니다", githubReconnectNeeded: "저장된 GitHub 토큰을 다시 연결해야 합니다.", @@ -3843,6 +3860,8 @@ const analyticsSliceTranslations: { opening: "Abertura...", reconnect: "Reconectar", connect: "Conectar", + dbtSharedIdentity: "Identidade de serviço compartilhada do workspace", + dbtConnectedTools: "Conectado · {{count}} ferramentas", connectedAs: "Conectado como {{viewer}}", githubConnected: "GitHub está conectado", githubReconnectNeeded: "O token GitHub salvo precisa ser reconectado.", @@ -4240,6 +4259,8 @@ const analyticsSliceTranslations: { opening: "खुल रहा है...", reconnect: "रिकनेक्ट", connect: "जोड़ना", + dbtSharedIdentity: "वर्कस्पेस साझा सेवा पहचान", + dbtConnectedTools: "कनेक्टेड · {{count}} टूल", connectedAs: "{{viewer}} के रूप में जुड़ा हुआ है", githubConnected: "GitHub जुड़ा हुआ है", githubReconnectNeeded: "सहेजे गए GitHub टोकन को पुनः कनेक्ट करने की आवश्यकता है।", @@ -4625,6 +4646,8 @@ const analyticsSliceTranslations: { opening: "افتتاح...", reconnect: "أعد الاتصال", connect: "يتصل", + dbtSharedIdentity: "هوية خدمة مشتركة لمساحة العمل", + dbtConnectedTools: "متصل · {{count}} أداة", connectedAs: "متصل كـ {{viewer}}", githubConnected: "GitHub متصل", githubReconnectNeeded: "يجب إعادة توصيل الرمز المميز GitHub المحفوظ.", diff --git a/templates/analytics/app/i18n/zh-TW.ts b/templates/analytics/app/i18n/zh-TW.ts index 19ac29400b3..8f617b8864a 100644 --- a/templates/analytics/app/i18n/zh-TW.ts +++ b/templates/analytics/app/i18n/zh-TW.ts @@ -430,6 +430,8 @@ const messages = { opening: "開幕...", reconnect: "重新連線", connect: "連線", + dbtSharedIdentity: "工作區共用服務身分", + dbtConnectedTools: "已連線 · {{count}} 個工具", connectedAs: "連線為 {{viewer}}", githubConnected: "GitHub已連線", githubReconnectNeeded: "已儲存的GitHub權杖需要重新連線。", diff --git a/templates/analytics/app/lib/data-source-status.spec.ts b/templates/analytics/app/lib/data-source-status.spec.ts index 9206e7a0274..e43d47e4402 100644 --- a/templates/analytics/app/lib/data-source-status.spec.ts +++ b/templates/analytics/app/lib/data-source-status.spec.ts @@ -268,6 +268,30 @@ describe("data source status", () => { ).toBe(false); }); + it("declares dbt as MCP-backed and preserves its focused Ask return", () => { + const dbt = dataSources.find((source) => source.id === "dbt"); + const resolution = focusedDataSourceFromSearchParams( + new URLSearchParams("source=dbt&returnTo=ask"), + ); + + expect(dbt).toMatchObject({ + category: "analytics", + connectionKind: "mcp", + }); + expect(dbt && "envKeys" in dbt).toBe(false); + expect(dbt && "walkthroughSteps" in dbt).toBe(false); + expect(resolution).toMatchObject({ + status: "found", + source: { id: "dbt" }, + }); + expect( + dataSourceOAuthReturnPath( + resolution.status === "found" ? resolution.source : undefined, + true, + ), + ).toBe("/data-sources?source=dbt&returnTo=ask"); + }); + it("focuses a provider-specific setup link and preserves the Ask return", () => { const resolution = focusedDataSourceFromSearchParams( new URLSearchParams("source=HubSpot&returnTo=ask"), diff --git a/templates/analytics/app/lib/data-source-status.ts b/templates/analytics/app/lib/data-source-status.ts index d1c030d03ad..ef190c7cf76 100644 --- a/templates/analytics/app/lib/data-source-status.ts +++ b/templates/analytics/app/lib/data-source-status.ts @@ -27,6 +27,37 @@ export interface WorkspaceConnectionProviderSummary { hasActiveWorkspaceConnection: boolean; } +export interface DbtMcpStatus { + available: boolean; + error?: string; + configured: boolean | null; + serverId?: string; + capabilities: { + discovery: boolean; + lineage: boolean; + healthAndFreshness: boolean; + semanticLayer: boolean; + }; + sqlTools: { + available: boolean; + intentionallyUnused: true; + }; + toolCount: number; + setupLink: "/data-sources?source=dbt&returnTo=ask"; +} + +export interface ConfiguredDataSourceSummary { + provider: string; + label: string; + via: + | "built-in" + | "credentials" + | "workspace" + | "credentials-and-workspace" + | "mcp"; + queryAction?: string; +} + export interface DataSourceProviderStatus { provider: string; label: string; @@ -40,6 +71,13 @@ export interface DataSourceProviderStatus { } export interface DataSourceStatusResponse { + hasConfiguredDataSources?: boolean; + configuredDataSourceCount?: number; + configuredDataSources?: ConfiguredDataSourceSummary[]; + hasConnectedExternalDataSources?: boolean | null; + connectedExternalDataSourceCount?: number; + dataSourcesSetupLink?: string; + dbt?: DbtMcpStatus; credentials?: EnvKeyStatus[]; providers?: DataSourceProviderStatus[]; workspaceConnections?: { @@ -189,6 +227,7 @@ export function credentialRowsFromStatus( } export function getOptionalCredentialKeys(source: DataSource): Set { + if (source.connectionKind === "mcp") return new Set(); return new Set( source.walkthroughSteps .filter((step) => step.optional) @@ -203,6 +242,8 @@ export function isSourceConfigured( source: DataSource, envStatus: EnvKeyStatus[], ): boolean { + if (source.connectionKind === "mcp") return false; + const statusMap = new Map( envStatus.map((s) => [normalizeCredentialKey(s.key), s.configured]), ); @@ -299,6 +340,7 @@ export function isSourceReady( envStatus: EnvKeyStatus[], ): boolean { return ( + (source.id === "dbt" && data?.dbt?.configured === true) || isSourceConfigured(source, envStatus) || getProviderStatusForSource(source, data)?.configured === true || getSharedConnectionStatus(source, data, envStatus)?.kind === "ready" diff --git a/templates/analytics/app/lib/data-sources.ts b/templates/analytics/app/lib/data-sources.ts index fad755cda8c..fcb6cbf95d3 100644 --- a/templates/analytics/app/lib/data-sources.ts +++ b/templates/analytics/app/lib/data-sources.ts @@ -45,18 +45,28 @@ export interface WalkthroughStep { inputAcceptFile?: string; } -export interface DataSource { +interface DataSourceDescriptor { id: string; name: string; description: string; category: DataSourceCategory; icon: ComponentType>; + docsUrl: string; +} + +export interface CredentialDataSource extends DataSourceDescriptor { + connectionKind?: "credentials"; envKeys: string[]; credentialRequirementMode?: "all" | "any"; walkthroughSteps: WalkthroughStep[]; - docsUrl: string; } +export interface McpDataSource extends DataSourceDescriptor { + connectionKind: "mcp"; +} + +export type DataSource = CredentialDataSource | McpDataSource; + export const categoryLabels: Record = { analytics: "Analytics & Product", database: "Database", @@ -203,6 +213,15 @@ export const dataSources: DataSource[] = [ }, ], }, + { + id: "dbt", + name: "dbt", + description: "Governed metadata, lineage, model health, and metrics", + category: "analytics", + icon: IconDatabase, + connectionKind: "mcp", + docsUrl: "https://docs.getdbt.com/docs/dbt-ai/mcp-quickstart-remote", + }, { id: "amplitude", name: "Amplitude", diff --git a/templates/analytics/app/pages/DataSources.tsx b/templates/analytics/app/pages/DataSources.tsx index ea78cee6b3e..9a09a0b04ed 100644 --- a/templates/analytics/app/pages/DataSources.tsx +++ b/templates/analytics/app/pages/DataSources.tsx @@ -94,6 +94,7 @@ import { dataSources, categoryLabels, categoryOrder, + type CredentialDataSource, type DataSource, type WalkthroughStep, } from "@/lib/data-sources"; @@ -103,6 +104,7 @@ import { type ConnectionTestResult, } from "../components/ConnectionTestStatus"; import { CustomApiCard } from "../components/CustomApiCard"; +import { DbtMcpDataSourceCard } from "../components/data-sources/DbtMcpDataSourceCard"; interface AnalyticsPublicKeyRow { id: string; @@ -348,7 +350,9 @@ async function deleteCredentials(keys: string[]): Promise { await callAction("delete-data-source-credentials", { keys }); } -async function disconnectDataSource(source: DataSource): Promise { +async function disconnectDataSource( + source: CredentialDataSource, +): Promise { if (source.id === "github") { const res = await fetch( agentNativePath("/_agent-native/oauth/github/disconnect"), @@ -771,7 +775,7 @@ function ConnectedView({ onSaved, envStatus, }: { - source: DataSource; + source: CredentialDataSource; onSaved: () => void; envStatus: EnvKeyStatus[]; }) { @@ -842,7 +846,10 @@ function ConnectedView({ } const sharedCredentialKeys = source.envKeys.filter((key) => dataSources.some( - (other) => other.id !== source.id && other.envKeys.includes(key), + (other) => + other.connectionKind !== "mcp" && + other.id !== source.id && + other.envKeys.includes(key), ), ); const sharedSourceNames = Array.from( @@ -850,6 +857,7 @@ function ConnectedView({ dataSources .filter( (other) => + other.connectionKind !== "mcp" && other.id !== source.id && other.envKeys.some((key) => source.envKeys.includes(key)), ) @@ -1205,7 +1213,7 @@ function DataSourceCard({ showAskContinuation, onSaved, }: { - source: DataSource; + source: CredentialDataSource; locallyConfigured: boolean; ready: boolean; sharedConnectionStatus: SharedConnectionStatus | null; @@ -2169,33 +2177,46 @@ export default function DataSources() { filteredSources.length > 0 || firstPartyAnalyticsMatchesSearch ? (
{firstPartyAnalyticsMatchesSearch && } - {filteredSources.map((source) => ( - - ))} + {filteredSources.map((source) => + source.connectionKind === "mcp" ? ( + + ) : ( + + ), + )}
) : (

@@ -2213,33 +2234,46 @@ export default function DataSources() {

{category === "analytics" && } - {sources.map((source) => ( - - ))} + {sources.map((source) => + source.connectionKind === "mcp" ? ( + + ) : ( + + ), + )}
); diff --git a/templates/analytics/changelog/2026-09-02-analytics-can-connect-to-shared-dbt-metadata-lineage-freshne.md b/templates/analytics/changelog/2026-09-02-analytics-can-connect-to-shared-dbt-metadata-lineage-freshne.md new file mode 100644 index 00000000000..c481c8fe922 --- /dev/null +++ b/templates/analytics/changelog/2026-09-02-analytics-can-connect-to-shared-dbt-metadata-lineage-freshne.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-09-02 +--- + +Analytics can connect to shared dbt metadata, lineage, freshness, and governed metrics. diff --git a/templates/analytics/server/lib/dbt-mcp-status.spec.ts b/templates/analytics/server/lib/dbt-mcp-status.spec.ts new file mode 100644 index 00000000000..b5d79c9b891 --- /dev/null +++ b/templates/analytics/server/lib/dbt-mcp-status.spec.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const listVisibleMcpTools = vi.hoisted(() => vi.fn()); + +vi.mock("@agent-native/core/mcp-client", () => ({ + listVisibleMcpTools, +})); + +const { readDbtMcpStatus } = await import("./dbt-mcp-status"); + +const fullContractNames = [ + "get_all_models", + "get_all_sources", + "get_node_details", + "get_related_models", + "get_mart_models", + "get_lineage", + "get_model_health", + "get_model_performance", + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + "list_saved_queries", + "execute_sql", + "text_to_sql", +]; + +function tool(name: string, serverId = "org-dbt") { + return { + serverId, + name, + description: `${name} description`, + inputSchema: { type: "object", secret: "must not be projected" }, + }; +} + +describe("readDbtMcpStatus", () => { + beforeEach(() => { + listVisibleMcpTools.mockReset(); + }); + + it("projects the full official dbt capability contract", async () => { + listVisibleMcpTools.mockResolvedValue( + fullContractNames.map((name) => tool(name)), + ); + + await expect(readDbtMcpStatus()).resolves.toEqual({ + available: true, + configured: true, + serverId: "org-dbt", + capabilities: { + discovery: true, + lineage: true, + healthAndFreshness: true, + semanticLayer: true, + }, + sqlTools: { + available: true, + intentionallyUnused: true, + }, + toolCount: fullContractNames.length, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + }); + + it("reports discovery-only dbt without treating SQL tools as a capability", async () => { + listVisibleMcpTools.mockResolvedValue([ + tool("get_all_models", "dbt-discovery"), + tool("execute_sql", "dbt-discovery"), + tool("unrelated_tool", "dbt-discovery"), + ]); + + await expect(readDbtMcpStatus()).resolves.toEqual({ + available: true, + configured: true, + serverId: "dbt-discovery", + capabilities: { + discovery: true, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: true, + intentionallyUnused: true, + }, + toolCount: 2, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + }); + + it("reports a successful empty list as disconnected", async () => { + listVisibleMcpTools.mockResolvedValue([]); + + await expect(readDbtMcpStatus()).resolves.toMatchObject({ + available: true, + configured: false, + toolCount: 0, + }); + }); + + it("reports manager or list failure as unreadable rather than disconnected", async () => { + listVisibleMcpTools.mockRejectedValue( + new Error("MCP client is not configured."), + ); + + await expect(readDbtMcpStatus()).resolves.toMatchObject({ + available: false, + configured: null, + error: "MCP client is not configured.", + toolCount: 0, + }); + }); +}); diff --git a/templates/analytics/server/lib/dbt-mcp-status.ts b/templates/analytics/server/lib/dbt-mcp-status.ts new file mode 100644 index 00000000000..38586ffd000 --- /dev/null +++ b/templates/analytics/server/lib/dbt-mcp-status.ts @@ -0,0 +1,138 @@ +import { + listVisibleMcpTools, + type AppMcpTool, +} from "@agent-native/core/mcp-client"; + +const DBT_DISCOVERY_TOOLS = new Set([ + "get_all_models", + "get_all_sources", + "get_node_details", + "get_related_models", + "get_mart_models", +]); +const DBT_LINEAGE_TOOLS = new Set(["get_lineage"]); +const DBT_HEALTH_TOOLS = new Set([ + "get_model_health", + "get_model_performance", + "get_all_sources", +]); +const DBT_SEMANTIC_LAYER_TOOLS = new Set([ + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + "list_saved_queries", +]); +const DBT_SQL_TOOLS = new Set(["execute_sql", "text_to_sql"]); +const DBT_CAPABILITY_TOOLS = new Set([ + ...DBT_DISCOVERY_TOOLS, + ...DBT_LINEAGE_TOOLS, + ...DBT_HEALTH_TOOLS, + ...DBT_SEMANTIC_LAYER_TOOLS, +]); +const DBT_CONTRACT_TOOLS = new Set([...DBT_CAPABILITY_TOOLS, ...DBT_SQL_TOOLS]); + +export interface DbtMcpStatus { + available: boolean; + error?: string; + configured: boolean | null; + serverId?: string; + capabilities: { + discovery: boolean; + lineage: boolean; + healthAndFreshness: boolean; + semanticLayer: boolean; + }; + sqlTools: { + available: boolean; + intentionallyUnused: true; + }; + toolCount: number; + setupLink: "/data-sources?source=dbt&returnTo=ask"; +} + +function emptyStatus(configured: false | null, error?: string): DbtMcpStatus { + return { + available: !error, + ...(error ? { error } : {}), + configured, + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { + available: false, + intentionallyUnused: true, + }, + toolCount: 0, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }; +} + +function selectDbtServer(tools: AppMcpTool[]): AppMcpTool[] { + const byServer = new Map(); + for (const tool of tools) { + if (!DBT_CONTRACT_TOOLS.has(tool.name)) continue; + const current = byServer.get(tool.serverId) ?? []; + current.push(tool); + byServer.set(tool.serverId, current); + } + + return ( + [...byServer.values()] + .filter((serverTools) => + serverTools.some((tool) => DBT_CAPABILITY_TOOLS.has(tool.name)), + ) + .sort((left, right) => { + const capabilityDifference = + right.filter((tool) => DBT_CAPABILITY_TOOLS.has(tool.name)).length - + left.filter((tool) => DBT_CAPABILITY_TOOLS.has(tool.name)).length; + return ( + capabilityDifference || + left[0].serverId.localeCompare(right[0].serverId) + ); + })[0] ?? [] + ); +} + +export async function readDbtMcpStatus(): Promise { + let tools: AppMcpTool[]; + try { + tools = await listVisibleMcpTools(); + } catch (error) { + return emptyStatus( + null, + error instanceof Error ? error.message : String(error), + ); + } + + const dbtTools = selectDbtServer(tools); + if (dbtTools.length === 0) return emptyStatus(false); + + const names = new Set(dbtTools.map((tool) => tool.name)); + return { + available: true, + configured: true, + serverId: dbtTools[0].serverId, + capabilities: { + discovery: dbtTools.some((tool) => DBT_DISCOVERY_TOOLS.has(tool.name)), + lineage: dbtTools.some((tool) => DBT_LINEAGE_TOOLS.has(tool.name)), + healthAndFreshness: dbtTools.some((tool) => + DBT_HEALTH_TOOLS.has(tool.name), + ), + semanticLayer: dbtTools.some((tool) => + DBT_SEMANTIC_LAYER_TOOLS.has(tool.name), + ), + }, + sqlTools: { + available: [...DBT_SQL_TOOLS].some((name) => names.has(name)), + intentionallyUnused: true, + }, + toolCount: dbtTools.length, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }; +} diff --git a/templates/analytics/server/plugins/agent-chat.spec.ts b/templates/analytics/server/plugins/agent-chat.spec.ts index 3aac9c4415a..40e2fd07c8d 100644 --- a/templates/analytics/server/plugins/agent-chat.spec.ts +++ b/templates/analytics/server/plugins/agent-chat.spec.ts @@ -11,6 +11,18 @@ const accountHealthSkill = readFileSync( new URL("../../.agents/skills/account-health/SKILL.md", import.meta.url), "utf8", ); +const dbtSkill = readFileSync( + new URL("../../.agents/skills/dbt/SKILL.md", import.meta.url), + "utf8", +); +const bigquerySkill = readFileSync( + new URL("../../.agents/skills/bigquery/SKILL.md", import.meta.url), + "utf8", +); +const dataQueryingSkill = readFileSync( + new URL("../../.agents/skills/data-querying/SKILL.md", import.meta.url), + "utf8", +); const { agentChatPluginOptions, representativeAnalyticsActions } = vi.hoisted( () => ({ @@ -88,12 +100,14 @@ import { } from "../lib/real-data-actions"; import { analyticsDataDictionaryRoutingContext, + analyticsDbtRoutingContext, analyticsSourceGuidanceOpening, ANALYTICS_OBSERVABILITY_INCIDENT_GUIDANCE, ANALYTICS_CROSS_APP_ROUTING_GUIDANCE, ANALYTICS_CUSTOM_BLOCK_GUIDANCE, ANALYTICS_BACKGROUND_RUN_NO_PROGRESS_TIMEOUT_MS, ANALYTICS_ACCOUNT_HEALTH_GUIDANCE, + ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE, INTERNAL_PRODUCT_USAGE_GUIDANCE, BOUNDED_STRUCTURED_LOOKUP_GUIDANCE, DASHBOARD_REFERENCE_GUIDANCE, @@ -126,6 +140,7 @@ describe("Analytics agent Plan mode policy", () => { expect(guidance).toContain(""); expect(guidance).toContain(BOUNDED_STRUCTURED_LOOKUP_GUIDANCE); expect(guidance).toContain(ANALYTICS_ACCOUNT_HEALTH_GUIDANCE); + expect(guidance).toContain(ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE); expect(guidance).toContain(ANALYTICS_OBSERVABILITY_INCIDENT_GUIDANCE); expect(guidance).toContain(ANALYTICS_CROSS_APP_ROUTING_GUIDANCE); expect(guidance).toContain(BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE); @@ -252,6 +267,101 @@ describe("Analytics agent Plan mode policy", () => { expect(context.length).toBeLessThan(1_000); }); + it("keeps dbt routing bounded and distinguishes connection errors", () => { + const connected = analyticsDbtRoutingContext({ + available: true, + configured: true, + capabilities: { + discovery: true, + lineage: true, + healthAndFreshness: true, + semanticLayer: false, + }, + sqlTools: { available: true, intentionallyUnused: true }, + toolCount: 8, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + const unreadable = analyticsDbtRoutingContext({ + available: false, + configured: null, + error: "MCP client is not configured.", + capabilities: { + discovery: false, + lineage: false, + healthAndFreshness: false, + semanticLayer: false, + }, + sqlTools: { available: false, intentionallyUnused: true }, + toolCount: 0, + setupLink: "/data-sources?source=dbt&returnTo=ask", + }); + + expect(connected).toContain("discover the exact dynamic dbt tools"); + expect(connected).toContain("direct SQL only through the bigquery action"); + expect(connected).toContain( + "Missing Semantic Layer tools are a capability gap", + ); + expect(unreadable).toContain("status is unreadable"); + expect(unreadable).toContain("Do not infer that dbt is disconnected"); + expect(connected).toContain( + "visible health/freshness capability does not mean the underlying data is fresh", + ); + expect(connected.length).toBeLessThan(900); + expect(unreadable.length).toBeLessThan(500); + }); + + it("guides the agent away from restricted schemas unless explicitly requested", () => { + for (const skill of [dbtSkill, bigquerySkill]) { + expect(skill).toMatch(/dbt_dev/); + expect(skill).toMatch(/dbt_backup/); + expect(skill).toMatch(/latest end-user request explicitly names/i); + expect(skill).toMatch( + /Never infer permission from (?:SQL|agent-generated SQL)/i, + ); + expect(skill).not.toContain("restrictedSchemaAccess"); + } + }); + + it("keeps stale, high-stakes, and inferred-join caveats conditional", () => { + const guidance = ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE; + + expect(guidance).toContain("explicitly reports"); + expect(guidance).toContain("does not prove that data is fresh or stale"); + expect(guidance).toContain("client-facing, board, investor, QBR"); + expect(guidance).toContain( + "routine internal exploration needs no such warning", + ); + expect(guidance).toContain("join was inferred"); + expect(guidance).toContain("Documented joins need no generic hedge"); + expect(guidance).toContain("Combine multiple applicable caveats"); + + expect(dataQueryingSkill).toMatch(/Known stale data/); + expect(dataQueryingSkill).toMatch(/High-stakes distribution/); + expect(dataQueryingSkill).toMatch(/Complex inferred joins/); + }); + + it("documents the governed dbt and MetricFlow decision order", () => { + for (const toolName of [ + "get_node_details", + "get_lineage", + "get_model_health", + "get_model_performance", + "get_all_sources", + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + ]) { + expect(dbtSkill).toContain(`\`${toolName}\``); + } + expect(dbtSkill).toContain("Never use dbt `execute_sql` or `text_to_sql`"); + expect(dbtSkill).toContain("keep it unknown"); + expect(dbtSkill).toContain("label the result as ad hoc SQL"); + expect(dbtSkill).toContain("shared workspace dbt identity"); + }); + it("leaves representative read-only Analytics tools available to the shared Plan-mode policy", () => { const pluginActions = agentChatPluginOptions[0]?.actions as Record< string, @@ -295,6 +405,24 @@ describe("Analytics agent Plan mode policy", () => { ); }); + it("keeps dynamic dbt MCP tools off the initial tool surface", () => { + expect(INITIAL_TOOL_NAMES).not.toEqual( + expect.arrayContaining([ + "get_node_details", + "get_lineage", + "get_model_health", + "get_model_performance", + "get_all_sources", + "list_metrics", + "get_dimensions", + "get_entities", + "get_dimension_values", + "query_metrics", + "get_metrics_compiled_sql", + ]), + ); + }); + it("keeps named-session incident evidence on the initial tool surface", () => { expect(INITIAL_TOOL_NAMES).toEqual( expect.arrayContaining([ @@ -320,6 +448,7 @@ describe("Analytics agent Plan mode policy", () => { | (() => Promise) | undefined; const context = await extraContext?.(); + expect(context).toContain(""); expect(context).toContain("EXPORT DELIVERY"); expect(context).toContain("call `show-workspace-file`"); expect(context).toContain("Never save an error or failed response"); diff --git a/templates/analytics/server/plugins/agent-chat.ts b/templates/analytics/server/plugins/agent-chat.ts index c9cef13c469..7bdab8be68a 100644 --- a/templates/analytics/server/plugins/agent-chat.ts +++ b/templates/analytics/server/plugins/agent-chat.ts @@ -14,6 +14,7 @@ import actionsRegistry from "../../.generated/actions-registry.js"; import { INITIAL_TOOL_NAMES } from "../lib/agent-chat-plan-mode"; import { ANALYTICS_CONNECTOR_CATALOG } from "../lib/analytics-connector-catalog"; import { credentialProviderConfigs } from "../lib/credential-keys"; +import { readDbtMcpStatus, type DbtMcpStatus } from "../lib/dbt-mcp-status"; import { isProductionServerlessRuntime } from "../lib/production-serverless-runtime.js"; import { deriveGroundingActionNames, @@ -257,6 +258,9 @@ export const ANALYTICS_ACCOUNT_HEALTH_GUIDANCE = export const DASHBOARD_REFERENCE_GUIDANCE = "DASHBOARD REFERENCE DISCOVERY — When the user asks to replicate, clone, or adapt an existing dashboard, this branch takes precedence over the ordinary metric fast path: call `search-dashboard-references` with focused terms before creating, editing, or querying anything. It searches accessible active saved dashboard ids, names, descriptions, and serialized config with bounded SQL wildcard matches, including legacy saved dashboards. Treat each result as a reference to inspect with `get-sql-dashboard` when `kind` is `sql` or `get-explorer-dashboard` when `kind` is `explorer`, not as proof that its source is authoritative for the new request. Do not automatically route a replication request to first-party Analytics or copy its source semantics without checking the user's requested provider and scope. "; +export const ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE = + "CONDITIONAL CAVEATS — Do not add generic confidence or verification warnings. Treat data as stale only when dbt health/source freshness or another authoritative source explicitly reports that it is beyond its expected refresh window; include the observed refresh timestamp or window when available. A freshness-capable tool, query-cache age, or unknown freshness does not prove that data is fresh or stale. Mention unverified freshness only when it materially affects the requested answer. For explicitly client-facing, board, investor, QBR, or executive-distribution output, recommend verifying figures against the source of record before distribution; routine internal exploration needs no such warning. When an undocumented relationship or grain requires an inferred, email-only, ID-only, fuzzy, or row-multiplying join, say that the join was inferred and the result is lower confidence. If the ambiguity could materially change the answer, clarify instead of merely hedging. Documented joins need no generic hedge. Combine multiple applicable caveats into one concise note. "; + export const BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE = "BUILT-IN FIRST-PARTY SOURCE — Analytics always provides one built-in first-party source alongside connected external providers such as BigQuery, HubSpot, Gong, Slack, and the other configured integrations. This does not replace or restrict external sources. When `search-analytics-query-catalog` identifies a first-party dashboard/chart definition, preserve its event semantics and use `query-agent-native-analytics` over `analytics_events` or `session_recordings` as appropriate. When the user names an external provider, or the catalog identifies one as authoritative, query that provider instead. Do not report the first-party source as disconnected merely because an external provider is not configured. If the authoritative query returns no rows, report that grounded result with its scope and time window. "; @@ -309,6 +313,7 @@ export function analyticsSourceGuidanceOpening(): string { BOUNDED_STRUCTURED_LOOKUP_GUIDANCE + INTERNAL_PRODUCT_USAGE_GUIDANCE + ANALYTICS_ACCOUNT_HEALTH_GUIDANCE + + ANALYTICS_CONDITIONAL_CAVEAT_GUIDANCE + BUILT_IN_FIRST_PARTY_SOURCE_GUIDANCE + ANALYTICS_OBSERVABILITY_INCIDENT_GUIDANCE + `DATA-SOURCE SETUP UX — Chat remains available when no external data source is connected. For a live-data request that needs an unavailable external provider, explain what is missing in the context of the user's question and guide them naturally to [Connect data sources](${ANALYTICS_DATA_SOURCES_LINK}). Use that real link from the app; do not emit a generic canned no-data sentence. For general conversation, conceptual questions, and questions the built-in first-party source can answer, continue helping normally. ` + @@ -331,6 +336,32 @@ Data-dictionary definitions are available through \`search-analytics-query-catal `; } +export function analyticsDbtRoutingContext(status: DbtMcpStatus): string { + if (status.configured === null) { + return ` +dbt capability status is unreadable. Do not infer that dbt is disconnected or that no dbt models or metrics exist. For a dbt-backed request, read the dbt skill and use tool-search to look for the official dynamic dbt tools; preserve a real connection error if discovery fails. +`; + } + if (!status.configured) { + return ` +No dynamic dbt capability was visible in a successful connection check. This is a connection capability gap, not evidence that no governed metrics exist. If the request requires dbt semantics, explain the missing connection and link to ${status.setupLink}. +`; + } + + const capabilities = [ + status.capabilities.discovery ? "Discovery" : null, + status.capabilities.lineage ? "lineage" : null, + status.capabilities.healthAndFreshness ? "health/freshness" : null, + status.capabilities.semanticLayer ? "Semantic Layer" : null, + ].filter((capability): capability is string => Boolean(capability)); + const semanticLayerGuidance = status.capabilities.semanticLayer + ? "Use MetricFlow for governed metric requests." + : "Missing Semantic Layer tools are a capability gap, not evidence that no metrics exist."; + return ` +dbt is connected with these visible capabilities: ${capabilities.join(", ") || "none classified"}. Read the dbt skill, then discover the exact dynamic dbt tools with tool-search. dbt owns model semantics and lineage; verify physical BigQuery relations with search-bigquery-schema and run direct SQL only through the bigquery action. A visible health/freshness capability does not mean the underlying data is fresh; use the returned status and timestamps. ${semanticLayerGuidance} +`; +} + export { INITIAL_TOOL_NAMES } from "../lib/agent-chat-plan-mode"; function latestUserText( @@ -1135,6 +1166,7 @@ export default createAgentChatPlugin({ // Always inject compact source-routing guidance. Dictionary definitions // stay behind list-data-dictionary so prompt assembly does not read and // render every organization metric before the model request starts. + const dbtRouting = analyticsDbtRoutingContext(await readDbtMcpStatus()); const sourceGuidance = analyticsSourceGuidanceOpening() + "DASHBOARD CREATION RULE — You may create dashboard artifacts, SQL panels, or other resources only when the user explicitly asks you to (e.g. 'build me a dashboard for...', 'save this analysis', 'add a chart for...'). Treat a requested saved analysis or deep-dive report as a dashboard request. Never create any resource proactively during research, trend analysis, or answering questions. If you think a dashboard would be useful, suggest it and wait for explicit confirmation before creating anything. Never add new items to the sidebar or modify existing dashboards without an explicit user directive. " + @@ -1161,7 +1193,7 @@ export default createAgentChatPlugin({ "For schema questions, prefer data-dictionary entries and configured warehouse schemas over assumptions; use `search-bigquery-schema` for BigQuery metadata before inventing datasets, tables, or columns. " + "Before finalizing any analytics answer, make the evidence trail explicit enough to audit: answer the user's question, name the source(s), time window, sample size or row count, filters, join/match method, caveats/gaps, and recommended next action when useful. Never substitute fabricated numbers for a failed query or unavailable provider. It is fine to ask a clarifying question, provide a plan, or say exactly which source is unavailable as long as you do not present metrics or source-record conclusions without evidence.\n" + ""; - return `${sourceGuidance}\n\n${ANALYTICS_CUSTOM_BLOCK_GUIDANCE}\n\n${analyticsDataDictionaryRoutingContext()}`; + return `${sourceGuidance}\n\n${ANALYTICS_CUSTOM_BLOCK_GUIDANCE}\n\n${analyticsDataDictionaryRoutingContext()}\n\n${dbtRouting}`; }, mentionProviders: { dashboards: {