8.6 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.
Construction
const app = new App(
{ name: "MyWidget", version: "1.0.0" },
{}, // capabilities
{ autoResize: true }, // options
);
autoResize: true wires a ResizeObserver that emits ui/notifications/size-changed so the host iframe height tracks your rendered content. Without it the frame is fixed-height and tall renders get clipped — set it for any widget whose height depends on data.
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.
const csv = rows.map((r) => Object.values(r).join(",")).join("\n");
app.downloadFile({
name: "export.csv",
mimeType: "text/csv",
content: btoa(unescape(encodeURIComponent(csv))),
});
app.requestDisplayMode({ mode })
Ask the host to switch the widget between "inline", "pip", or "fullscreen". Check getHostContext().availableDisplayModes first; hide the control if the mode isn't offered. The host responds by firing onhostcontextchanged with new displayMode and containerDimensions — re-render at the new size.
if (app.getHostContext()?.availableDisplayModes?.includes("fullscreen")) {
expandBtn.hidden = false;
expandBtn.onclick = () => app.requestDisplayMode({ mode: "fullscreen" });
}
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.ontoolinputpartial = ({ arguments }) => {...} / app.ontoolcancelled = () => {...}
ontoolinputpartial fires while Claude is still streaming arguments — use it to show a skeleton ("Preparing: