DEV Community

Cover image for Lessons from building 20 MCP Apps in 2 days
Teal Larson for Arcade.dev

Posted on • Originally published at teallarson.dev

Lessons from building 20 MCP Apps in 2 days

A few weeks back, my team sat down for two days and built around twenty MCP Apps. I came out with a much better idea of what they are, what they aren't (yet), and where the duct tape is currently holding things together. Here's the brain dump.

If you haven't run into them yet: MCP Apps is the first official extension to the MCP spec. It lets a tool return a UI resource alongside its result. The host renders that UI inline as a sandboxed iframe. Tables, charts, forms, branded layouts, little interactive bits that can call back into other tools. Real, actual UI in the middle of a chat-based experience. Very cool.

They matter because some information is just better visually. A neatly grouped list of pull requests is much easier to scan than a wall of bulleted text. A chart beats a CSV. And as more of our day-to-day work shifts into chat, "bring your brand and your product surface into the conversation" stops being a nice-to-have.

OK. Lessons.

The call is coming from inside the house

MCP Apps live inside the server. This was the first thing that surprised me. An MCP App isn't a hosted URL you point your tool at or some third-party iframe you embed. It is fetched via MCP, not HTTP, so the UI code ships with the MCP server and is served via the ui:// resource scheme.

There are a number of different ways you could go about organizing this. For example, you could co-locate the files with the tools themselves:

my-mcp-server/
  tools/
    list_projects    
      list_projects.py
      project-summary.html
    list_project_patterns
      list_project_patterns.py
      pattern-card.html    
Enter fullscreen mode Exit fullscreen mode

Or co-locate them in a single place:

my-mcp-server/
  tools/
   list_projects.py
   list_project_patterns.py
  ui/
    pattern-card.html
    project-summary.html
    ...
Enter fullscreen mode Exit fullscreen mode

We were using React because we wanted to leverage the existing internal design system components. So we landed on:

my-mcp-server/
   tools/
   ui/
     pattern-card.tsx
     project-summary.tsx
     package.json
     vite.config.mjs
Enter fullscreen mode Exit fullscreen mode

A single Vite project at the root of /ui, configured to output an HTML file per TSX file at build time.

MCP Apps are enrichment-only

If a host supports MCP Apps, your user sees the rich UI. If it doesn't (Claude Code, most terminal-based clients, anything that isn't on the new extension), the _meta.ui property is silently ignored and your user just gets the text response.

So the text response is still the contract. Your MCP App is enrichment on top. If you stuff the actual answer into the UI and leave your text response empty, congratulations: you've shipped a tool that works in some clients and silently breaks in others. Always design as if half your users will never see the app.
Keep these things STUPID simple
I am going to be the first one to tell you: keep your MCP App components dumb. Pure. Boring. All data passed in as props from the tool result. No fetches from inside the app, no state machines, no calls back to your API.

The tool runs, computes its answer, hands the data to the UI as props, and the UI is just a deterministic render of that. This made our apps fast to build, easy to reason about, and very simple to test in isolation. It also kept us honest about what exactly we were sending into a sandbox we don't fully control (more on that in a sec).

Host quirks are real

There's a spec, but hosts implement it with their own opinions. Container width, padding, default typography, dark/light handling, the whole vibe varies. ChatGPT renders wide in a browser. Claude renders narrow in a chat panel. Mobile is mobile. VS Code's side panel is its own little adventure.

There's no standardized testing harness yet, so our iteration loop was: build, install in client A, eyeball it, install in client B, eyeball it, adjust, repeat. Compared to ordinary frontend dev, where you'd just spin up a Storybook or run Playwright across browsers in CI, it felt slow. Like, painfully slow.

Two things helped:

Designing layouts that gracefully reflow at narrow widths from the start, rather than fixing them after the fact.
Using a file watcher to rebuild on save, so the inner loop wasn't quite so brutal.

The tooling will catch up. For now: plan for visual QA across multiple hosts, and accept the dev loop is going to feel slow.

The host can see everything

MCP Apps run in a sandboxed iframe, but the content of that iframe is visible to the host. This has a real implication and I don't want to bury it: don't use MCP Apps to collect secrets. No API keys in form fields. No OAuth tokens. Nothing you wouldn't want logged.

If you need to collect secrets, use URL elicitation or a separate secure form outside the MCP App. You can pair that with an MCP App that polls the external endpoint for completion status. The secret itself just shouldn't live inside the rendered iframe.

TL;DR

If you're starting from zero:

Bundle your UI inside your server. Multi-page Vite, one HTML per surface, your existing design system imported directly.
Always make the text response stand on its own.
Pure components, props in, no client-side state.
Test on every host you care about, by hand, until tooling catches up.
Don't put secrets in the app.

The patterns that do still feel impossible (gathering tool inputs via UI before the call, for example) might not stay that way for long. MCP Tasks are in the experimental phase and looks like it could open that door

For now: MCP Apps are early, the spec is moving, the tooling is thin, and they're already worth shipping.

Top comments (0)