Skip to content

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's show_canvas renders the viewer inline. The same scenario passes in both modes (canvas-show and viewer-parity).
  • A local host. The lab's /host.html is a minimal MCP Apps host built on ext-apps' AppBridge, without CopilotKit. It calls show_canvas itself, 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.