How to ship an MCP server whose tools render interactive HTML widgets ("MCP
Apps", SEP-1865,
extension id io.modelcontextprotocol/ui) in hosts like Cursor, Claude, and other
MCP clients that support the extension — using the helpers in this package.
Everything in this document was extracted from a production integration that was debugged host-by-host. The hard-won lesson: five independent pieces must all be exactly right or the host silently falls back to plain text (or worse, renders a blank/zero-height frame). This playbook lists all five, the helpers that produce them, a verification runbook, and a troubleshooting table mapping each observed failure mode to its cause.
Three parties:
┌────────────┐ MCP over HTTP ┌────────────┐
│ MCP host │ ◄────────────────► │ your server│ (this package)
│ (Cursor, │ └────────────┘
│ Claude…) │ postMessage JSON-RPC
│ ┌───────┐ │
│ │ widget│ │ the widget is YOUR html, rendered by the host in a
│ │ iframe│ │ sandboxed iframe, talking to the host via postMessage
│ └───────┘ │
└────────────┘
- The server declares the MCP Apps extension capability, registers a
ui://HTML resource (the widget), and tags tools with_metapointing at it. - When a tagged tool is called, the host loads the widget HTML into a
sandboxed iframe and delivers the tool result's
structuredContentto it. - The widget performs a
ui/initializehandshake with the host overpostMessage, then renders whateverstructuredContentit receives. It may also call server tools back through the host (tools/call).
| # | Requirement | Helper | What happens if you miss it |
|---|---|---|---|
| 1 | Server speaks MCP protocol 2025-11-25 (or newer) and negotiates the client's requested version |
MCPServerConfig(protocol_version=…, supported_protocol_versions=[…]) |
Host treats the server as pre-Apps; widgets never render |
| 2 | initialize response advertises capabilities.extensions["io.modelcontextprotocol/ui"] |
ui_extension_capability() |
Host ignores all ui:// resources — tool results show as plain text |
| 3 | Widget registered as a ui:// resource with mime type exactly text/html;profile=mcp-app |
register_ui_resource! |
Bare text/html is not treated as a renderable app |
| 4 | Tool _meta carries the widget uri under both ui.resourceUri (nested) and ui/resourceUri (flat) |
ui_tool_meta(resource) |
Some host builds read one shape, some the other; missing one → text fallback in that host |
| 5 | Tool results embed the widget resource in content and put the widget's data in structuredContent |
ui_tool_content(text, resource) + MCPToolResult(structured_content=…) |
Cursor renders the embedded copy, not the registered resource — without it: no widget. Without structuredContent: an empty widget |
| 6 | Widget performs the ui/initialize handshake, sends ui/notifications/initialized, and reports its size |
mcp_app_html (bundles MCP_APP_BOOTSTRAP_JS) |
No handshake → host never activates the frame. No size reports → 0-height (blank-looking) widget in Cursor |
Item 6 lives inside your HTML, which is why "the server looks right but nothing
renders" is so common: the bug can be on either side of the iframe boundary.
mcp_app_html makes the widget side turnkey.
using ModelContextProtocol
const MCP = ModelContextProtocol
server = MCPServer(
name="Acme Reports",
version="1.0.0",
# Requirement 1: modern protocol + negotiation for older clients
supported_protocol_versions=["2025-06-18", "2025-03-26"],
# Cursor omits the MCP-Protocol-Version header on some requests; don't 400 on it
missing_protocol_header=:warn,
# Requirement 2: advertise the Apps extension
capabilities=ui_extension_capability(),
)
# Requirement 3 + 6: register the widget (mcp_app_html handles the handshake JS)
card = register_ui_resource!(
server;
uri="ui://acme/report-card",
name="acme-report-card",
title="Acme Report Card",
description="Inline report card for report-lookup results.",
html=mcp_app_html(
app_name="acme-report-card",
body="""<main id="app" class="card">Loading…</main>""",
css=""".card{font-family:system-ui;padding:16px;border-radius:8px}""",
script="""
mcpApp.onRender(function (data) {
document.getElementById("app").textContent =
data && data.title ? data.title : "No data";
});
""",
),
)
# Requirement 4 + 5: tag the tool and embed the widget in its results
register_tool!(
server;
name="report-lookup",
description="Look up a report by id.",
input_schema=Dict("type" => "object",
"properties" => Dict("id" => Dict("type" => "string"))),
meta=ui_tool_meta(card),
handler=(ctx, args) -> begin
report = Dict("title" => "Q3 revenue", "id" => get(args, "id", ""))
MCPToolResult(
content=ui_tool_content("Found report: Q3 revenue", card),
structured_content=report, # ← this is what the widget receives
)
end,
)
http = serve_mcp_http(server; host="127.0.0.1", port=8765)That's the entire surface area. Every requirement from §2 is satisfied by construction.
-
The default
protocol_versionin this package is2025-11-25, the first revision with extension capabilities. Don't lower it for an Apps server. -
Clients send their
protocolVersionin theinitializerequest. Per spec the server must echo it when supported, otherwise answer with its own. This package handles that viasupported_protocol_versions— list every older revision you're willing to serve:MCPServer(; protocol_version="2025-11-25", supported_protocol_versions=["2025-06-18", "2025-03-26"], …)
-
After
initialize, spec-compliant clients send anMCP-Protocol-Versionheader on every HTTP request. Cursor does not always do this. With the defaultmissing_protocol_header=:errorthose requests get a 400 and the integration looks "randomly broken". Use:warn(log it) or:ignore.
The initialize response must contain:
{
"capabilities": {
"extensions": {
"io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] }
}
}
}ui_extension_capability() builds exactly this; add_ui_extension_capability!(caps)
merges it into capabilities you already have. Hosts check this before looking
at any resource or tool metadata — it is the master switch.
Clients advertise the same extension shape in their initialize request. In a
tool handler, call ModelContextProtocol.supports_mcp_apps_ui(context) when
adapting presentation-aware output. It returns true only when both the server
and client advertise the exact requested MIME type. This does not guarantee that
the host will render the app, so always return a meaningful text fallback.
Missing or malformed client capabilities return false.
card = register_ui_resource!(server; uri="ui://acme/report-card", html=…, …)- The uri must use the
ui://scheme (enforced by the helper). - The mime type must be
text/html;profile=mcp-app(MCP_APP_HTML_MIME_TYPE). The;profile=mcp-appparameter is how hosts distinguish an app widget from an ordinary HTML resource. - Keep uris stable across releases: hosts key caching and the tool→widget association on them.
- Resource
_metadefaults to{"ui": {"prefersBorder": true, "displayMode": "inline"}}(ui_resource_meta());inlineis the widely supported display mode. - One widget can serve many tools, or each tool can have its own widget — a tool points at exactly one widget uri.
register_tool!(server; …, meta=ui_tool_meta(card))produces
{
"ui": { "resourceUri": "ui://acme/report-card", "visibility": ["model", "app"] },
"ui/resourceUri": "ui://acme/report-card"
}The uri is deliberately dual-written: host builds in the wild disagree on whether they read the nested or the flat key. Writing both is harmless and makes the tool render everywhere.
visibility controls exposure: "model" lets the LLM call the tool, "app"
lets the widget call it via tools/call. For actions that only make sense from
inside a widget (e.g. a "validate this selection" button), register an app-only
tool with ui_tool_meta(card; visibility=["app"]) so the model never calls it.
MCPToolResult(
content=ui_tool_content("Readable text summary", card), # text + embedded widget
structured_content=data, # the widget's render data
)Two independent things happen here:
- Embedding.
ui_tool_contentappends{"type": "resource", "resource": {"uri", "mimeType", "text"}}— a full inline copy of the widget HTML — after the text content. Cursor renders this embedded copy and does not reliably fetch the registered resource viaresources/read. Register the resource and embed it; other hosts use the registration, Cursor uses the embed. - Render data. The host feeds the result's
structuredContentto the widget (via theui/initializeresponse and/or aui/notifications/tool-resultmessage).contenttext is for humans/the model;structuredContentis for the widget. A missingstructuredContentyields a rendered-but-empty widget.
Skip the embed for outcomes that shouldn't render a widget (e.g. "not found"):
ui_tool_content(text, card; embed=false).
The wire sequence the widget must perform (all messages are JSON-RPC 2.0 over
window.parent.postMessage(msg, "*")):
widget → host {"jsonrpc":"2.0","id":1,"method":"ui/initialize",
"params":{"protocolVersion":"2026-01-26",
"appInfo":{"name":"acme-report-card","version":"1.0.0"},
"appCapabilities":{}}}
host → widget {"id":1,"result":{ …may include render data… }}
widget → host {"jsonrpc":"2.0","method":"ui/notifications/initialized","params":{}}
widget → host {"jsonrpc":"2.0","method":"ui/notifications/size-changed",
"params":{"width":…,"height":…}} (initially + on every resize)
host → widget {"jsonrpc":"2.0","method":"ui/notifications/tool-result",
"params":{ …structuredContent… }} (per tool call)
Empirically discovered constraints, all handled by MCP_APP_BOOTSTRAP_JS
(injected by mcp_app_html):
ui/initializemust carryprotocolVersion+appInfo+appCapabilities. An earlier shape ({"name": …, "version": …}) left Cursor's frame inactive.ui/notifications/initializedmust follow the initialize response — fire-and-forgetting it earlier is not equivalent.- Without
ui/notifications/size-changed, Cursor sizes the iframe to 0 height: the widget "renders" but looks blank. The bootstrap wires aResizeObserver(plus load/fonts-ready hooks) so sizing is automatic and continuous. - Only accept messages where
event.source === window.parent, and never throw from the message handler. - Render data can arrive in the
ui/initializeresult, inui/notifications/tool-resultparams.structuredContent, or nested underparams.result.structuredContentdepending on host/version. The bootstrap checks all of them and replays the latest data toonRendercallbacks that register late — eliminating the startup race where data arrives before the widget's own script has subscribed. - Hosts may attach their message listener after the iframe's scripts have
already run, silently dropping the first
ui/initialize. The bootstrap retries the request (at ~0.8s and ~3.2s) before giving up, so a dropped first message doesn't leave the widget hung on its loading state.
Widget authors only see this API:
mcpApp.onRender(function (data, raw) { /* re-render; called on every update */ });
mcpApp.callTool("select-offer", {offerId: "…"}).then(r => render(r.structuredContent));
mcpApp.reportSize(); // manual nudge; automatic reporting is already on
mcpApp.ready.then(ok => …); // resolves false (not rejects) on handshake timeoutThe iframe is sandboxed and hosts apply restrictive CSPs. Author widgets as if no network access exists:
- Inline all CSS and JS (
mcp_app_htmldoes this by construction). - No CDN scripts, no external stylesheets, no
fetch/XHR to your own API — go throughmcpApp.callToolinstead, which the host proxies and authenticates. - External images may load in some hosts; don't depend on them. Prefer data
URIs or inline SVG. Web fonts are progressive enhancement only (pass font
<link>tags viamcp_app_html'sheadkeyword, or lead yourcsswith@import) — always specify a system-font fallback stack, and expect the fallback to be what renders in stricter hosts. - Support both themes cheaply:
:root{color-scheme:light dark}(set bymcp_app_html) +@media (prefers-color-scheme: dark)overrides. - Escape every interpolated value (the sample widgets use a tiny
esc()); the data you render came from tool output and can contain anything. mcp_app_htmlrejects</script>/</style>sequences insidescript/css— write<\/script>inside JS strings if you genuinely need the text.
- Streamable HTTP: this package implements the 2025-11 transport —
POST(JSON-RPC),GET(SSE stream),DELETE(session teardown) on one path. Theinitializeresponse sets anMCP-Session-Idheader; clients echo it on every subsequent request. Register all three verbs (plusOPTIONSif you do CORS) when mounting into an existing HTTP router. - Multiple replicas: the default session store is in-memory. Behind a load
balancer, provide a shared
session_store(seeMCPSessionStore; a Redis implementation is straightforward) or hosts will getsession not founderrors mid-conversation. - Auth: respond
401with aWWW-Authenticate: Bearer resource_metadata="…"challenge for missing/invalid tokens; hosts drive the OAuth flow from that. Nothing about MCP Apps changes auth — but remembertools/callissued by the widget arrives as a normal MCP request under the host's session/token.
BASE=http://127.0.0.1:8765/v1/mcp
# 1) initialize: capture the session id header, check version + capability
curl -si "$BASE" -X POST -H 'Content-Type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"curl","version":"0"}}}' | tee /tmp/init.txt
SESSION=$(grep -i '^mcp-session-id:' /tmp/init.txt | tr -d '\r' | awk '{print $2}')
# CHECK: body .result.protocolVersion == "2025-06-18" (echoed, not overridden)
# CHECK: .result.capabilities.extensions["io.modelcontextprotocol/ui"].mimeTypes
# == ["text/html;profile=mcp-app"]
H=(-H "Content-Type: application/json" -H "MCP-Session-Id: $SESSION" \
-H "MCP-Protocol-Version: 2025-06-18")
# 2) tools/list — CHECK each widget tool has BOTH _meta.ui.resourceUri and _meta["ui/resourceUri"]
curl -s "$BASE" -X POST "${H[@]}" -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| jq '.result.tools[] | {name, meta: ._meta}'
# 3) resources/read — CHECK mimeType and that the html contains "ui/initialize"
curl -s "$BASE" -X POST "${H[@]}" -d '{
"jsonrpc":"2.0","id":3,"method":"resources/read",
"params":{"uri":"ui://acme/report-card"}}' \
| jq '.result.contents[0] | {uri, mimeType, hasHandshake: (.text | contains("ui/initialize"))}'
# 4) tools/call — CHECK content[1] is the embedded resource and structuredContent is present
curl -s "$BASE" -X POST "${H[@]}" -d '{
"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"report-lookup","arguments":{"id":"r-1"}}}' \
| jq '{types: [.result.content[].type],
embeddedUri: .result.content[1].resource.uri,
mime: .result.content[1].resource.mimeType,
hasStructured: (.result.structuredContent != null)}'If all four checks pass, the server side is correct; any remaining problem is in the widget HTML or the host.
Open the widget in a plain browser with a fake host — this catches handshake and rendering bugs in seconds instead of a deploy cycle:
<!-- harness.html: serve next to widget.html and open in a browser -->
<iframe id="w" src="widget.html" style="width:420px;border:1px solid #ccc"></iframe>
<script>
const SAMPLE = { title: "Q3 revenue" }; // structuredContent fixture
const frame = document.getElementById("w");
window.addEventListener("message", (ev) => {
const m = ev.data || {};
if (m.method === "ui/initialize")
frame.contentWindow.postMessage({ jsonrpc: "2.0", id: m.id, result: {} }, "*");
else if (m.method === "ui/notifications/size-changed")
frame.style.height = m.params.height + "px";
else if (m.method === "ui/notifications/initialized")
frame.contentWindow.postMessage({ jsonrpc: "2.0",
method: "ui/notifications/tool-result",
params: { structuredContent: SAMPLE } }, "*");
});
</script>A correct widget renders SAMPLE and the iframe grows to fit. This harness
mimics the host order of operations (init reply → initialized → tool-result).
-
Add the server to
~/.cursor/mcp.json(or project.cursor/mcp.json):{ "mcpServers": { "acme": { "url": "http://127.0.0.1:8765/v1/mcp" } } } -
Cursor Settings → MCP: the server should show connected with your tools listed. Cursor caches
initializeandtools/listaggressively — after changing capabilities, tool_meta, or widget HTML, toggle the server off/on (or reload the window). Testing against a stale handshake is the #1 source of phantom bugs. -
Ask the agent to call a widget-backed tool. The widget renders inline in chat, in place of the text content.
-
Debugging: Help → Toggle Developer Tools; widget
console.*output and the postMessage traffic are visible from the iframe context (select it in the console's context dropdown).
Then repeat on your deployed environments (dev → stage → prod). The wire checks
in §6.1 run unchanged against deployed URLs (add your Authorization header).
| Symptom | Cause | Fix |
|---|---|---|
| Tool result shows as plain text, no widget at all | Extension capability missing from initialize (req 2), or host cached an old handshake |
capabilities=ui_extension_capability(); toggle the server in the host to force a fresh initialize |
| Still text-only, capability confirmed present | Tool _meta missing/wrong (req 4) — or only one of the two uri keys present |
meta=ui_tool_meta(resource) (writes both keys); verify via §6.1 step 2 |
| Widget renders in one host but not Cursor | Resource registered but not embedded in the tool result (req 5) | Build content with ui_tool_content(text, resource) |
| Widget frame appears but is blank / 1px tall | No ui/notifications/size-changed from the widget |
Use mcp_app_html (auto size reporting), or send size-changed after every render |
| Widget frame appears, never receives data | Handshake params wrong — ui/initialize without protocolVersion/appInfo/appCapabilities; or initialized notification never sent |
Use the bootstrap; if hand-rolling, match §4.6 exactly |
| Widget shows its empty/placeholder state | Tool result has no structuredContent, or widget only listens on one delivery channel |
Set structured_content on MCPToolResult; bootstrap reads init-result and tool-result channels |
| Renders once, never updates on subsequent calls | Widget rendered only from the init result | Subscribe via mcpApp.onRender (fires on every tool-result) |
| Random 400s from the server in Cursor | missing_protocol_header=:error while Cursor omits MCP-Protocol-Version |
Set :warn or :ignore |
| Client and server disagree on protocol version | Server ignored the requested version | Set supported_protocol_versions; verify §6.1 step 1 echoes the requested version |
Session not found after deploy/restart or behind LB |
In-memory session store, multiple replicas or restart | Shared/persistent session_store |
Widget's tools/call fails while the model's calls work |
Action tool registered app-only… or not; visibility mismatch | Check visibility in ui_tool_meta; app-called tools need "app" in the list |
| Fonts/images missing in the rendered widget | Host CSP blocks external requests | Treat external assets as progressive enhancement; inline or use system fallbacks (§4.7) |
| Widget renders garbage/XSS-y content | Unescaped interpolation of tool data | Escape everything (esc() pattern) |
Adding MCP Apps to a fresh server, in order — with §6 verification after each group:
- Depend on this package; create the
MCPServerwithcapabilities=ui_extension_capability(),missing_protocol_header=:warn, and (if you must serve older clients)supported_protocol_versions. - Mount the transport:
serve_mcp_http, or registerPOST/GET/DELETEhandlers (handle_jsonrpc_request/handle_stream_request/handle_session_delete) on your existing router. - Author the widget:
mcp_app_html(app_name=…, body=…, css=…, script=…)with amcpApp.onRenderentry point. Verify standalone with the §6.2 harness. - Register it:
card = register_ui_resource!(server; uri="ui://<app>/<widget>", html=…). - Tag tools:
meta=ui_tool_meta(card)(usevisibility=["app"]for widget-only action tools). - Return widget-backed results:
MCPToolResult(content=ui_tool_content(summary, card), structured_content=data). - Run the §6.1 curl checks. All four must pass.
- Connect a real host (§6.3), remembering to force a handshake refresh.
- If deploying multi-replica: shared session store. If authing: 401 challenge.
- Keep the widget uri stable forever after.