Files
claude-plugins-official/plugins/mcp-server-dev/skills/build-mcp-app/references/apps-sdk-messages.md

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.

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: