6.0 KiB
ext-apps messaging — widget ↔ host ↔ server
The @modelcontextprotocol/ext-apps package provides the App class (browser side) and registerAppTool/registerAppResource helpers (server side). Messaging is bidirectional and persistent.
Widget → Host
app.sendMessage({ role, content })
Inject a visible message into the conversation. This is how user actions become conversation turns.
app.sendMessage({
role: "user",
content: [{ type: "text", text: "User selected order #1234" }],
});
The message appears in chat and Claude responds to it. Use role: "user" — the widget speaks on the user's behalf.
app.updateModelContext({ content })
Update Claude's context silently — no visible message. Use for state that informs but doesn't warrant a chat bubble.
app.updateModelContext({
content: [{ type: "text", text: "Currently viewing: orders from last 30 days" }],
});
app.callServerTool({ name, arguments })
Call a tool on your MCP server directly, bypassing Claude. Returns the tool result.
const result = await app.callServerTool({
name: "fetch_order_details",
arguments: { orderId: "1234" },
});
Use for data fetches that don't need Claude's reasoning — pagination, detail lookups, refreshes.
app.openLink({ url })
Open a URL in a new browser tab, host-mediated. Required for any outbound navigation — the iframe sandbox blocks window.open() and <a target="_blank">.
await app.openLink({ url: "https://example.com/cart" });
For anchors in rendered HTML, intercept the click:
card.querySelector("a").addEventListener("click", (e) => {
e.preventDefault();
app.openLink({ url: e.currentTarget.href });
});
app.downloadFile({ name, mimeType, content })
Host-mediated download (sandbox blocks direct <a download>). content is a base64 string.
Host → Widget
app.ontoolresult = ({ content }) => {...}
Fires when the tool handler's return value is piped to the widget. This is the primary data-in path.
app.ontoolresult = ({ content }) => {
const data = JSON.parse(content[0].text);
renderUI(data);
};
Set this BEFORE await app.connect() — the result may arrive immediately after connection.
app.ontoolinput = ({ arguments }) => {...}
Fires with the arguments Claude passed to the tool. Useful if the widget needs to know what was asked for (e.g., highlight the search term).
app.getHostContext() / app.onhostcontextchanged = (ctx) => {...}
Read and subscribe to host context — theme ("light" / "dark"), locale, etc. Call getHostContext() after connect(). Subscribe for live updates (user toggles dark mode mid-conversation).
const applyTheme = (t) =>
document.documentElement.classList.toggle("dark", t === "dark");
app.onhostcontextchanged = (ctx) => applyTheme(ctx.theme);
await app.connect();
applyTheme(app.getHostContext()?.theme);
Keep colors in CSS custom props with a :root.dark {} override block and set color-scheme: light | dark so native form controls follow.
Server → Widget (progress)
For long-running operations, emit progress notifications. The client sends a progressToken in the request's _meta; the server emits against it.
// In the tool handler
async ({ query }, extra) => {
const token = extra._meta?.progressToken;
for (let i = 0; i < steps.length; i++) {
if (token !== undefined) {
await extra.sendNotification({
method: "notifications/progress",
params: { progressToken: token, progress: i, total: steps.length, message: steps[i].name },
});
}
await steps[i].run();
}
return { content: [{ type: "text", text: "Complete" }] };
}
No { notify } destructure — extra is RequestHandlerExtra; progress goes through sendNotification.
Lifecycle
- Claude calls a tool with
_meta.ui.resourceUrideclared - Host fetches the resource (your HTML) and renders it in an iframe
- Widget script runs, sets handlers, calls
await app.connect() - Host pipes the tool's return value →
ontoolresultfires - Widget renders, user interacts
- Widget calls
sendMessage/updateModelContext/callServerToolas needed - Widget persists until conversation context moves on — subsequent calls to the same tool reuse the iframe and fire
ontoolresultagain
There's no explicit "submit and close" — the widget is a long-lived surface.
Sandbox & CSP gotchas
The iframe runs under both an HTML sandbox attribute and a restrictive Content-Security-Policy. The practical effect is that almost nothing external is allowed — widgets should be self-contained.
| Symptom | Cause | Fix |
|---|---|---|
| Widget is a blank rectangle, nothing renders | CDN import of ext-apps blocked (transitive SDK fetches) |
Inline the ext-apps/app-with-deps bundle — see iframe-sandbox.md |
| Widget renders but JS doesn't run | Inline event handlers blocked | Use addEventListener — never onclick="..." in HTML |
eval / new Function errors |
Script-src restriction | Don't use them; use JSON.parse for data |
fetch() to your API fails |
Cross-origin blocked | Route through app.callServerTool() instead |
| External CSS doesn't load | style-src restriction |
Inline styles in a <style> tag |
| Fonts don't load | font-src restriction |
Use system fonts (font: 14px system-ui) |
External <img src> broken |
CSP img-src + referrer hotlink blocking |
Fetch server-side, inline as data: URL in the tool result payload |
window.open() does nothing |
Sandbox lacks allow-popups |
Use app.openLink({url}) |
<a target="_blank"> does nothing |
Same | Intercept click → preventDefault() → app.openLink |
| Edited HTML doesn't appear in Desktop | Desktop caches UI resources | Fully quit (⌘Q) + relaunch, not just window-close |
When in doubt, open the iframe's own devtools console (not the main app's) — CSP violations log there. See iframe-sandbox.md for the bundle-inlining pattern.