Back to blog
Article · MCP

Two Tools, Any Question: Building Axioma's MCP on a Semantic Layer

Why we skipped both text-to-SQL and one-tool-per-chart for Axioma's MCP, built two general tools on a Cube semantic layer instead, and let the schema descriptions do the real work.

Gal Ben Ami · · 9 min read ·
MCP AI Agents Semantic Layer Cube BigQuery Analytics
Two Tools, Any Question: Building Axioma's MCP on a Semantic Layer
2
General tools at the core
0
Lines of SQL the model writes
0
New MCP code per new cube
1
Query path for every tool

Axioma shows brands how AI engines like ChatGPT, Perplexity and Google’s AI see their sites: which pages AI agents visit, which brands the engines recommend, and how that lines up with Google Search Console demand. Its MCP (Model Context Protocol) server lets customers ask those questions in plain language from Claude or any other MCP client.

The tempting first design for an analytics assistant is text-to-SQL: give the model a tool that runs SQL over your warehouse and let it figure out the rest. It looks great in a demo and falls apart on real questions. It picks the wrong table out of three that look alike. It joins on a column that fans out and double-counts. It reinvents “AI visits” slightly differently every time, so its number never matches the dashboard. And you have to trust it to add the right WHERE clause so one customer never sees another customer’s rows. We never shipped that tool, and this post is about what we built instead.

The usual fix is to take the query away from the model: one tool per chart. Traffic by engine. Top pages. Keyword breakdown. Share of voice over time. Safe, but every real customer question is slightly off from the menu. “ChatGPT vs Claude visits, daily, for one domain” is not “traffic by engine.” “Which pages did Perplexity start visiting this month that it didn’t visit last month” is not “top pages.” A one-tool-per-chart server either grows to hundreds of tools or keeps answering “I don’t have a tool for that.”

Before we wrote a line of the Axioma MCP, we wanted a third option: an assistant that is context-aware, that understands what data we have and figures out how to answer, without ever writing SQL. So the first two tools we built were not charts at all. One describes our data. The other queries it, through a semantic layer instead of raw tables. Instead of teaching the MCP hundreds of questions, we taught it how to ask questions safely.

too much freedom
Text-to-SQL
The model writes SQL over raw tables.
Wrong table, wrong join, wrong WHERE clause.
too little freedom
One tool per chart
The model picks from a fixed menu.
“I don’t have a tool for that.”
what we built
Semantic query
The model reads descriptions and composes a Cube query.
Answers the question nobody anticipated.
Three ways to let a model answer a data question. The first two fail in opposite directions: too much freedom, or too little.

The two tools that do the heavy lifting

Axioma’s analytics already run on Cube, a semantic layer that sits between our dashboards and BigQuery. Cube models data as cubes with named measures (things you count or sum), dimensions (things you group or filter by), and time dimensions. A dashboard chart is just a JSON query against that model.

That is the layer we exposed, through two tools:

  • get_cube_schema returns the cubes the caller is allowed to see, with their descriptions. Ask for a specific cube and it returns that cube’s measures and dimensions, each with its own description.
  • run_cube_query takes a Cube JSON query (measures, dimensions, filters, time range, order, limit) and runs it.
query path · the model never touches a table
server side · scoping, joins and SQL live hereQuestionfrom the customerModelcomposes queryCube descriptionswhat each cube meansMCP serveradds tenant filterCubeowns joins + SQLBigQuerywarehouseget_cube_schemarun_cube_queryrows back to the model, trimmed
The model composes the query, the server scopes it, and Cube writes the SQL. No step hands raw table access to the model.

That’s the whole query surface. The model discovers what exists, decides what it needs, and composes the query itself. A question like “Compare ChatGPT vs Claude daily visits for example.com this month” becomes, with no dedicated tool:

question → cube query · no dedicated tool
“Compare ChatGPT vs Claude daily visits for example.com this month”
what to count
visits
{
"measures": ["site_visits_daily.ai_visits"],
how to split it
ChatGPT vs Claude
"dimensions": ["site_visits_daily.agent"],
when
dailythis month
"timeDimensions": [{
"dimension": "site_visits_daily.day",
"granularity": "day",
"dateRange": "this month"
}],
which rows
example.comChatGPT, Claude
"filters": [
{ "member": "site_visits_daily.domain",
"operator": "equals", "values": ["example.com"] },
{ "member": "site_visits_daily.agent",
"operator": "equals", "values": ["chatgpt", "claude"] }
]
}
Each part of the question maps to one part of the query. Swap any part and the same tool answers a different question.

We didn’t have to predict the question. We just had to make the data describe itself well enough that the model could find the answer.

This is also why most of the text-to-SQL problems go away. The model never touches a table or a join. Every join is defined once in the cube, so nothing fans out. Every measure is defined once, so “AI visits” means the same thing in the MCP as on the dashboard. And the model never writes the tenant filter, so there is no WHERE clause to get wrong. That filter is enforced in two layers: the MCP server adds a tenant_id filter to every query and forwards the caller’s JWT, then Cube’s checkAuth builds a security context from that token and queryRewrite enforces the tenant filter again.

What’s left is the model’s real decision: which cube, which members and which filter values. It can still get that wrong, picking the wrong cube or filtering on a value that doesn’t exist. It is the same kind of mistake as before, just in a much smaller space, and that is exactly the decision the descriptions are written to guide.

The schema is the prompt

The part that makes this work isn’t the tool. It’s the descriptions.

A schema that lists site_visits_daily, site_visits_with_gsc, and site_visits_with_trend with no explanation is useless to a model. It would guess, and it would guess wrong. So we treat every cube description and every field description as prompt engineering. They are written for the model, in the model’s terms: what the cube is, what a row means, which questions it answers, and, critically, when to pick a different cube instead.

three similar cubes · when the model should pick each
site_visits_dailydefault
Page-level AI traffic: one row per day, domain, page and agent.
site_visits_with_gsc
The question mixes AI traffic with Google Search Console impressions, clicks or position.
site_visits_with_trend
The question is “up or down versus last month”: the period-over-period change is built in.
Without descriptions, these are three names that look alike. With them, the model knows which one fits the question.

In the cube definition, each of these is just a description field, written right next to the cube, measure or dimension it explains. Here is a simplified version of site_visits_daily:

# site_visits_daily.yml (illustrative example)
cubes:
  - name: site_visits_daily
    sql_table: analytics.site_visits_daily
    description: >
      Daily AI-agent visits: one row per day, domain, page and agent. The default cube for
      page-level traffic questions ("which pages did ChatGPT visit?"). Use site_visits_with_trend
      instead when the question asks for a change versus a previous period.
    measures:
      - name: ai_visits
        sql: visit_count
        description: >
          Times an AI agent fetched a page to answer a user's prompt (not training crawls).
          This is the headline number on the dashboard, so answers should use it by default.
    dimensions:
      - name: agent
        sql: agent_slug
        description: >
          The AI agent behind the visit. Values: chatgpt, claude, perplexity, gemini.
          Filter on these exact slugs.
The description lives next to the definition it explains, so it ships, and changes, with the cube.

Each description has a job. The cube’s description says what one row is and when to switch to site_visits_with_trend, so the model picks a cube by the shape of the user’s question. The ai_visits description marks it as the headline number on the dashboard, so the MCP’s answer matches what the customer already sees. And the agent description lists the exact values (chatgpt, claude, perplexity, gemini), so the model filters on slugs that exist instead of inventing ChatGPT-4.

It also keeps the MCP cheap to grow:

[shipped] new cube on the dashboard → queryable through the MCP · new MCP code: 0 · work: one good description.

What we learned once real users arrived

The flexible primitive worked, but we also watched what people actually asked. A handful of questions dominated: per-engine traffic for a set of domains, top pages, top keywords, comparing snapshot runs. For those, composing a query from scratch every time was slower and occasionally inconsistent with the dashboard.

So we added named tools for the common questions. The important detail is how: each one is a thin recipe that builds a Cube query in code and runs it through the exact same run_cube_query path, with the same scoping, the same freshness window, and the same response trimming.

The freshness window exists because our data lands about four days late, so a plain “last 7 days” range would include days that aren’t fully loaded and would undercount. The shared path turns the default range into the seven most recent complete days, and the named tools build their ranges the same way. Response trimming happens on the way back: the server strips Cube’s extra metadata and shortens keys like site_visits_daily.ai_visits to ai_visits (unless two keys would collide), so the model gets smaller rows that are easier to read.

A query the model used to rebuild from the descriptions on every call is now code that can’t be miscopied.

The two layers complement each other. Named tools make the frequent questions fast and dashboard-exact. run_cube_query remains the escape hatch for everything else, and “everything else” is where an AI assistant earns its keep. When a customer asks the question nobody anticipated, the MCP doesn’t say “no tool for that.” It reads the schema, picks the cube the description points it to, and answers.

named tools + escape hatch · one shared path
get_traffic_by_enginelist_top_pageslist_top_keywordsget_run_comparisonrun_cube_querythe escape hatchShared query pathscoping · freshnessresponse trimmingCubeBigQuerynamed tools = recipes written in code
Named tools and the escape hatch run through one path, so a fix to scoping or freshness lands everywhere at once.

The takeaway

If you’re building an MCP server on top of an analytics stack, don’t start with text-to-SQL or with one tool per chart. Expose your semantic layer through a small number of general tools, then invest the saved effort in writing descriptions as if the model were a new analyst on their first day. Route between similar datasets explicitly. Name the dashboard-headline metric. List real enum values. Once real usage shows which questions dominate, add named tools for those, as recipes on the same query path rather than a replacement for it. Let the semantic layer own the joins, the metric definitions, and the access rules, so the model never has to.

The model turns out to be a very good analyst. It just needs a well-documented schema and a way to run a query.

Gal Ben Ami is a software engineer at Codo.

Keep reading