<?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: Tanguy Compagnon de La Servette</title>
    <description>The latest articles on DEV Community by Tanguy Compagnon de La Servette (@tanguy_compagnon).</description>
    <link>https://dev.to/tanguy_compagnon</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%2F4030798%2F5fb15b95-b4d6-4661-add3-551fa4cc25fd.png</url>
      <title>DEV Community: Tanguy Compagnon de La Servette</title>
      <link>https://dev.to/tanguy_compagnon</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tanguy_compagnon"/>
    <language>en</language>
    <item>
      <title>How we built our FinOps MCP server: lessons learned</title>
      <dc:creator>Tanguy Compagnon de La Servette</dc:creator>
      <pubDate>Thu, 16 Jul 2026 14:40:34 +0000</pubDate>
      <link>https://dev.to/tanguy_compagnon/how-we-built-our-finops-mcp-server-lessons-learned-50lf</link>
      <guid>https://dev.to/tanguy_compagnon/how-we-built-our-finops-mcp-server-lessons-learned-50lf</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Our first MCP was basically our API, exposed as-is. Claude wouldn't use it. We had a dedicated tool to compare two periods and it never called it once: it just called &lt;code&gt;query_cost&lt;/code&gt; twice and did the subtraction itself. The tools made sense if you knew our database. The model didn't. We ended up rebuilding most of it, and the app is better for it.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://costory.io" rel="noopener noreferrer"&gt;Costory&lt;/a&gt; pulls in billing data from all your cost centers (AWS, GCP, Azure, Cursor, Anthropic, Aiven, Kubernetes Cost), normalizes it into one common format (FOCUS, from the FinOps Foundation), then ties the spend to the things that explain it: business metrics like DAU and revenue, events like tech releases or known incidents, and technical metrics from tools like Datadog and CloudWatch.&lt;/p&gt;

&lt;p&gt;Developing a &lt;a href="https://docs.costory.io/features/mcp" rel="noopener noreferrer"&gt;FinOps MCP server&lt;/a&gt; (Model Context Protocol) was an obvious fit. FinOps work is repetitive, our users are technical, and our whole point is that engineers should spend less time on FinOps, not more.&lt;/p&gt;

&lt;p&gt;But building the MCP turned out to be less about shipping an API and more about rethinking our product to make it more logical. LLMs need simple contracts, where every parameter means something.&lt;/p&gt;

&lt;h3&gt;
  
  
  The FinOps MCP we shipped first: a wrapper around our API
&lt;/h3&gt;

&lt;p&gt;Our first MCP was really just our app, exposed. Six tools, each one a mirror of an internal endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query_cost: return the cost time series
query_cost_diff: return a cost change between two periods
search_filters: search across dimensions
get_metric: retrieve a business or technical metric
query_metric: return the time series of a metric
create_alert: create an alert (with painfully complex parameters)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It worked, technically. But once we started using it, almost everything about it fought the model instead of helping it.&lt;/p&gt;

&lt;p&gt;The first one that stuck with us was &lt;code&gt;query_cost_diff&lt;/code&gt;. It mirrored our internal split between time-series and period-comparison endpoints, so we assumed the model would reach for it whenever it needed a comparison. It never did: ask Claude to compare this month to last month and it called &lt;code&gt;query_cost&lt;/code&gt; twice and did the subtraction itself. To us the split made sense. To the model it was noise — and to the customer, a source of errors whenever Claude's own arithmetic slipped.&lt;/p&gt;

&lt;p&gt;The filter format was worse. Internally every &lt;code&gt;where&lt;/code&gt; clause is a deeply nested react-querybuilder JSON object, and the model got the structure slightly wrong constantly, so perfectly reasonable queries failed on syntax before they ever ran.&lt;/p&gt;

&lt;p&gt;Eventually the common thread became obvious: the tools matched the shape of our database, not the way a model reasons about a problem. So we stopped forcing our API onto the model and started over. That meant changing four things: our query surface, our data model, our error semantics, and how we inject context. A wrapper optimizes for API parity; a native tool optimizes for what the model can reason about in a single shot.&lt;/p&gt;




&lt;h3&gt;
  
  
  Fix #1: One query tool instead of five
&lt;/h3&gt;

&lt;p&gt;Our first mistake was thinking the MCP's job was to answer specific questions. So we built a tool per question: one endpoint for cost, one for a period diff, one for metrics, one for budgets. Every time a new kind of question came up: budget end of month vs cost, cost per DAU, savings over time, we'd go build another endpoint for exactly that shape. It doesn't scale for us (small dev team, many endpoints), and it caps what the model can ever ask: the model can only pose the questions we already anticipated and hard-coded.&lt;/p&gt;

&lt;p&gt;So we stopped shipping answers and shipped tools instead. There's now a single &lt;code&gt;query&lt;/code&gt; tool. You hand it a list of named queries, each one a &lt;code&gt;type&lt;/code&gt; (&lt;code&gt;cost&lt;/code&gt;, &lt;code&gt;metric&lt;/code&gt;, &lt;code&gt;usage&lt;/code&gt;, &lt;code&gt;externalMetric&lt;/code&gt;, &lt;code&gt;budget&lt;/code&gt;, &lt;code&gt;formula&lt;/code&gt;). We don't build a "savings" endpoint anymore. The model asks for public cost as &lt;code&gt;a&lt;/code&gt;, amortized cost as &lt;code&gt;b&lt;/code&gt;, and writes &lt;code&gt;a - b&lt;/code&gt;. Cost per DAU is &lt;code&gt;cost / daily_active_use&lt;/code&gt;. A Kubernetes waste ratio is idle-namespace cost over cluster cost, &lt;code&gt;a / b&lt;/code&gt;. None of those are features we built just formulas the model composed out of the same handful of primitives.&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;"queries"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cost"&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;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"metricId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list_cost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cost"&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;"b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"metricId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"effective_cost"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"USD"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"formula"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"formula"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a-b"&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;"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-03-01"&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-03-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aggBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MONTH"&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;That tool became Advanced Explorer. It started life as an MCP tool and is now a feature in the app itself: the interface we designed for the model turned out to be the better design for people too.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/feK5VErY4UQ"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;h3&gt;
  
  
  Fix #2: Large Language Model, not Large JSON Model
&lt;/h3&gt;

&lt;p&gt;Filters were the other place the wrapper fell apart. Internally we build a &lt;code&gt;where&lt;/code&gt; clause with react-querybuilder, which spits out a nested tree of rule objects. To scope a query to AWS spend that is either EC2 or production, that looks like this:&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;"combinator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"and"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"not"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&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;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cos_provider"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"operator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&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;"AWS"&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;"combinator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"or"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"not"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rules"&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;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cos_service_name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"operator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&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;"AmazonEC2"&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;"field"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cos_environment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"operator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"in"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&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;"prod"&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="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;You'd think this is the format a machine would love. It's explicit, it's typed, nothing is left to interpretation. In practice it's the opposite. To write this, the model has to be extra careful about the structure while also thinking about what it's filtering on: which &lt;code&gt;rules&lt;/code&gt; array it's in, whether this level is &lt;code&gt;and&lt;/code&gt; or &lt;code&gt;or&lt;/code&gt;, whether the braces line up.&lt;/p&gt;

&lt;p&gt;The fix was simple: give it code. We dropped the JSON tree and now pass filters as a &lt;code&gt;filterCel&lt;/code&gt; string (CEL, the Common Expression Language). Same filter, one line — and react-querybuilder supports it natively.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;cos_provider&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AWS&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cos_service_name&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AmazonEC2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;cos_environment&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Across repeated runs of the same query workflows in both formats, this cut the error rate by roughly 25%, and what's left isn't the model mangling structure anymore — it's mostly guessing a column name that doesn't exist, which is a much easier thing to fix (we get to that in the errors section).&lt;/p&gt;

&lt;p&gt;Once we saw it work we started applying it everywhere we'd been handing the model a config blob. Alert conditions were structured JSON too; now they're &lt;code&gt;a &amp;gt; 1000&lt;/code&gt; or &lt;code&gt;rollingSum(a, 7, DAY) &amp;gt; 50000&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Fix #3: Give the model context on the first call
&lt;/h3&gt;

&lt;p&gt;An MCP session starts cold. The model doesn't know who's connected or what they did yesterday, so left alone it asks: which project, which team, which environment — every session from scratch. There is nothing more frustrating than an AI asking "Which GCP project?" every single day, and FinOps is the worst place for it, since the work repeats: same people, same dashboards.&lt;/p&gt;

&lt;p&gt;So we built &lt;code&gt;get_context&lt;/code&gt; and made it the first call of every session — we pin that in the MCP server instructions. It returns everything the user needs: dimensions often queried, the group-bys people reach for, recently touched dashboards, what they did last session (summarized), and their team ownership.&lt;/p&gt;

&lt;p&gt;This reduced hallucinations from misunderstood dimensions, put the model back into its last-action context, and stopped it asking the same questions every day.&lt;/p&gt;




&lt;h3&gt;
  
  
  Fix #4: Rewrite your errors
&lt;/h3&gt;

&lt;p&gt;When a query failed, we returned exactly what our API had always returned. A frontend knows what to do with that thanks to our well-defined contract — but a model can't tell whether the call was one typo away from working or fundamentally malformed, so it either gives up or retries the same mistake in a loop.&lt;/p&gt;

&lt;p&gt;So we rewrote errors to be understandable "by a child," with options on how to fix them: what broke, whether it's worth retrying, and how to fix it. The same failure now reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: 'AmazonEC2' is not a recognized service name in this dataset. Available services are: 'Amazon EC2', 'Amazon S3'. Did you mean 'Amazon EC2'?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model reads that and corrects itself on the next call. It was cheap to build, too: our runtime already runs on BigQuery, so the valid values and the "did you mean" suggestion were just a passthrough from BQ errors.&lt;/p&gt;

&lt;p&gt;State was the other half of the same problem. Models are bad at tracking state across a session. &lt;code&gt;update_dashboard&lt;/code&gt; used to want the entire widget list on each call, and the model would silently drop widgets it had lost track of. We looked at how other dashboarding solutions handle this: we saw &lt;a href="https://github.com/grafana/mcp-grafana/issues/101" rel="noopener noreferrer"&gt;Grafana MCP had similar issues&lt;/a&gt; and followed the same pattern: a diff of operations:&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;"operations"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"add"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"widget"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remove"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;786&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"replace"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;345&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"widget"&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;It's still not perfect, but it stopped removing arbitrary widgets (or moving them 😅).&lt;/p&gt;




&lt;h3&gt;
  
  
  How we found all of this: we asked the model to debug our MCP
&lt;/h3&gt;

&lt;p&gt;The primary user of an LLM-facing product is the LLM. If you want to know where your tool interface fails, just ask it.&lt;/p&gt;

&lt;p&gt;Every time we add a workflow, we run it at least ten times on our demo datasets. After each execution, we ask the model to generate developer feedback detailing every misunderstanding, bad tool call, or bug it encountered. For example, while building the Virtual Dimensions MCP, the model pointed out a direct contradiction in our instructions: one tool optimized for rule size, while another strictly enforced rule execution order. The conflicting constraints were paralyzing the agent.&lt;/p&gt;

&lt;p&gt;They are language models: ask them to explain their confusion. We eventually baked this feedback instruction directly into our MCP system prompt. It paid off massively when a customer forwarded us an unvarnished, Claude-generated feedback report after two months of usage — exposing edge cases we never would have caught manually.&lt;/p&gt;




&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;We tried building an MCP by mimicking our existing API. You can't: internal APIs carry implementation details, endpoints that only make sense to developers, parameter names that aren't consistent between endpoints. An LLM has none of that context and no patience either — which makes it a good reviewer of your product.&lt;/p&gt;

&lt;p&gt;Rebuilding for it was one of the best things we did: it forced us to rethink every user flow from scratch, and a lot of what came out made the app better too (Alerts, Advanced Explorer).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Costory FinOps MCP server is available: it works in Claude, ChatGPT, Cursor, or any MCP-compatible client, and setup takes a couple of minutes. Read the &lt;a href="https://docs.costory.io/features/mcp" rel="noopener noreferrer"&gt;MCP docs&lt;/a&gt;, browse the &lt;a href="https://github.com/costory-io/costory-plugin" rel="noopener noreferrer"&gt;plugin on GitHub&lt;/a&gt;, or get started at &lt;a href="https://costory.io" rel="noopener noreferrer"&gt;costory.io&lt;/a&gt; — 15-day free trial, $250/month under 10M annual spend.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>finops</category>
      <category>mcp</category>
      <category>devex</category>
    </item>
  </channel>
</rss>
