MCP Apps: Your Tool Doesn't Serve the Widget's HTML

An MCP app is a standard MCP server that also serves interactive UI inside the chat. This video explains the two-part registration pattern, when to build a widget at all, five design rules, and the two causes of a blank widget.

2:56 video3 min readWatch on YouTube

Many people building their first MCP app assume the tool sends back the widget's HTML. It does not: a separate resource serves it. This video covers that two-part pattern, how to decide whether a widget is needed at all, five rules that save rewrites, and the two mistakes that produce a blank widget without any error.

What an MCP app is

An MCP app is a standard MCP server that also serves interactive UI. The interface is rendered inline in the chat surface as an iframe. Because it is still an ordinary MCP server, tools keep working the way they always did, with the addition of a resource that carries the interface.

The two-part registration

The core pattern has two parts. In part one, the tool declares a UI resource through the _meta.ui.resourceUri field, and its handler returns plain data, not the HTML. In part two, a resource is registered separately and serves the widget's HTML using the exact MIME type constant for MCP app HTML.

At runtime the host sees the resource URI, fetches the resource and renders it in an iframe. It then passes the tool's return value into the iframe through an on-tool-result event. Inside the iframe an App class bridges the two sides. Its methods are sendMessage, updateModelContext, callServerTool and openLink, and they are set up before the app connects. One step is mandatory: the iframe's content security policy blocks CDN script fetches, so the widget script bundle must be inlined directly into the HTML at startup.

Route first, build second

Before building a widget, the video says to route the need correctly. Elicitation is native to the spec, needs zero UI code and works in any compliant host. It suits yes or no confirmation, short enums and flat forms. A widget is justified only when elicitation cannot meet the need, such as:

  • large or searchable lists that need scrolling,
  • visual previews before choosing,
  • charts and maps,
  • live-updating progress.

If none of those apply, skip the widget. Text is faster to build and faster for the user.

Five rules that save rewrites

  1. One widget per tool.
  2. The tool's description must mention the widget, since that is what makes Claude reach for it.
  3. Graceful degradation is automatic: hosts without the app surface simply render the tool's text content.
  4. Do not block on widget results for display-only tools, and return a text summary alongside so Claude can keep reasoning.
  5. Follow the host's theme once connected, rather than hardcoding one.

Two causes, one blank rectangle

The video closes on the most frustrating failure. If you serve the widget with the wrong MIME type, the host silently refuses to render it. If you leave the ext-apps bundle un-inlined, the content security policy blocks the CDN fetch and the widget is blank for that reason instead. It is the same symptom with two different causes, and the browser console does not say which one occurred. Get the type or the bundle wrong and the widget fails blank, not loud.

Key takeaways

  • The tool returns data; a separate resource serves the widget's HTML.
  • The tool points to its resource through _meta.ui.resourceUri.
  • Use the exact MIME type constant for the resource, not a hand-typed string.
  • Inline the ext-apps bundle at startup because the iframe blocks CDN scripts.
  • Use elicitation for confirmations, short enums and flat forms, and build a widget only when it cannot do the job.
  • Always return text content too, so hosts without the app surface still show something useful.
  • A wrong MIME type and a missing inlined bundle both produce a silent blank widget.

Try it yourself

Paste this into Claude: build an MCP app with a file-picker widget that lets someone choose a file from a list a directory-scan tool returns. Then check four things. Does it register a tool with _meta.ui.resourceUri plus a separate resource? Does the resource use the exact MIME type constant? Is the ext-apps bundle inlined at startup instead of pulled from a CDN? Does the tool still return real text content? Run it on your own MCP server, not the video's example.

Chapters

  1. 0:00Does my tool serve the HTML?
  2. 0:10Tool returns data, resource serves HTML
  3. 0:56Route first: elicitation, widget, or text
  4. 1:44Two causes, one blank rectangle
  5. 2:05The carry-out
  6. 2:16Your turn
Full transcript(auto-generated, with timestamps)

Does my tool serve the HTML?

[0:00]Someone assumed their tool sends back the widget's HTML directly. It doesn't, a separate resource serves that. So, how do you make a resource serve the widget's HTML? The two-part registration

Tool returns data, resource serves HTML

[0:11]Is the core pattern. Part one, the tool, it declares a UI resource via meta.ui.resourceuri and its handler returns plain data, not the HTML. Part two, the resource registered separately, it serves the widget's HTML with the exact mime type text/html;profile=mcapp. The host sees that resource URI, fetches the resource, and renders it in an iframe. Piping the tool's return value in through an on tool result event. Inside the iframe, an app class bridges the two sides. Methods like send message, update model context, call server tool, and open link set up before it connects. One more step is mandatory. The iframe's content security policy blocks CDN script fetches, so the widget script bundle has to be inlined directly into the HTML at startup. Before

Route first: elicitation, widget, or text

[0:56]Building a widget, route correctly. Elicitation is spec native zero UI code works in any compliant host. Use it for yes/no confirmation, short and unpicks, and flat forms. Build a widget only when the need can't be met by elicitation. Large or searchable lists that need scrolling, visual previews before choosing, charts and maps, and live updating progress. If none of those apply, skip the widget entirely. Text is faster to build and faster for the user. Five design rules that save rewrites. One widget per tool. The tool's description must mention the widget since that's what makes Cloud Reach for it. Graceful degradation is automatic. Hosts that don't support the app surface just render the tool's text content. Don't block on widget results for display-only tools. Return a text summary alongside so Cloud can keep reasoning. And follow the host's theme once connected. Instead of hardcoding one. Two ways to end up with the exact

Two causes, one blank rectangle

[1:45]Same blank rectangle and no error message. Serve the widget with the wrong mime type and the host silently refuses to render it. Or leave the X-Taps bundle un-inlined, the iframe's content security policy blocks the CDN fetch, and the widget stays blank there, too. Same failure, two different causes, and the browser console won't tell you which one it was. An MCP app is just a server

The carry-out

[2:06]With an extra resource. The tool still returns data, and it's the resource that serves the widget's HTML. Get the type or the bundle wrong, and it fails blank, not loud. Your turn. Paste this into

Your turn

[2:17]Claude. Build an MCP app with a file picker widget that lets someone choose a file from a list a directory scan tool returns. Then check four things. Does it use two-part registration, a tool with meta.ui.resourceUri plus a separate resource? Does the resource use the exact mime type constant, not a hand-typed string? Is the X-Tags bundle inlined at startup instead of pulled from a CDN? And does the tool still return real text content alongside the widget, so a host without the app's surface see something useful? Get the type or the bundle wrong, and the widget renders blank. Same failure, no error either way. Return data, serve HTML, lay 'em in for bear.

More from Extending Claude — Skills, Plugins & Connectors

Humanitarians AI Lyrical Literacy Project