<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel Busch</title>
    <description>The latest articles on DEV Community by Daniel Busch (@daniel_busch_d161c31d80a0).</description>
    <link>https://dev.to/daniel_busch_d161c31d80a0</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4230102%2Fdfefbcf6-8920-405e-8d19-7017a8d42c3b.png</url>
      <title>DEV Community: Daniel Busch</title>
      <link>https://dev.to/daniel_busch_d161c31d80a0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daniel_busch_d161c31d80a0"/>
    <language>en</language>
    <item>
      <title>Don't let the model write SQL: designing an MCP server around a semantic layer</title>
      <dc:creator>Daniel Busch</dc:creator>
      <pubDate>Mon, 09 Nov 2026 11:41:18 +0000</pubDate>
      <link>https://dev.to/adtribute/dont-let-the-model-write-sql-designing-an-mcp-server-around-a-semantic-layer-ae4</link>
      <guid>https://dev.to/adtribute/dont-let-the-model-write-sql-designing-an-mcp-server-around-a-semantic-layer-ae4</guid>
      <description>&lt;p&gt;Ask an LLM with a warehouse connection for last week's ROAS and you get an answer in seconds. It is confident, nicely formatted, and often wrong.&lt;/p&gt;

&lt;p&gt;The model doesn't know which attribution model your company reports with, whether "revenue" means gross or net, what counts as a new customer, or how contribution margin is defined. When it can't answer precisely, it still picks a plausible calculation, writes the SQL, and the query runs. Nothing flags the mismatch.&lt;/p&gt;

&lt;p&gt;A better prompt doesn't fix this. Taking the decisions away from the model does. Here is how we designed the &lt;a href="https://www.adtribute.io/mcp" rel="noopener noreferrer"&gt;adtribute MCP server&lt;/a&gt;, which lets clients like Claude query a company's marketing attribution data, built from shop systems like Shopify, ad platforms like Meta, Google and TikTok, CRM and email tools like HubSpot and Klaviyo, and first-party tracking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model picks keys, the server writes SQL
&lt;/h2&gt;

&lt;p&gt;Each organization has a semantic layer, exposed through drill-down tools such as &lt;code&gt;get_dimensions&lt;/code&gt; and &lt;code&gt;get_attribution_models&lt;/code&gt;: metrics, dimensions, attribution models and calculated fields, each with a key, a display name and a description. A small &lt;code&gt;get_context&lt;/code&gt; index lists the drill-downs and their entry counts but no keys, so a large catalog doesn't flood the context window.&lt;/p&gt;

&lt;p&gt;The query tools take keys, never SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ROAS by channel last week"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metrics"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"roas"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"date_range"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-26"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"to"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-11-01"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server validates every key against the organization's catalog; an unknown key fails with an error listing the valid ones, so the model can correct itself. Then the server compiles the query: identifiers must match a strict pattern and are quoted, filter values and dates are bound parameters, and the dataset comes from the authenticated organization, never from tool input.&lt;/p&gt;

&lt;p&gt;Validation also refuses questions the data can't answer honestly. An early version applied metric filters such as &lt;code&gt;spend &amp;gt; 0&lt;/code&gt; to raw rows before summing. Spend and conversions live on different rows, so that filter silently zeroed revenue. Now metric filters are rejected with a hint to sort and limit instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formulas live in the data model, not the prompt
&lt;/h2&gt;

&lt;p&gt;Metrics like ROAS, average order value and contribution margins are calculated fields the organization defines once, the same definitions its reporting uses. The model passes the key, and the server expands the stored formula after aggregation: a ratio of sums, never a sum of ratios. Divisions are guarded with &lt;code&gt;NULLIF&lt;/code&gt;, and inputs without rows count as zero. The tool description tells the model not to recompute formulas itself.&lt;/p&gt;

&lt;p&gt;When a ratio isn't defined as a calculated field, the model may send an &lt;code&gt;ad_hoc_formula&lt;/code&gt;: tokens over known metric keys, operators and constants. That still isn't SQL, and the answer labels the result as ad hoc.&lt;/p&gt;

&lt;p&gt;Ad-hoc formulas get a mechanical ambiguity check. If a chosen metric has siblings that share words in their names or descriptions, like gross and net revenue, and the user's &lt;code&gt;intent&lt;/code&gt; doesn't contain its name, the server returns the siblings for the user to pick from instead of a plan. Dimensions get no such check; there, the instructions tell the model to surface every plausible candidate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plan first, then query
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;plan_insights&lt;/code&gt; takes the query's inputs plus a required &lt;code&gt;intent&lt;/code&gt;, the question in the user's own words. It returns no metric values, only a &lt;code&gt;discovery_required&lt;/code&gt; payload or a plan: a confirmation block in display names, a provenance sentence, warnings about thinly described entries, and the exact row count. The model is told to paste the confirmation block verbatim and wait for the user to confirm before calling &lt;code&gt;get_insights&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each HTTP request gets a fresh MCP server instance, so &lt;code&gt;get_insights&lt;/code&gt; doesn't trust the plan. It re-runs validation and model selection; the plan's session id only correlates logs, and a call without one is logged.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server settles ambiguity, in a fixed order
&lt;/h2&gt;

&lt;p&gt;The LLM must never pick an attribution model silently, so the server settles it in the order a person would:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;settleModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Question&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ModelChoice&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;needsModel&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelNamed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;modelNamed&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sameUnderEveryModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// conversion-side questions only&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agree&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;agree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;model doesn't change these numbers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;organizationDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// exactly one marked default, or none&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;organization default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;viableModels&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A model counts as named if the user named it in the conversation or a loaded skill (an organization-specific runbook) names it. If the question only groups and filters by the conversion's own fields, such as date, country or product, the server runs it under every model and compares the numbers within a small tolerance. If they agree, the answer says the model doesn't matter. Next comes the default: the one model whose description starts with "Default Model". Two marked models count as none.&lt;/p&gt;

&lt;p&gt;Only then does the server ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"discovery_required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"discovery_kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"attribution_model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"viable_models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"First Touch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Linear 30 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instructions tell the model to list the options neutrally, without an "I'd use X", because the choice changes what the numbers mean. Applying the default in code instead of the prompt was deliberate: a reworded instruction behaves differently from run to run and can't be tested or logged.&lt;/p&gt;

&lt;p&gt;Numbers count by conversion date unless the caller asks for click date, and a click-date request for a model without click data is refused, not quietly answered by conversion date. When a user compares against a report and the date basis is unclear, the model is told to ask first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the response hard to misread
&lt;/h2&gt;

&lt;p&gt;Responses are written for a reader that likes to improvise:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;result_interpretation&lt;/code&gt; is a server-written sentence naming the model, date basis, metrics, date range, timezone and currency. The model is told to lead with it verbatim.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;display_labels&lt;/code&gt; map every key to its display name. The model is told to keep keys and SQL out of the chat; SQL comes back only on request, as a debug field.&lt;/li&gt;
&lt;li&gt;Totals, and a "rest" for rows that didn't fit, come from the same query via window sums over per-group parts, so ratios are recomputed, not added. When rows are cut, &lt;code&gt;was_truncated&lt;/code&gt;, &lt;code&gt;total_row_count&lt;/code&gt; and a note say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Read-only, per person, logged
&lt;/h2&gt;

&lt;p&gt;No tool writes anything. Lint rules ban database writes and raw SQL in the package, warehouse queries are SELECTs the server builds, and each runs under a cost cap and a timeout. Every number is an aggregate, such as a sum or a formula over sums, grouped by report dimensions.&lt;/p&gt;

&lt;p&gt;People connect through OAuth on a URL that names their organization, and the bearer token is verified on every request. The user must be a member of that organization with an allowed role (admins, editors and report viewers; limited report viewers are out), and the organization must have switched the MCP on in its settings, which is off by default. Non-members get one uninformative denial, so organization ids can't be probed; members get a reason they can act on.&lt;/p&gt;

&lt;p&gt;Every tool call writes a structured log line tied to the organization and the user. Planning and query calls also log the question in the user's words and how the attribution model was settled; query calls add the warehouse job id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for your own MCP server
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expose governed keys and compile the query yourself.&lt;/strong&gt; Return errors that list the valid options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep definitions in data, not prompts.&lt;/strong&gt; Formulas and defaults the server applies can be tested and logged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split plan from execute and detect ambiguity in code.&lt;/strong&gt; Return an "ask the user" payload, and re-check on execute: calls are stateless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write responses for an LLM reader.&lt;/strong&gt; Display names, a provenance sentence to quote, server-side totals, explicit truncation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate the person, not the workspace.&lt;/strong&gt; Gate by organization opt-in and role, and log intent next to the query id.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I work at adtribute, the company that builds the MCP server described in this post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>analytics</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
