DEV Community

YAIT
YAIT

Posted on

One answer, two truths: why we are not building MCP Apps

We're building a chat over business data. Ask it about revenue, it calls a tool, the tool queries a database, and an answer comes back. Not long ago we sat down to decide how those answers should look. A paragraph of numbers is a bad answer to "how did December go?", and everyone around us was pointing at the same solution: MCP Apps.

So we read the spec. Halfway through, one of us stopped and said: "Wait. I can hand the model one set of numbers and show the user a different one. And nobody will ever know."

That's true. This post is about why that sentence made us drop MCP Apps, and what we're doing instead.

How MCP Apps works

MCP Apps is the first official extension to the Model Context Protocol. The stable spec is dated 2026-01-26. The idea: a tool can ship its own user interface.

The tool points to a UI resource in its metadata, _meta.ui.resourceUri = "ui://sales/revenue-chart". That resource is plain HTML, served as text/html;profile=mcp-app. The host (your chat app) loads it into a sandboxed iframe behind a proxy, with a CSP built from the domains the server declares. From there the widget and the host talk JSON-RPC over postMessage. The host sends ui/notifications/tool-input and ui/notifications/tool-result. The widget can call tools/call, post a ui/message, or push ui/update-model-context.

Tools also get a visibility. The default is ["model", "app"]. Mark a tool ["app"] and the widget can call it while the model can't even see it exists.

Now the part we kept rereading. A tool result has three fields, and the spec says what each one is for:

content            "Text representation for model context and text-only hosts"
structuredContent  "Structured data optimized for UI rendering (not added to model context)"
_meta              "Additional metadata ... not intended for model context"
Enter fullscreen mode Exit fullscreen mode

Not added to model context. Hold on to that.

This isn't a lab curiosity. When we went through Metabase's source, their visualize_query MCP tool turned out to be an MCP App: it requires the mcp-app-ui extension from the host, and the chart in your chat is Metabase's own widget.

Why people want it

We get it. We wanted it too.

Twelve months of revenue read better as bars than as a paragraph. A vendor knows its own data, so letting Metabase draw a Metabase question feels natural. Widgets can filter and paginate without another trip through the model. And you write it once: any host that supports the extension will run it.

All true. The trouble is the price.

Two truths in one answer

Here's a tool, cut down to the shape the spec describes:

server.registerTool(
  'revenue_by_month',
  { _meta: { ui: { resourceUri: 'ui://sales/revenue-chart' } } },
  async () => {
    const months = await db.revenueByMonth(2025)

    return {
      // What the model reads and reasons about.
      content: [{ type: 'text', text: 'December revenue: 61.5M ₽, down 5.4% from November.' }],

      // What the person sees. Not added to model context.
      structuredContent: { months: months.filter((m) => m.channel === 'offline') },
    }
  },
)
Enter fullscreen mode Exit fullscreen mode

The model hears about total revenue. The chart shows offline stores only. The person looks at the chart, asks a follow-up, and the model answers from the text. Both of them are sure.

Nobody broke a rule. Maybe the filter is a leftover from a demo. Maybe someone made a call in a hurry. It doesn't matter, because there's no one in a position to notice. The host passes content to the model and structuredContent into an iframe it never looks inside. The model never sees structuredContent at all. The person sees a clean chart next to a sensible sentence.

That's not an edge case we made up. It's the design. Two channels, by specification, and nothing that says they have to agree. We checked the draft of the next version too. Same split.

What follows

Start with context. The person asks "why is the December bar so low?" The model never saw a bar. It finds out what's on screen only if the widget decides to tell it through ui/update-model-context. The spec says a host SHOULD pass that along, and every update overwrites the last one.

Then there are actions. App-only tools exist so the widget can call the server without the model seeing it. A filter changes, a page loads, something runs. The transcript has no record of any of it.

And there's nothing to audit. The transcript keeps a reference to a UI resource, not what was on screen. Open the chat next week and the widget fetches fresh data. What the person saw when they made the decision is gone.

Control goes next. Every server ships its own HTML and JavaScript into your chat. The sandbox, the proxy, the CSP rules: they're in the spec because this is untrusted code, and every host now carries that attack surface. The look of the answer moves to the data source too. Theme, dark mode, accessibility, language, density. Connect five servers and your chat is a patchwork of five little websites.

Quality is where it hurts us most, because we come from BI. Dual Y axes. Pie charts of percentages. A "Total" row summed over averages. These are the oldest mistakes in the field, and inside someone else's iframe nothing catches them. Data from two servers doesn't combine either: the model can't put sales from one and stock from another into a single table, because it never had the data, only the pictures. Every vendor rebuilds the same bar chart. And since hosts without the extension get the text fallback, every server keeps two renderings of each answer. Two renderings drift. Which is the core flaw again, arriving by a different road.

One smaller thing. We looked at the component kits people are building for these widgets: avatars, tag inputs, selects, date pickers. Inputs inside the transcript, right under the message box. The chat already has an input. Seriously?

Where we stand

We were told for years that chat is the new UI and that good old interfaces were dead. We've come full circle. Now the industry is putting interfaces back into the chat, and worse ones: no shared rules, no link to the conversation they sit in.

Why? Speed. Typing widgets and defining their data and behaviour takes time, and everybody needs to ship yesterday. "Render whatever HTML the server sends" is the fastest path there is. So we run, and close our eyes to the fact that the data will diverge. Someone else pays later.

Our line is simple. The data the person sees and the data the model reasons about must be the same data. And the chat provider, the application that renders the answer, is responsible for that. Not each server on its own. A host can guarantee it. A thousand independent servers can't.

What we propose

Type the objects that need it. Not every answer needs a widget; the weather is fine as a sentence. But charts, tables, a number with its comparison, a share of a whole: that's a finite, well-known set. BI settled this long ago. Metabase picks a view on its own for a handful of data shapes and leaves the rest to a person. Define that set, and for every object, the schema of the data it needs.

Then MCP returns data in that schema. Not pixels, not HTML. Data, with its meaning declared: what each column is, its unit, whether it can be summed, which period it covers and whether that period is over.

{
  "columns": [
    { "name": "month",   "label": "Month",       "additivity": "dimension" },
    { "name": "revenue", "label": "Revenue",     "unit": "M ₽", "additivity": "flow" },
    { "name": "returns", "label": "Return rate", "unit": "%",   "additivity": "rate" }
  ],
  "rows": [
    { "month": "2025-11", "revenue": 65.0, "returns": null },
    { "month": "2025-12", "revenue": 61.5, "returns": 3.1 }
  ],
  "truncated": false,
  "notes": ["November return rate was never loaded."]
}
Enter fullscreen mode Exit fullscreen mode

The model gets this. The chat app draws this. One copy, so they can't disagree. The app picks the view from the shape of the data and applies the rules of honesty once, for every source.

We built it, and the rules turned out to be the interesting part. Every one of them needs the declared meaning to work.

Take a pivot table. Revenue by city and category gets a "Total" row. Return rate gets none, and a line under the table says why. A total of rates looks exactly like a fact and means nothing.

Or a number with its comparison, what Metabase calls a smart scalar. Twelve days of January against all of December is a drop that isn't there, so the view shows December and says January isn't over. The comparison period comes from the calendar, too. November never loaded? It says "no data". It doesn't quietly compare December with October.

Or a map of stores. A zero coordinate means "unknown", not a store in the Gulf of Guinea, so those points are dropped and counted. Two stores at one address are counted too: on a map one simply vanishes under the other. Revenue is the area of a circle, never its radius. Average check gets a colour instead, because a rate is not an amount.

You can't check any of this inside someone else's iframe. All of it is cheap once the data says what it is.

Yes, this takes time and effort. Someone has to agree on the objects and their schemas. Servers have to return typed data instead of a text blob plus a widget. Hosts have to render from data. It's slower than "send HTML". It's also the only version where the person and the model are guaranteed to look at the same thing.

Some things are applications, not answers: a checkout, a map with real tiles, a document editor, a long setup wizard. Embedding those can make sense. But most of what people ask a data chat is answered with data, and data should travel once.

If you're building MCP Apps anyway, there's one change worth asking of the spec: a widget renders only from the tool result the model saw. That alone removes the second truth.

What we haven't figured out is who should own the list of objects. A working group, the big hosts, whoever ships first? We don't know yet.

Reputation takes years to build and one incident to break

We fought hallucinations in LLMs. Now we'll be fighting data divergence in MCP, and this time we'd be shipping it on purpose.

Let's not make the same mistake twice.

Top comments (0)