dbtBigQuery
Provides a governed semantic layer over Google Analytics 4 ecommerce data stored in BigQuery, enabling AI agents to query product metrics such as activation, retention, feature adoption, session depth, and funnel conversion.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@dbtBigQueryWhat's D7 retention for mobile users acquired in November?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
A governed GA4 semantic layer, queryable by humans and by agents
A dbt project over Google's public GA4 ecommerce sample that defines the product-analytics metrics people actually argue about — activation, retention, feature adoption, session depth, funnel conversion — and then puts an MCP server in front of them so an LLM answers "what's D7 retention for mobile users acquired in November?" by traversing the metric graph instead of writing SQL.
The README is the deliverable. Not the models. Anyone can aggregate GA4 events; the hard part is deciding what a number means, writing the decision down, and making it impossible for the definition and the dashboard to drift apart. That's what the metric dictionary below is for, and every definition in it is generated from the same YAML the warehouse and the agent read.
Source |
|
Scale | 4,295,584 events → 360,129 sessions → 270,154 users |
Stack | dbt-core 1.12 · dbt-bigquery 1.12 · MetricFlow 0.213 · MCP Python SDK 2.2 |
Models | 7 models + 1 seed (1 staging, 1 intermediate, 4 marts, 1 time spine) |
Tests | 98 data tests, all passing — including one that cannot pass vacuously |
Metrics | 42 defined in MetricFlow, each with a four-part governance block |
Cost | 6.75 GiB billed per full build, across 106 query jobs. BigQuery's free 1 TiB/month covers ~150 builds |
Generated by scripts/render_overview_svg.py, which queries MetricFlow at
render time — no figure in it was typed by hand. Note the right-hand edge of the
cohort chart: the final week is blank because the seven-day window has not
closed, and the semantic layer returns null rather than a misleadingly low
number.
Contents
Related MCP server: Google Analytics MCP Server
The claim
Most analytics repos answer "can you build a star schema?". That question was settled twenty years ago. The question that isn't settled is what happens when four teams each need "activation rate" and nobody wrote down which seven days they meant.
This project takes a position on that. Three things follow from it:
1. A definition lives in exactly one place. models/semantic/metrics.yml
holds the metric, its assumption, the trade-off accepted when it was chosen, and
what breaks if someone chooses differently. dbt compiles that into the semantic
manifest. The MCP server reads the manifest and hands the governance block back
with every number. scripts/render_metric_docs.py renders the same text into
the dictionary below, and CI fails if the README drifts. There is no second copy
to forget to update.
2. Eligibility is inside the measure, not left to the caller. A user whose
first session was three days before the data ends has had three days to
activate, not seven. Counting them drags activation down for reasons that have
nothing to do with the product — and does it worst in the most recent cohorts,
which is exactly where people look for a trend. So activated_users is defined
as:
case when is_activated
and not is_left_censored
and has_full_activation_window
then 1 else 0 endAn analyst cannot forget the exclusion, because no measure exists that omits it. 19,300 users are excluded from activation on this dataset for having an open window, and 8,417 more for predating the loaded window entirely.
3. The agent gets metrics, not a SQL prompt. The MCP server exposes seven tools. None of them accepts SQL. Ask it for a metric that doesn't exist and it refuses, names the 42 that do, and tells the model that the metric would need to be defined and reviewed first. An LLM with warehouse credentials will cheerfully compute D7 retention three different ways across three conversations and present all three with equal confidence. This one can't.
How it was built
Eight stages, in order. Each one produced something the next depended on, and each involved a decision worth defending rather than a default worth accepting.
1. Reconnaissance, before writing any dbt
Three queries against the raw export, to find out what the property actually fires rather than what the GA4 documentation says it might:
select event_name, count(*) from `bigquery-public-data.ga4_obfuscated_sample_ecommerce.events_*`
group by 1 order by 2 descSeventeen distinct event names, three device categories, six traffic mediums.
This is why seeds/feature_catalogue.csv maps select_promotion and not
view_cart: view_cart and add_to_wishlist are standard GA4 recommended
events that this property never fired once in 92 days. Cataloguing them would
have produced a permanent zero that reads like a product failure.
It also surfaced that view_promotion fires 190,104 times against 9,450
select_promotion clicks — the 20:1 ratio that forced the
impression-versus-action distinction into the seed file.
2. Staging — one row per event, and no opinions
models/staging/stg_events.sql. Typing, renaming, and promoting the useful
event_params keys to real columns. 4,295,584 rows.
GA4 stores per-event attributes in a repeated STRUCT, so every field needs an
unnest-and-filter. Rather than repeat that eight times, the pattern lives in
macros/ga4_event_params.sql:
{% macro ga4_param_int(key) -%}
(select ep.value.int_value from unnest(event_params) ep where ep.key = '{{ key }}' limit 1)
{%- endmacro %}The limit 1 is load-bearing — GA4 does not guarantee a key appears once per
event, and without it the build dies mid-run on a scalar-subquery error.
Two decisions here. Staging is materialised as a table, not a view, so the
three models that reference it read a local table instead of re-scanning 2.9
GiB of the public dataset each time. And traffic_source is renamed to
first_touch_*, because in the BigQuery export it describes how the user was
first acquired, not where the current session came from — the single most
common error built on this dataset.
3. Intermediate — sessionisation and the cohort anchor
models/intermediate/int_sessions.sql. 360,129 rows, one per session.
This is where the window functions live: LAG over events within a session to
detect inactivity gaps, QUALIFY ROW_NUMBER() to take session attributes from
the chronologically first event rather than an arbitrary row, and
ARRAY_AGG(... IGNORE NULLS ORDER BY ... LIMIT 1) for the landing page.
Two things are computed here precisely so that nothing downstream has to recompute them and risk disagreeing:
days_since_first_session— one elapsed-time axis, shared by activation, retention and feature adoption.is_left_censored— GA4's ownuser_first_touch_timestampcompared against the window start, which identifies the 8,417 users whose "first session" here is an artefact of where the data was cut.
4. Marts — four tables at three grains
Model | Grain | Rows | Carries |
| event | 4,295,584 | cohort context and feature classification on every event |
| user | 270,154 | activation, retention and the eligibility flags |
| session | 360,129 | engagement, depth, acquisition |
| session × step | 1,800,645 | five dense rows per session |
fct_funnel is deliberately dense — five rows per session whether or not the
step was reached — so step-to-step conversion is a ratio of two counts over one
table, and a step with no traffic on a given day appears as a zero instead of
vanishing from the chart.
5. Tests — 98 of them, and one that cannot pass vacuously
Uniqueness and not-null on every key, accepted_values on every enumerated
dimension, relationships across all four marts, dbt_utils.accepted_range on
every count and duration, and model-level expression_is_true assertions for
the invariants that encode actual business logic.
Plus tests/assert_models_are_not_empty.sql, which exists because of a real
failure documented below.
6. Semantic layer — 4 semantic models, 42 metrics
models/semantic/. Entities, dimensions and measures in
semantic_models.yml; metrics in metrics.yml (955 lines, most of it prose).
The technique that matters: eligibility is compiled into the measure expression, not left as a filter the caller must remember.
- name: activated_users
description: >
Eligible users whose first purchase landed inside the activation
window. Eligibility is inside the expression on purpose.
agg: sum
expr: "case when is_activated and not is_left_censored and has_full_activation_window then 1 else 0 end"And the governance text rides along in config.meta, so dbt compiles it into
target/semantic_manifest.json where both the docs generator and the MCP server
can read it.
7. Agent interface — MCP server, seven tools, no SQL
mcp_server/server.py, 642 lines. Reads the compiled manifest for metric
metadata; shells out to the MetricFlow CLI for the numbers. Every response that
carries a figure also carries that figure's definition, assumption, trade-off
and failure mode.
Unknown metrics are refused with a structured payload naming the 42 that exist — returned, not raised, so the model learns what it may ask for instead of seeing an opaque tool error.
8. Documentation and CI — closing the drift loop
Three generators and a workflow:
scripts/render_metric_docs.pyrenders the metric dictionary from the manifest, with a--checkmode that fails CI if the README has drifted.scripts/render_overview_svg.pybuilds the chart at the top of this README from livemf querycalls, so the picture cannot contradict the metrics.scripts/smoke_test_mcp.pylaunches the server as a subprocess and drives it over a real stdio MCP session, asserting that governance travels with every number and that no raw-SQL tool exists..github/workflows/ci.ymlruns all of the above without warehouse credentials, becausedbt parsevalidates models, semantic models and metrics against each other without opening a connection.
The toolchain
Tool | Version | What it does here | Why this one |
BigQuery | sandbox tier | Warehouse. Source dataset plus seven built models and one seed | Free without a credit card, and the GA4 sample lives there. Its constraints shaped real decisions — see the partitioning story |
dbt-core | 1.12.5 | Transformation, testing, lineage, documentation, exposures | The lingua franca of analytics engineering. Everything here is portable to any dbt warehouse |
dbt-bigquery | 1.12.1 | Adapter: partitioning, clustering, | — |
dbt_utils | 1.4.1 |
| dbt core ships |
MetricFlow | 0.213.0 (CLI 0.15.0) | The metric engine: semantic models, ratio and filtered metrics, cohort time dimensions, the join graph | The open-source engine behind the dbt Semantic Layer. Metrics compile to SQL rather than being reimplemented per tool |
MCP Python SDK | 2.2.0 | The agent interface, over stdio | The emerging standard for exposing tools to LLMs. Works with any MCP client |
uv | 0.10.12 | Environment and dependency management, lockfile-backed | Fast, and |
Python | 3.12.13 | MCP server (642 lines) plus three tooling scripts — two doc generators and an MCP integration test (734 lines) | — |
Ruff | 0.16.8 | Lint and format, enforced in CI | — |
GitHub Actions | — | Parse, drift-check, agent-surface guard, lint | Runs credential-free, so a fork gets a green build without a GCP account |
Google Cloud SDK | 585.0.0 |
| Makes the whole thing reproducible from one command |
Power BI | — | The BI consumer, declared as a dbt exposure | The one consumer that reads the marts directly, and therefore the one that needs written rules |
A few choices worth the sentence:
Why MetricFlow and not just well-named dbt models. A model can encode a metric, but it cannot encode a ratio over a dynamic group-by. Activation rate sliced by acquisition channel, device and cohort week is one metric definition in MetricFlow and one SQL compilation per question; as models it is either a cube with every combination pre-computed or a BI tool re-deriving the numerator each time. The whole governance argument depends on there being exactly one definition, and MetricFlow is what makes that mechanically true.
Why MCP and not a chat wrapper over the warehouse. A text-to-SQL agent with warehouse credentials is faster to build and produces answers that cannot be audited. Wiring the agent to the metric layer instead means the set of answerable questions is exactly the set of governed metrics — and the ones outside it come back as an explicit refusal rather than a plausible guess.
Why no incremental models. dbt's incremental strategy on BigQuery emits
MERGE, which the free sandbox does not support. At 4.3m rows and a 75-second
full refresh, incrementality would be complexity without benefit. Stating that
is the engineering decision; reaching for incremental because it looks advanced
would have been the mistake.
The metric dictionary
Each metric below states four things: what it means, the assumption it rests on, the trade-off accepted when it was chosen over the obvious alternative, and what breaks if someone defines it differently. The fourth is the one that matters. It's the difference between a definition and a warning label.
Generated from
models/semantic/metrics.ymlbyscripts/render_metric_docs.py. Do not edit by hand — edit the YAML and re-rundbt parse. CI fails if this section drifts.
Activation
activation_rate — Activation rate
ratio metric, user grain.
Definition. activated_users / activation_eligible_users. A user activates when their first purchase falls within activation_window_days of their first observed session. Both sides of the ratio exclude left-censored users and users whose window has not closed.
Assumption. Purchase is the activation moment, and seven days is long enough for a genuinely interested buyer to get there. For a merchandise store where most of the catalogue is under $50, that is defensible; for a considered purchase it would not be.
Trade-off. Chosen over "any add-to-cart" because add-to-cart survives neither bot traffic nor idle curiosity — it is cheap to trigger and does not commit the user to anything, so it measures interest rather than value delivered. Purchase is expensive to fake and unambiguous. The cost is real: slow-considered categories look worse than they are, because a user who returns on day nine to buy a $300 item is counted as a failure. A second cost is that this metric can only move when checkout works, so it conflates product-market fit with payment reliability.
Breaks if. Someone widens the window to 30 days to make the number look better. On this dataset that moves activation from 1.18% to 1.57% — a third higher, and less than most people expect, because the buyers who were going to buy mostly did so quickly. The real cost is elsewhere: the eligible population falls from 242,437 users to 173,333, a 28% drop, because every cohort from the last 30 days leaves the denominator. The series gets shorter at the same time as it gets higher, and a 30-day window cannot tell you on Tuesday that Monday's release broke onboarding.
avg_days_to_first_purchase — Average days to first purchase
simple metric, user grain.
Definition. Mean of days_to_first_purchase across users who ever purchased.
Assumption. Non-purchasers are excluded rather than counted as infinite. This makes the metric a diagnostic for "how long should the activation window be", not a measure of how well the funnel works.
Trade-off. A mean over purchasers is badly skewed by a small number of long considerers. It is published because its shape justifies the choice of a seven-day activation window, not because it is a good KPI.
Breaks if. Someone reads it as "the average customer buys in N days". It is the average of those who bought at all, which is a much smaller and much keener population.
Retention
d7_retention_rate — D7 retention rate
ratio metric, user grain.
Definition. d7_retained_users / d7_eligible_users. A user is retained if they had at least one session on any of days 1-7 after their first session. Day 0 is excluded.
Assumption. Returning at all is the signal. A user who returns on day 2 for eight seconds and bounces counts exactly the same as one who returns daily.
Trade-off. This is "bounded" or "rolling" D7 — any return in the first week — chosen over "classic" D7, which counts only users active on day 7 exactly. Bounded D7 is far less noisy on small cohorts and matches how most people describe retention out loud. The cost is that it is structurally higher than classic D7 and not comparable to a competitor's number without asking which one they mean. Day 0 is excluded because a same-day return is part of the acquisition visit, not evidence of habit.
Breaks if. Someone compares it with a classic day-7-exact figure, or includes day 0. Including day 0 returns exactly 100% — not approximately, exactly — because every eligible user has a session on day 0 by construction: it is the session that put them in the cohort. A retention metric that reads 100% for every segment on every day is the clearest possible signal that day 0 crept into the numerator.
d1_retention_rate — D1 retention rate
ratio metric, user grain.
Definition. d1_retained_users / d1_eligible_users, where day 1 means the calendar day after the first session.
Assumption. Calendar days, in the property's reporting timezone, not rolling 24-hour periods.
Trade-off. Calendar-day bucketing means a user who arrives at 23:50 and returns at 00:10 counts as retained after 20 minutes. Rolling 24-hour windows would be fairer but do not line up with how the cohort chart is drawn.
Breaks if. The property's reporting timezone changes. Every day boundary moves, and historical D1 shifts without any code changing.
d30_retention_rate — D30 retention rate
ratio metric, user grain.
Definition. d30_retained_users / d30_eligible_users.
Assumption. The same bounded definition as D7, over a 30-day window.
Trade-off. On a 92-day dataset, requiring a full 30-day window discards the last third of the cohorts. The metric is included for completeness but the eligible population is small enough that segment-level cuts get noisy fast.
Breaks if. Someone slices it by channel and campaign at once. The eligible population is roughly a third of the dataset before any filtering.
Feature adoption
feature_adoption_rate — Feature adoption rate (first 7 days)
ratio metric, user grain.
Definition. feature_adopters_7d / adoption_base_users. A user adopts a feature when they fire an event mapped to it in seeds/feature_catalogue.csv with is_user_initiated = true, within seven days of their first session.
Assumption. The feature catalogue is complete and correctly classified. It is a hand-maintained seed file; a feature nobody added to it has an adoption rate of zero by construction, not by evidence.
Trade-off. Impression events are excluded. view_promotion fires 190,104 times in this sample against 9,450 select_promotion clicks — a 20:1 ratio — so counting impressions would report promotions adoption near 100% and mean nothing. The cost is that a feature whose value is being seen rather than clicked cannot be measured this way at all. The seven-day window is inherited from the activation definition so the two metrics can be read on the same axis. That is a convenience, not a finding: nothing says feature adoption and first purchase operate on the same clock.
Breaks if. Someone groups this metric by feature_name. A ratio metric's group-by applies to the denominator as well as the numerator, so grouping by feature turns the denominator from "all eligible users" into "eligible users who used this feature" — and the ratio collapses towards 1. On this dataset it returns 96% for site_search, against the true 5.1%. The four per-feature metrics avoid this by filtering the numerator only, which is why they exist as separate metrics rather than as a slice of this one. It also breaks if someone removes the is_user_initiated filter or flags an impression event as user-initiated: adoption then jumps towards 100% and the metric silently becomes a measure of page traffic.
feature_adoption_rate_site_search — Feature adoption rate — site search
ratio metric, user grain.
Definition. feature_adoption_rate filtered to the site_search feature (event view_search_results).
Assumption. Seeing a search result set means the user searched deliberately.
Trade-off. Search is the clearest case of a user-initiated feature in this property, which is why it is broken out. Note that a user who typed a query returning zero results still counts as an adopter.
Breaks if. The site adds an auto-suggest panel that fires view_search_results on keystroke. Adoption then measures typing, not searching.
feature_adoption_rate_promotions — Feature adoption rate — promotions
ratio metric, user grain.
Definition. feature_adoption_rate filtered to the promotions feature. Counts select_promotion only; view_promotion is catalogued as an impression and excluded.
Assumption. A promotion click is a deliberate act of engagement with merchandising.
Trade-off. This is the metric that most clearly demonstrates why the impression/action distinction is in the catalogue at all — the two event types differ by 20x in this sample.
Breaks if. view_promotion is reclassified as user-initiated. The rate leaps and the metric stops distinguishing merchandising that works from merchandising that merely renders.
feature_adoption_rate_product_list — Feature adoption rate — product list
ratio metric, user grain.
Definition. feature_adoption_rate filtered to product_list (event select_item).
Assumption. A click from a list is navigation and a feature interaction. This is the weakest of the four feature definitions — browsing a category is arguably just using the site.
Trade-off. Included because it is the highest-volume user-initiated feature signal in this property and gives the adoption chart a baseline to read the others against.
Breaks if. Read as evidence that a discovery feature is working. It largely measures ordinary navigation.
feature_adoption_rate_outbound_click — Feature adoption rate — outbound click
ratio metric, user grain.
Definition. feature_adoption_rate filtered to outbound_click (GA4's automatic click event).
Assumption. An outbound click is a deliberate action worth measuring.
Trade-off. Only 1,446 such events exist in the whole sample, so this metric is included mainly as a worked example of a low-volume feature: the rate is near zero and any segment cut of it is noise. A metric that cannot support the slices people will want is worth labelling as such before somebody builds a dashboard on it.
Breaks if. It is sliced by anything. At this volume a single day's segment can swing the rate by an order of magnitude.
Session depth and engagement
session_depth — Session depth (events per session)
ratio metric, session grain.
Definition. Total events divided by total sessions.
Assumption. All events are equally meaningful. They are not — user_engagement and scroll together are 36% of the event volume in this dataset, and both fire without the user deciding anything.
Trade-off. Events per session was chosen over page views per session because it captures interaction on single-page flows that never fire a second page_view. The cost is that the number is dominated by automatic events, so it moves when GA4's tagging changes even though user behaviour did not.
Breaks if. Someone compares session depth across a tagging change. Adding a single new automatic event to the site raises this metric everywhere, overnight, with no behavioural change whatsoever.
engagement_rate — Engagement rate (GA4 definition)
ratio metric, session grain.
Definition. engaged_sessions / sessions, where engagement is GA4's own client-side flag: the session lasted over 10 seconds, or had a conversion, or had two or more page views.
Assumption. The client-side flag arrived intact.
Trade-off. Chosen over the independently derived definition so that the number reconciles with the GA4 UI. engagement_rate_derived computes the same rule server-side, and the difference between the two is a direct measure of how much client-side signal is being lost.
Breaks if. Compared with engagement_rate_derived without saying which is which. They answer the same question through different plumbing and will not agree.
engagement_rate_derived — Engagement rate (derived definition)
ratio metric, session grain.
Definition. engaged_sessions_derived / sessions, where engagement is recomputed from the event stream: duration >= 10s OR page_views >= 2 OR a purchase occurred.
Assumption. Duration measured as last-event minus first-event is a fair proxy for time on site. It is not, for a single-event session, which always measures zero.
Trade-off. Published purely as a control on the GA4 flag. Using it as the headline engagement number would mean explaining to every stakeholder why it does not match the GA4 UI.
Breaks if. It becomes the default without the comparison being documented.
sessionisation_disagreement_rate — Sessionisation disagreement rate
ratio metric, session grain.
Definition. sessions_split_by_gap / sessions, where a session is "split" if any two consecutive events in it are more than session_gap_minutes (default 30) apart.
Assumption. A 30-minute gap means the user left. It might instead mean a tab sat open in the background, which GA4's own timeout should have ended.
Trade-off. This is a data-quality metric, not a product metric. It exists so that the choice to adopt GA4's session boundaries can be defended with a number instead of a shrug.
Breaks if. It is put on a business dashboard. It answers a question about instrumentation that no commercial stakeholder asked.
Funnel
purchase_conversion_rate — Session purchase conversion rate
ratio metric, session grain.
Definition. sessions_reaching_purchase / sessions_starting_funnel, both using the strict in-sequence definition: a step counts only when every prior step was also reached in the same session.
Assumption. The user journey is view_item then add_to_cart then begin_checkout then purchase, within one session. Cross-session journeys — research on mobile, buy on desktop next day — are invisible to this metric.
Trade-off. Strict sequencing was chosen over permissive step-reach because the permissive version produces conversion rates above 100% at the cart step: GA4 fires add_to_cart from category pages without a preceding view_item. The cost is that the strict version undercounts genuine cart adds, so the absolute rate is conservative. Both measures are published; the strict one is wired to the metric.
Breaks if. Someone builds the same funnel on is_reached instead of is_reached_in_sequence. On this dataset that moves begin_checkout from 5,868 sessions to 11,106 and purchase from 2,816 to 4,848 — the permissive counts are 89% and 72% higher respectively. The gap is concentrated at checkout, not at the cart, because carts persist across sessions while this funnel does not: a user who adds to cart on Monday and checks out on Tuesday has a Tuesday session that begins checkout with no preceding cart add. Neither number is wrong; mixing them inside one chart is.
checkout_completion_rate — Checkout completion rate
ratio metric, session grain.
Definition. sessions_reaching_purchase / sessions_reaching_begin_checkout, both strict in-sequence.
Assumption. Checkout abandonment is a product problem. Some of it is payment failure, which this dataset cannot distinguish.
Trade-off. The narrowest funnel step pair, and therefore the most actionable — but also the one with the smallest denominator, so daily slices get noisy quickly.
Breaks if. Sliced by channel and device at once on a single day. On this dataset the begin_checkout population is under 500 sessions a day.
Commercial
revenue — Revenue (USD)
simple metric, event grain.
Definition. Sum of ecommerce.purchase_revenue_in_usd across purchase events. Item revenue, shipping and tax are excluded.
Assumption. Every purchase event represents a real, completed order. The GA4 export has no refund or cancellation signal in this sample, so returns are invisible.
Trade-off. Taken at event grain rather than from the item array, because the item array double-counts when a purchase event carries both order-level and line-level revenue. The cost is that no product-level revenue split is possible from this metric.
Breaks if. Someone sums revenue from fct_funnel. Session revenue is repeated onto all five step rows, so the total comes out five times too high.
transactions — Transactions
simple metric, session grain.
Definition. Distinct transaction_id values observed on purchase events.
Assumption. transaction_id is populated and unique per order.
Trade-off. Counted from distinct transaction_id within a session, then summed across sessions. That removes the duplication caused by reloading an order-confirmation page inside one visit, but not duplication across visits. On this dataset the three numbers are: 5,692 purchase events, 5,258 session-scoped distinct transactions (this metric), and 4,452 globally distinct transaction ids. The 806-order gap is order confirmations reopened in a later session.
Breaks if. It is read as "orders placed". It is closer to "order confirmations seen, deduplicated within a visit". For a true order count, take count(distinct transaction_id) across the whole period — which this semantic layer deliberately does not expose as an additive measure, because a distinct count cannot be summed across time buckets.
average_order_value — Average order value (USD)
ratio metric, session grain.
Definition. Total session revenue divided by distinct transactions.
Assumption. Revenue and transaction counts cover the same set of orders.
Trade-off. Both sides are taken at session grain so they cannot drift apart under grouping. The cost is that the denominator is session-scoped distinct transactions, so this reports $68.88 on the full dataset where a globally deduplicated order count would report $81.35. The session-scoped version was chosen because it stays correct when the metric is sliced by day or by channel; the global one does not.
Breaks if. It is compared against an order-management system's AOV. That system counts orders once; this counts order confirmations once per visit.
revenue_per_session — Revenue per session (USD)
ratio metric, session grain.
Definition. Total session revenue in USD divided by total sessions.
Assumption. Revenue belongs to the session in which the purchase event fired.
Trade-off. Last-click within-session attribution. A user who researched across four sessions and bought in the fifth gives all the credit to the fifth. Multi-touch attribution is out of scope on a dataset whose traffic source is user-scoped and partly obfuscated.
Breaks if. Used to compare acquisition channels. Because GA4's traffic_source is first-touch and user-scoped, every session a user ever has is credited to the channel that first acquired them, so this will systematically flatter whichever channel wins first contact.
Population
new_users — New users
simple metric, user grain.
Definition. A distinct user_pseudo_id whose first observed session falls inside the loaded window, excluding users GA4 says it had already seen before the window opened.
Assumption. user_pseudo_id is a person. It is not — it is a browser on a device. One human on a laptop and a phone counts twice, and clearing cookies creates a new "new user".
Trade-off. Chosen over GA4's own first_visit event count because first_visit fires on the client and is lost whenever the event is blocked, while this derives from the session record we already trust for everything else. The cost is that our number will not tie exactly to the GA4 UI.
Breaks if. Someone drops the left-censoring exclusion. Users active before 2020-11-01 then appear as new arrivals on the first few days of the window, inflating early cohorts and making retention look worse than it is for exactly those cohorts.
sessions — Sessions
simple metric, session grain.
Definition. A distinct (user_pseudo_id, ga_session_id) pair. Session boundaries are GA4's: 30 minutes of inactivity, or midnight in the property's reporting timezone, whichever comes first.
Assumption. The client-side session id is trustworthy. It is assigned in the browser, so an ad blocker, a cleared cookie mid-visit, or a device asleep in a tab can all end a session that a human would say continued.
Trade-off. Chosen over deriving our own 30-minute-gap boundaries server-side, because this is the number the GA4 UI shows and therefore the number a stakeholder will challenge. The cost is that midnight splits one late visit into two sessions, which depresses session depth for night-time traffic. sessionisation_disagreement_rate measures how often this bites.
Breaks if. Someone switches to gap-based sessionisation without restating the metric. Session counts fall, session depth rises, and every per-session rate in the dashboard moves at once with no code change visible in the BI tool.
Component metrics
These exist so that the headline ratios above have inspectable numerators and denominators. Each carries the same four-part governance block in the manifest; the MCP server and dbt docs serve it in full.
Metric | Type | Grain | Definition |
| simple | user | An eligible new user whose first purchase occurred within |
| simple | user | Observed new users for whom the loaded window extends at least |
| simple | user | Distinct users who are neither left-censored nor inside an open seven-day window. |
| simple | user | Observed new users whose first session is at least one day before the end of the window. |
| simple | user | Eligible users with at least one session on the calendar day after their first session. |
| simple | user | Observed new users whose first session is at least thirty days before the end of the window. |
| simple | user | Eligible users with at least one session on days 1 through 30 after their first session. |
| simple | user | Observed new users whose first session is at least seven days before the end of the window. |
| simple | user | Eligible users with at least one session on days 1 through 7 after their first session. |
| simple | session | Sessions where GA4 set session_engaged = '1' on any event. |
| simple | session | Sessions where duration >= 10s OR page_views >= 2 OR a purchase occurred. |
| simple | user | Distinct eligible users with at least one user-initiated feature event on days 0-7 after their first session. Across all features this is 25,476 users out of 242,437 eligible. |
| simple | session | Sum of events_in_session across sessions. |
| simple | session | Sum of session_revenue_usd across sessions. |
| simple | session | Sessions where is_reached_in_sequence is true at step 3. |
| simple | session | Sessions where is_reached_in_sequence is true at step 4. |
| simple | session | Sessions where is_reached_in_sequence is true at step 5. |
| simple | session | Sessions where is_reached_in_sequence is true at step 2. |
| simple | session | Sessions with at least one pair of consecutive events more than session_gap_minutes apart. |
| simple | session | Sessions where is_reached_in_sequence is true at step 1. |
Five things the data changed my mind about
Writing the definitions first and checking them afterwards was the point of the exercise. Five survived contact with the data badly enough to be worth recording.
1. The funnel breaks at checkout, not at the cart
I expected the permissive funnel ("did this session fire the event?") and the
strict one ("did it fire the event having reached every prior step?") to diverge
at add_to_cart, because GA4 lets a user add to cart from a category page
without ever firing view_item. That effect is real but tiny — 15,188 sessions
against 14,897, a 2% gap.
The real divergence is further down:
Step | Permissive | Strict | Gap |
session_start | 354,857 | 354,857 | — |
view_item | 77,020 | 75,271 | +2% |
add_to_cart | 15,188 | 14,897 | +2% |
begin_checkout | 11,106 | 5,868 | +89% |
purchase | 4,848 | 2,816 | +72% |
Carts persist across sessions; a session-scoped funnel does not. Nearly half of all checkouts begin in a session that never added anything to a cart, because the cart was filled yesterday. Neither number is wrong. A chart that mixes them is, and this is the kind of thing that costs someone a morning.
2. Grouping a ratio metric by the thing it's filtered on destroys it
feature_adoption_rate is adopters / eligible users. The obvious way to get a
per-feature breakdown is to group it by feature_name. That returns 96% for
site search. The true figure is 5.1%.
A ratio metric's group-by applies to the denominator too, so grouping by feature
silently turns "all eligible users" into "eligible users who used this feature",
and the ratio collapses towards 1. The fix is four separate metrics that filter
only the numerator — which is why feature_adoption_rate_site_search exists as
its own metric rather than as a slice of the general one. The general metric's
breaks_if note now says so explicitly, because the trap is invisible until you
know the number is wrong.
3. GA4's sessionisation is fine, and I can prove it rather than assume it
The project adopts GA4's client-side ga_session_id rather than deriving
30-minute-gap boundaries server-side, on the grounds that it's the number a
stakeholder will argue with. That's a defensible choice but an untested one, so
int_sessions counts how many GA4 sessions contain an internal gap long enough
that a server-side rule would have split them.
The answer is 182 sessions out of 360,129 — 0.05%. The choice barely matters on this property. That's worth knowing precisely because it frees you to stop arguing about it, and it's only knowable because the disagreement was made into a metric instead of a footnote.
The engagement definitions are similarly close: GA4's own flag says 67.2% of sessions were engaged, an independently recomputed rule says 68.9%.
4. "Transactions" has three defensible values, and they differ by 28%
5,692
purchaseevents fired5,258 distinct transaction ids within a session, summed across sessions
4,452 distinct transaction ids across the whole period
The middle one is what the semantic layer publishes, because it stays correct when sliced by day or channel — a global distinct count cannot be summed across time buckets without double-counting. The cost is real: average order value reports $68.88 where a globally deduplicated count would report $81.35. The 806-order gap is order-confirmation pages reopened in a later session.
This is the sort of thing that surfaces as "finance says orders are down 15%" eighteen months later.
5. Left-censoring is measurable, not just a caveat
A user's "first session" inside a loaded window usually isn't their first
session ever. Most projects note this and move on. GA4 actually hands you the
answer in user_first_touch_timestamp, so is_left_censored compares the two
and flags the 8,417 users (3.1%) who existed before 2020-11-01. They're excluded
from every cohort metric.
Two smaller findings in the same vein: 5,272 sessions (1.5%) never fired a
session_start event at all, which is why the funnel's entry step is measured
from fct_funnel rather than from the session count. And user_engagement plus
scroll are 36% of all event volume — both fire without the user deciding
anything, which is why session depth is documented as a tagging-sensitive metric
rather than a behavioural one.
Architecture
models/
staging/ stg_events.sql 4.3m rows · one row per event, typed and renamed
intermediate/ int_sessions.sql 360k rows · sessionisation + the user first-seen anchor
marts/ fct_events.sql 4.3m rows · events with cohort and feature context
dim_users.sql 270k rows · activation, retention, eligibility flags
fct_sessions.sql 360k rows · session grain with acquisition attached
fct_funnel.sql 1.8m rows · 5 dense rows per session
semantic/ semantic_models.yml 4 semantic models · entities, dimensions, measures
metrics.yml 42 metrics · each with a governance block
metricflow_time_spine dense daily calendar
seeds/ feature_catalogue.csv what counts as a "feature", and what's just an impression
macros/ ga4_event_params.sql event_param unnesting, in one place
ga4_partitioning.sql conditional partitioning (see below)
tests/ assert_models_are_not_empty.sql
mcp_server/ server.py the agent-facing surface
scripts/ setup_gcp.sh one-time GCP project + credentials
render_metric_docs.py README generator, with a --check mode for CI
smoke_test_mcp.py drives the server over a real MCP sessionThree decisions worth explaining:
The first_touch_ prefix. In the GA4 BigQuery export, traffic_source is
user-scoped — it describes how the user was first acquired, not where the
current session came from. Naming it session_source is the single most common
error built on this dataset, so the columns are called first_touch_source,
first_touch_medium and first_touch_campaign and the docs say why. Every
per-session channel metric here inherits that limitation, and
revenue_per_session's breaks_if note spells out the consequence.
One elapsed-time axis. days_since_first_session is computed once in
int_sessions and carried down. Activation, retention and feature adoption all
measure from the same day zero. If each computed its own they'd disagree at the
boundaries and nobody could say which was right.
The feature catalogue is a seed, not a CASE statement. What counts as a
feature — and crucially which events are a user choosing something versus the
site rendering something — lives in seeds/feature_catalogue.csv with an
is_user_initiated flag. view_promotion fires 190,104 times against 9,450
select_promotion clicks, a 20:1 ratio. Counting impressions would report
promotions adoption near 100% and mean nothing.
The catalogue is property-specific, not universal: view_cart and
add_to_wishlist are standard GA4 recommended events and would belong here in
most stores, but the Google Merchandise Store never fired them in this window,
so cart-review and wishlist adoption are simply not measurable on this data.
Listing an event that never fires produces a permanent zero that reads like a
product failure.
The agent layer
mcp_server/server.py is an MCP server over stdio exposing seven tools:
Tool | What it does |
| The 42 metrics, filterable by substring |
| The full four-part governance block for one metric |
| Entities, dimensions and measures per model |
| What a metric can legitimately be sliced by — 16 for |
| Runs the query through MetricFlow, returns rows plus governance |
| The SQL MetricFlow would run, without running it |
| Manifests present, MetricFlow reachable, credentials set |
There is deliberately no tool that accepts SQL.
The headline demo, run against the live warehouse by
scripts/smoke_test_mcp.py:
query_metrics(
metrics = "d7_retention_rate,d7_eligible_users",
group_by = "metric_time__month",
where = "{{ Dimension('user__first_device_category') }} = 'mobile'"
){
"rows": [
{"metric_time__month": "2020-11-01", "d7_retention_rate": "0.0977", "d7_eligible_users": "29120"},
{"metric_time__month": "2020-12-01", "d7_retention_rate": "0.0706", "d7_eligible_users": "38743"},
{"metric_time__month": "2021-01-01", "d7_retention_rate": "0.0693", "d7_eligible_users": "28311"}
],
"governance": {
"d7_retention_rate": {
"definition": "d7_retained_users / d7_eligible_users. A user is retained if they had at least one session on any of days 1-7 after their first session. Day 0 is excluded.",
"assumption": "Returning at all is the signal. A user who returns on day 2 for eight seconds and bounces counts exactly the same as one who returns daily.",
"tradeoff": "This is \"bounded\" or \"rolling\" D7 — any return in the first week — chosen over \"classic\" D7, which counts only users active on day 7 exactly…",
"breaks_if": "Someone compares it with a classic day-7-exact figure, or includes day 0. Including day 0 pushes this above 60% on this dataset…"
}
}
}Note what metric_time means there. For a cohort metric it's the user's
first-seen month, not the month they came back — the retention of the
November cohort, not retention observed in November. Getting that wrong is the
most common retention error there is, and the semantic layer makes it structural
rather than a matter of remembering.
Ask for something that doesn't exist and the refusal is informative rather than an opaque error:
{
"refused": true,
"error": "Unknown metric(s): weekly_active_users.",
"reason": "This server only serves metrics defined in the semantic layer. It has no raw-SQL tool, so a metric that is not defined cannot be approximated here.",
"next_step": "Either pick an existing metric from available_metrics, or tell the user the metric would need to be added to models/semantic/metrics.yml and reviewed before it can be reported."
}Connecting it
{
"mcpServers": {
"ga4-semantic-layer": {
"command": "/absolute/path/to/dbtBigQuery/.venv/bin/python",
"args": ["-m", "mcp_server.server"],
"cwd": "/absolute/path/to/dbtBigQuery",
"env": {
"DBT_BIGQUERY_PROJECT": "your-gcp-project-id",
"DBT_PROFILES_DIR": "/absolute/path/to/dbtBigQuery"
}
}
}
}Testing: the day 97 tests passed against six empty tables
Worth recording, because it's the most useful thing this project taught me.
Every model was originally partitioned on its date column — the obviously
correct choice for BigQuery. The build succeeded. dbt build exited 0. All 97
data tests passed. Every table was empty.
BigQuery's free sandbox forces a 60-day expiration on every partition and
won't let you raise it. This dataset is from 2020–21, so every partition was
born already expired. BigQuery accepted the CREATE TABLE, scanned the full 2.9
GiB source, and wrote nothing. The one model that kept its rows was the time
spine — the only unpartitioned model in the project.
The tests didn't catch it because generic tests cannot fail on an empty
table. not_null asks "how many rows are null?" — zero. unique asks "how
many duplicates?" — zero. So do accepted_values, relationships and every
expression_is_true assertion. Each one asks how many rows break a rule, and an
empty table has no rows to break it. 97 green checks, no data.
Two things came out of it:
tests/assert_models_are_not_empty.sql asserts a row count on all eight
models. Row-count assertions are the only kind that can't pass vacuously, which
is why every project needs at least one.
Partitioning is now conditional and off by default — see
macros/ga4_partitioning.sql. The project builds correctly in a free sandbox
for anyone who clones it, and partitioning turns on with one var once a billing
account is attached:
dbt build --vars 'enable_partitioning: true'Clustering stays on either way; it needs no billing account and does most of the pruning work at this volume.
The rest of the test suite
98 data tests: uniqueness and not-null on every key, accepted_values on every
enumerated dimension, relationships across all four marts, accepted_range on
every count and duration, and model-level expression_is_true assertions for
the invariants that actually encode business logic —
page_views <= events_in_session, engaged_sessions <= sessions,
not (is_activated and first_purchase_at is null),
not (is_reached_in_sequence and not is_reached).
One result worth reporting because I expected it to fail: GA4 exports no unique
event identifier, so event_key is a surrogate over six fields
(user_pseudo_id, ga_session_id, event_at, event_name,
event_bundle_sequence_id, page_location). The uniqueness test was written
expecting collisions. It passes cleanly across all 4,295,584 events.
Running it yourself
You need a Google account. You do not need a credit card — the free BigQuery
sandbox is enough. A full build bills 6.75 GiB across 106 query jobs, measured
from INFORMATION_SCHEMA.JOBS_BY_PROJECT rather than estimated, against a
1 TiB monthly free allowance — roughly 150 full builds a month before you pay
anything.
git clone <this repo> && cd dbtBigQuery
# 1. Google Cloud: creates a dedicated project, enables BigQuery, writes
# credentials, and smoke-tests access to the public dataset.
./scripts/setup_gcp.sh
# 2. Python toolchain
uv venv --managed-python --python 3.12 .venv
uv pip install -e .
# 3. Point dbt at your project
export DBT_BIGQUERY_PROJECT=<the project id the script printed>
export DBT_PROFILES_DIR=.
# 4. Build and test
.venv/bin/dbt deps
.venv/bin/dbt build # ~75s, 6.75 GiB billed, 106 nodes + 2 exposures
# 5. Query a metric
.venv/bin/mf query --metrics activation_rate,d7_retention_rate,session_depth
# 6. Drive the MCP server the way an agent would
.venv/bin/python scripts/smoke_test_mcp.pyTwo knobs in dbt_project.yml worth knowing about:
vars:
ga4_start_date: '20201101' # narrow this while developing to cut bytes billed
ga4_end_date: '20210131'
activation_window_days: 7 # changing this changes activation_rate, in one place
session_gap_minutes: 30
enable_partitioning: false # see the sandbox story aboveThe BI layer
Two exposures are declared in models/exposures.yml, so
dbt ls --select +exposure:* answers "what do I break if I change this model?"
before the change ships rather than after a dashboard goes blank. The MCP server
is listed as an exposure alongside the Power BI report deliberately — an agent
consuming the semantic layer is a consumer like any other, and it is the one
most likely to be forgotten in an impact analysis precisely because nobody has
to log in to notice it is broken.
Power BI is also the only consumer here that can disagree with the semantic
layer, because the BigQuery connector reads the mart tables directly instead of
going through MetricFlow. docs/powerbi/README.md sets out the two rules that
keep it honest — filter on the pre-computed eligibility flags rather than
recomputing windows in DAX, and build the funnel on is_reached_in_sequence
rather than is_reached — along with the connection steps and the relationship
model.
The report screenshot is not in this repo. Power BI Desktop is Windows-only and this project was built on macOS; the generated SVG above is produced from the same metrics and is the artefact that stays in sync automatically.
What this demonstrates
For anyone evaluating this as a work sample rather than reading it end to end — each row names a capability and the file that evidences it.
Capability | Where to look |
Dimensional modelling at three grains, plus a dense session × step fact | |
Advanced SQL — window functions, | |
Metrics-as-code / semantic layer — 4 semantic models, 42 metrics, ratio metrics with per-input filters | |
Data quality engineering — 98 tests, and the discovery that generic tests cannot fail on an empty table | |
Warehouse cost control — var-driven scan windows, a per-query | |
Documentation as code — 213 documented columns, and a metric dictionary generated from the same YAML the warehouse reads | |
AI / agent integration — an MCP server over a governed metric layer, with structured refusals | |
Python engineering — 1,376 lines across an MCP server and three tooling scripts, linted and formatted in CI | |
CI/CD — credential-free validation, plus a drift check that fails the build if docs and definitions disagree | |
BI integration and impact analysis — exposures covering both the dashboard and the agent | |
Data visualisation — a chart generated from live metric queries, on a colour-blind-validated palette | |
Analytical judgement — five documented cases where the data contradicted the first draft of a definition |
The last row is the one I would read first. Everything above it is craft that can be learned from documentation; that one is the part that only shows up when somebody checks their own assumptions against the data and writes down what they found.
What this project does not do
No identity resolution.
user_pseudo_idis a browser on a device, not a person.user_idexists in the GA4 schema and is null throughout this sample, so cross-device stitching isn't possible and isn't faked. Every "user" number here is really a device-browser.No multi-touch attribution. GA4's traffic source is user-scoped and first-touch, and this sample's values are partly obfuscated — 313,917 events carry the medium
(data deleted)and 597,482 carry<Other>. Channel comparisons on this data are illustrative, not decisive.No incremental models. Everything is a full-refresh table. dbt's incremental strategy on BigQuery emits
MERGE, which the free sandbox doesn't support. At 4.3m rows and 75 seconds a build, incrementality would be complexity without benefit.No refunds. The sample has no refund or cancellation signal, so revenue is gross and returns are invisible.
Only 92 days. D30 retention requires a full 30-day window, which discards roughly a third of all cohorts. It's published, but any segment cut of it gets noisy fast.
Licence
MIT. The GA4 sample dataset is Google's, published under the Creative Commons Attribution 4.0 licence.
Available Tools
7 toolsdescribe_metricA
Return the full governed definition of one metric.
Args:
metric: The exact metric name, as returned by `list_metrics`.
Returns the definition, the assumption it rests on, the trade-off accepted
when it was chosen, what breaks if someone defines it differently, its
grain, and the measures or metrics it is computed from. This is the
authoritative text — it is read from the compiled dbt manifest, which is
generated from models/semantic/metrics.yml.
| Name | Required | Description | Default |
|---|---|---|---|
| metric | Yes |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full burden. It discloses the read-only nature implicitly with 'Return' and explicitly states the source: 'read from the compiled dbt manifest, which is generated from models/semantic/metrics.yml, and calls it 'authoritative text.' This goes beyond a simple operation statement, though it does not mention error handling or edge cases.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is tightly organized: purpose, argument, return contents, and source. Every sentence adds value—no filler or repetition. The key purpose is front-loaded, and the additional details are compactly grouped in the 'Returns' sentence.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple single-parameter tool with an output schema, the description is complete. It explains the input source, lists the return components (definition, assumption, trade-off, breakage, grain, measures/metrics), and clarifies the authoritative source. No essential information an agent needs to call it correctly is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The input schema has 0% description coverage for the 'metric' parameter, so the description must compensate. It does so by specifying 'The exact metric name, as returned by list_metrics,' which adds critical guidance about validity and source that the schema lacks. It could include format examples or case sensitivity, but the provided semantics are sufficient.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with 'Return the full governed definition of one metric,' which names a specific verb, resource, and scope. It distinguishes this from siblings like list_metrics (which lists) and query_metrics (which queries data), leaving no ambiguity about what the tool does.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides clear context: the metric argument must be 'the exact metric name, as returned by list_metrics,' which tells the agent how to obtain a valid input. However, it does not explicitly state when to prefer this over alternatives like explain_metric_sql or query_metrics, so it lacks explicit exclusions.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
explain_metric_sqlA
Return the SQL MetricFlow would run for a query, without running it.
Args:
metrics: Metric names, comma-separated.
group_by: Dimensions to group by, comma-separated.
start_time: Inclusive ISO date lower bound on metric_time.
end_time: Inclusive ISO date upper bound on metric_time.
where: A MetricFlow filter expression.
Useful for showing a person how a metric is actually computed, and for
checking a query's cost before spending warehouse credits on it.
| Name | Required | Description | Default |
|---|---|---|---|
| where | No | ||
| metrics | Yes | ||
| end_time | No | ||
| group_by | No | ||
| start_time | No |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden of behavioral disclosure. It clearly states that the tool does not execute the query and instead returns SQL, and it explains the diagnostic purpose. It does not discuss permissions or failure modes, but for a read-only explanation tool this is a minor gap.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description opens with a crisp definition, then a tight parameter list, then two concise use-case sentences. Every sentence earns its place, and there is no redundant restatement of the schema.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
All five parameters are described, the non-executing behavior is explicit, and the use cases are clear. The output schema covers return value details, so the description need not explain those. The only minor gap is not explicitly naming query_metrics as the alternative for when actual results are needed.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the parameter block in the description is essential. It documents all five arguments with meaningful semantics: comma-separated metric names, grouping dimensions, inclusive ISO date bounds on metric_time, and a MetricFlow filter expression. The `where` grammar is slightly vague, but the description compensates well for the missing schema descriptions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The first sentence states a specific action and resource: 'Return the SQL MetricFlow would run for a query, without running it.' This clearly distinguishes the tool from query_metrics, which would execute the query and return data. The scope is unambiguous.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives concrete use cases: showing how a metric is computed and checking query cost before spending warehouse credits. It does not explicitly name query_metrics as the alternative when execution is desired, but the 'without running it' phrasing establishes the boundary clearly.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
health_checkA
Report whether the semantic layer is ready to serve queries: manifests
present, MetricFlow available, warehouse credentials configured.
Call this first if a query fails for reasons that look environmental.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full burden. It discloses what the tool checks and frames the output as a readiness report. It stops short of explicitly stating whether it performs external calls or whether it is side-effect-free, but the stated checks give sufficient behavioral context for a health-check tool.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences with no filler. The purpose and checks are front-loaded, and the usage guidance follows immediately. Every sentence earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With zero parameters and an output schema present, the description provides everything an agent needs to decide when to call it and what it will report. No inputs, preconditions, or side-effect warnings are missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool has zero parameters and schema coverage is 100%, so there is no parameter semantics for the description to add. Per the baseline for zero-parameter tools, this is fully adequate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description states a specific verb and resource: 'Report whether the semantic layer is ready to serve queries,' and enumerates concrete readiness checks (manifests present, MetricFlow available, warehouse credentials configured). This clearly distinguishes health_check from the sibling metric-querying tools.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
It provides an explicit invocation condition: 'Call this first if a query fails for reasons that look environmental.' This is a clear, actionable decision rule. The sibling tools are for metric operations and are not realistic alternatives for a health check, so no exclusion guidance is needed.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_dimensionsA
List the dimensions a given set of metrics can legitimately be grouped by.
Args:
metrics: One or more metric names, comma-separated. When several are
given, only dimensions valid for *all* of them are returned —
which is the honest answer, because MetricFlow will refuse a
group-by that has no join path to one of the metrics.
Dimension names are returned in MetricFlow's `entity__dimension` form,
which is exactly what `query_metrics` expects in `group_by`.
| Name | Required | Description | Default |
|---|---|---|---|
| metrics | Yes |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full burden of behavioral disclosure. It explains the filtering logic for multiple metrics (only dimensions valid for all) and the rationale ('because MetricFlow will refuse a group-by that has no join path to one of the metrics'). It also clarifies the output format (entity__dimension) and its direct compatibility with query_metrics. This is solid behavioral context, though it does not cover error handling, limits, or edge cases, so a 4 is appropriate.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is front-loaded with the purpose, then moves to parameter details and behavioral notes. It is concise at roughly 100 words, and each sentence adds value—explaining the union semantics, the 'honest answer' rationale, and the output format. It could be slightly tighter, but the structure is logical and efficient, earning a 4.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description covers the input parameter behavior, the filtering semantics, and the output format, and it ties the output to query_metrics expectations. Since an output schema exists, return-value details are presumably documented there. The description is complete for a read-only discovery tool, though it could mention potential empty results or error conditions. This is slightly above average, so a 4.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must fully explain the parameter. It does so clearly: 'metrics: One or more metric names, comma-separated' and explains the behavior when multiple are given. Since there is only one parameter and it is thoroughly documented, the description fully compensates for the schema's lack of parameter descriptions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a clear, specific statement: 'List the dimensions a given set of metrics can legitimately be grouped by.' This identifies the verb, resource, and scope precisely. It also distinguishes itself from sibling tools by referencing the exact form expected by query_metrics, making its purpose unmistakable even without naming alternatives.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides useful context about behavior when multiple metrics are given (only dimensions valid for all), and notes the exact form needed by query_metrics, which implicitly guides when to use it. However, it does not explicitly state when to use this tool over alternatives like list_metrics or describe_metric, nor does it mention exclusion conditions. The guidance is clear but lacks explicit 'use this instead of X' or 'when not to use' statements.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_metricsA
List every metric the semantic layer defines.
Args:
search: Optional case-insensitive substring to filter metric names,
labels and descriptions. Leave empty to list all.
Returns the metric name, label, type, one-line description and the short
definition. Use `describe_metric` for the full governance block.
| Name | Required | Description | Default |
|---|---|---|---|
| search | No |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the full burden of behavioral disclosure. It states what is returned (metric name, label, type, description, short definition) and implies a read-only operation, but it does not disclose potential limitations like result size, pagination, or permission requirements. The behavior is clear but not exhaustive.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is concise and well-structured: a one-sentence purpose, a clear args explanation, and a return summary. It front-loads the primary action and includes no redundant text, making it easy for an agent to parse quickly.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
The description is complete for a list tool: it explains the search parameter, the return fields, and points to describe_metric for deeper detail. An output schema exists, so the description need not elaborate the exact structure. Minor gaps like pagination or maximum results are not mentioned, but given the simplicity, it is largely sufficient.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, but the description fully compensates by explaining the search parameter: it is optional, case-insensitive, and filters on metric names, labels, and descriptions, with 'Leave empty to list all' clarifying the default. This adds significant meaning beyond the schema's bare type and default.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool's purpose with a specific verb and resource: 'List every metric the semantic layer defines.' It distinguishes itself from siblings like list_semantic_models (which lists models) and describe_metric (which provides full governance details), and the mention of describe_metric further clarifies scope.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides partial usage guidance by pointing to describe_metric for full governance details, implying this tool gives short definitions. However, it does not explicitly compare against other siblings like list_dimensions or query_metrics, nor does it state when not to use this tool. This is adequate but not comprehensive.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_semantic_modelsA
List the semantic models — the tables the metrics are built on, with their entities, groupable dimensions and aggregatable measures.
Use this to understand why two metrics can or cannot be sliced the same way.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
No annotations are provided, so the description carries the burden. It discloses the tool is a read-only listing operation and explains what the returned models contain, but it does not mention pagination, output size, or any side effects. For a simple list tool with no annotations, this is adequate but not rich.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two short sentences, front-loaded with the core definition and followed by a concrete use case. No wasted words.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a zero-parameter list tool with an output schema present, the description is nearly complete. It explains the purpose and contents of the result. It could mention whether the list is exhaustive or ordered, but that is minor given the output schema exists.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool has zero parameters, so there is no schema burden. The description adds value by explaining what the returned objects represent (entities, dimensions, measures), which is the only semantic content an agent needs.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description clearly states the tool lists semantic models, defines them as the tables metrics are built on, and enumerates their contents (entities, groupable dimensions, aggregatable measures). It also gives a concrete use case, distinguishing it from siblings like list_metrics or list_dimensions.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides a clear when-to-use signal: use it to understand why two metrics can or cannot be sliced the same way. It does not explicitly name alternatives or exclusions, but the use case is specific enough to guide an agent.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
query_metricsA
Run a metric query through MetricFlow and return the rows, together with the
governed definition of every metric involved.
Args:
metrics: Metric names, comma-separated. Must already exist in the layer.
group_by: Dimensions to group by, comma-separated, in MetricFlow's
`entity__dimension` form (e.g. `user__acquisition_medium`).
Use `metric_time` for the metric's own time axis, optionally with a
grain suffix such as `metric_time__week`.
start_time: Inclusive ISO date lower bound on metric_time, e.g. 2021-01-01.
end_time: Inclusive ISO date upper bound on metric_time.
where: A MetricFlow filter expression, e.g.
"{{ Dimension('user__first_device_category') }} = 'mobile'".
Dimensions must be referenced through the Dimension() wrapper; raw
column names are rejected by the layer, not by this server.
order_by: Fields to sort by, comma-separated. Prefix with `-` for
descending, e.g. `-metric_time`.
limit: Maximum rows to return. Capped at 500.
The response always includes a `governance` block. Report it alongside the
numbers rather than presenting the figures bare.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | ||
| where | No | ||
| metrics | Yes | ||
| end_time | No | ||
| group_by | No | ||
| order_by | No | ||
| start_time | No |
Output Schema
| Name | Required | Description |
|---|---|---|
No output parameters | ||
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations provided, the description carries the full burden and does so well. It discloses the mandatory governance block, the requirement to use the Dimension() wrapper in where (and that raw columns are rejected by the layer, not the server), the limit cap of 500, and the inclusive ISO date bounds. It doesn't mention permissions or side effects, but as a read-only query tool this is sufficient.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
The description is well-structured with a clear 'Args' section, and every sentence adds value. It front-loads the purpose and then details parameters without redundancy. The length is appropriate for the tool's complexity, and there is no fluff.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the tool's complexity (7 parameters, one required) and the existence of an output schema, the description is complete. It covers all parameters, gives usage examples, and highlights the governance block in the response. There is no missing information an agent would need to call it correctly.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 0%, so the description must explain all seven parameters—and it does comprehensively. It gives format examples for group_by, start_time, end_time, where, order_by, and limit, and clarifies constraints like the metric_time grain suffix and the cap at 500. This fully compensates for the missing schema descriptions.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
The description opens with a specific verb ('Run a metric query') and resource ('through MetricFlow'), and states the output: rows plus governed definitions. This clearly distinguishes it from siblings like list_metrics (which enumerates metrics) and describe_metric (which explains a single metric).
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description provides clear context for when to use the tool—when you need metric data rows with governance—and gives prerequisites (metrics must exist in the layer). It does not explicitly name alternatives or say when not to use it, but the purpose is so specific that an agent can infer the appropriate context.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
7 tool updates
v1.0.0- First observed
describe_metric - First observed
explain_metric_sql - First observed
health_check - First observed
list_dimensions - First observed
list_metrics - First observed
list_semantic_models - First observed
query_metrics
TDQS
Scored across 7 tools
Each tool targets a distinct concern: listing metrics, listing semantic models, describing a single metric, listing valid dimensions, running queries, explaining SQL, and checking health. There is no overlap or ambiguity—an agent can clearly choose the right tool for the task.
All tool names follow a consistent verb_noun snake_case pattern (list_*, describe_metric, query_metrics, explain_metric_sql, health_check). No mixed conventions or vague verbs; the naming is predictable and self-documenting.
Seven tools is well-scoped for a semantic layer server. Each tool covers a necessary aspect of exploring, querying, and validating metrics without redundancy or bloat. The count feels right for the domain.
The surface covers the full read-only lifecycle of the semantic layer: discovery (list_metrics, list_semantic_models), definition (describe_metric), grouping validation (list_dimensions), execution (query_metrics), cost/explanation (explain_metric_sql), and environment readiness (health_check). No obvious gaps for the stated purpose.
Maintenance
Related MCP Connectors
Query your warehouse or a CSV with Claude/ChatGPT over MCP, governed by table-level ACL + audit.
Query your org's data in natural language — read-only MCP access to SQL, NoSQL, files & warehouses.
Let AI agents query data and act across all your business apps via MCP.
Analytics your AI agent can actually use. Track, experiment, and optimize via MCP.
Related MCP Servers
- AlicenseBqualityDmaintenanceEnables LLM applications to query Google Analytics 4 data through standard MCP interfaces, supporting real-time data, custom reports, and metadata discovery.525 npm1MIT
- AlicenseNot gradedqualityDmaintenanceEnables querying Google Analytics 4 data using natural language through MCP clients like Claude and Cursor, supporting 200+ dimensions and metrics for traffic, user behavior, and e-commerce analysis.MIT
- AlicenseAqualityDmaintenanceEnables querying Google Analytics 4 properties using natural language through MCP clients. Supports customizable reports with any dimensions and metrics, listing properties, and real-time data.4MIT
- AlicenseAqualityDmaintenanceEnables LLM applications to query and analyze Google Analytics 4 data through standard MCP interfaces, supporting service account and OAuth2 authentication.59 npmMIT