MCP Apps as host and as viewer
MCP Apps let an MCP server
ship a UI with its tools: a tool names a ui:// resource, and the host renders
it in a sandbox next to the conversation. The stack plays both roles.
As a host: a partner's app in the chat
CopilotKit's MCP Apps middleware connects to the server, adds its model-visible
tools to the run, runs a call against the server, and renders the app in the
chat with the call's result. Allow the tool like any browser tool, with
client_tools, and give the server a stable serverId so the app's own
requests reach it again after a reload.
A UI tool ends the run waiting on its call; kaboo-runtime sees that the middleware answered it and runs the agent again with the answer, so the turn finishes with the agent's reply, as one run. The app's own requests — reading its resource, calling its server's tools — pass through the runner unrecorded.
Lab scenario: mcp-partner.
As a viewer: your canvas as an MCP App
The same canvas that sits beside the chat can be served as an MCP App, so another host — Claude, ChatGPT, or a host of your own — can show it. The lab's viewer endpoint is an MCP server with:
show_canvas, visible to the model and the app, with_meta.ui.resourceUri: ui://kaboo/canvas.- App-only tools —
canvas_get,canvas_query_data,canvas_set_variables— that the viewer calls for the dashboard and its rows._meta.ui.visibility: ["app"]keeps them from every model. - A
ui://shell that loads the viewer bundle from an origin listed in_meta.ui.csp.resourceDomains.
Inside the host, the viewer speaks the MCP Apps protocol: a selected widget goes
to ui/update-model-context, so the model knows what the person is looking at;
"Update analysis" posts ui/message; "Open in the lab" uses ui/open-link. It
refreshes by polling canvas_get. A host that doesn't offer model context
(CopilotKit's, today) gets the selection as a message instead.
The host sizes the iframe to the height the app reports, and the app measures
its document. A stylesheet that pins html and body to height: 100% — usual
in an app shell — keeps the document exactly as tall as the iframe, so the app
never reports growing past its loading state. The lab's shell marks its <html>
with class="mcp-app", and the shared stylesheet lets that document size to its
content.
The CanvasHost seam
The viewer is not a second canvas. The dashboard components take a CanvasHost
— where rows come from, where variable changes go, what to do with an intent —
and two adapters implement it:
| Native adapter | MCP Apps adapter | |
|---|---|---|
| Rows | The services' REST API | canvas_query_data, through the host |
| Variables | REST, then a kaboo_action message |
canvas_set_variables, then "Update analysis" |
| "Ask Kaboo" | A message with kaboo_action |
ui/update-model-context, or a message |
| Refresh | socket.io | polling |
Write a canvas against the seam and it runs in both places.
Parity mode and a local host
Two ways to see the viewer without an external host:
- Parity mode. The lab's canvas picker switches a thread to "as an MCP App
in the chat": the runtime attaches the viewer server for that thread, the
browser stops declaring its own
show_canvas, and the agent'sshow_canvasrenders the viewer inline. The same scenario passes in both modes (canvas-show and viewer-parity). - A local host. The lab's
/host.htmlis a minimal MCP Apps host built on ext-apps'AppBridge, without CopilotKit. It callsshow_canvasitself, renders the viewer, and shows what the app sends back — including the model context a selection produces.
Test in these two first; external hosts accept local servers on their own terms.