UI cards#
Tools can render an interactive card in the conversation instead of just text. Cards use the official MCP Apps extension, supported by Claude, ChatGPT, VS Code, Microsoft 365 Copilot and others. Clients without it show the tool's text result as usual.
A card is a static HTML file. Point a route at it with ui::
class Bookings < AC::Base
base "/bookings"
# Shows a booking
@[AC::MCP(ui: "bookings/card.html")]
@[AC::Route::GET("/:id")]
def show(id : String) : Booking
Booking.find!(id)
end
end
and tell the MCP server where the cards are:
ActionController::MCPServer.ui_base = "./cards" # cards/bookings/card.html
The template keeps its cards in cards/, next to www/. Set SG_MCP_UI to change it.
How it works#
- The tool's definition points at the card,
ui://bookings/card.html. - When the tool is called, the host reads the card with
resources/readand renders it in a sandboxed iframe. - The host sends the card the tool's arguments, then its result. The result is the
{status, headers, body}envelope, asstructuredContent.
Cards can also call tools through the host, for example to refresh their data or act on a button press.
Writing a card#
Cards are self-contained HTML5 documents that talk to the host with JSON-RPC over
postMessage. You can use the @modelcontextprotocol/ext-apps SDK, or the protocol
directly:
<!DOCTYPE html>
<html>
<body>
<h1 id="title">Loading…</h1>
<script>
const send = (message) => window.parent.postMessage({ jsonrpc: "2.0", ...message }, "*");
window.addEventListener("message", ({ data }) => {
if (data?.id === 1) {
// initialized, the host now sends the tool input and result
send({ method: "ui/notifications/initialized" });
} else if (data?.method === "ui/notifications/tool-result") {
const booking = data.params.structuredContent.body;
document.getElementById("title").textContent = booking.title;
}
});
send({ id: 1, method: "ui/initialize", params: {
protocolVersion: "2026-01-26",
appInfo: { name: "booking-card", version: "1.0.0" },
appCapabilities: {},
} });
</script>
</body>
</html>
The template's cards/welcome/result.html is a complete example: it follows the host's
theme and reports its size so the host can fit the frame to it.
- Theme: the
ui/initializeresult includeshostContext.theme(lightordark) and CSS variables such as--color-text-primaryinhostContext.styles.variables. - Size: send
ui/notifications/size-changedwith the content height. - Calling tools: send a
tools/callrequest, for example to refresh the card's data.
Card settings#
By default hosts run cards with a strict content security policy: inline scripts and
styles only, no external resources and no network requests. Declare anything a card
needs with ui_meta, the default for every card:
ActionController::MCPServer.ui_meta = ActionController::MCPServer::UIMeta.new(
prefers_border: true,
csp: ActionController::MCPServer::UICSP.new(resource_domains: ["https://cdn.example.com"]),
)
or for a single card, with a .meta.json file next to it, which replaces the default:
// cards/bookings/card.meta.json
{"csp": {"connectDomains": ["https://api.example.com"]}, "prefersBorder": false}
| Setting | Purpose |
|---|---|
csp.connectDomains |
origins the card can fetch from |
csp.resourceDomains |
origins for scripts, styles, images, fonts and media |
csp.frameDomains |
origins the card can embed |
csp.baseUriDomains |
allowed base URIs |
permissions |
browser permissions to request, e.g. {"clipboardWrite": {}}. Hosts may refuse |
domain |
a dedicated sandbox origin, host specific |
prefersBorder |
whether the host draws a border around the card |
Tools only cards call#
Mark tools a card uses, but the model shouldn't, with visibility: :card. Hosts hide them
from the model:
# Checks in to a booking
@[AC::MCP(visibility: :card)]
@[AC::Route::POST("/:id/check_in")]
def check_in(id : String) : Booking
They're still ordinary routes, protected by your filters. The opposite,
visibility: :model, stops cards calling a tool. Hosts enforce visibility, and the
call_tool and call_read_only proxies refuse card only tools, but it isn't access
control: a client can still call a tool by name. In the tool definition, :card is sent
as the spec's "app" (_meta.ui.visibility: ["app"]).
Card metadata is sent to every client. Hosts that support cards don't always advertise it, and hosts that don't support them ignore it and show the text result.
Things to know#
- Root by default: hosts only render cards for, and let cards call, the tools in
their tool list, and some clients don't refresh their tools when a toolbox opens. So
tools with
ui:orvisibility: :cardare root items, always listed, unless you annotate themroot: false. - Caching: hosts cache cards by URI, so tools advertise
ui://bookings/card.html?v=<content hash>. Deploying a changed card changes its URI. - Safety:
ui:paths must be relative.htmlpaths (checked at compile time), and nothing outsideui_baseis served. - Deploying: copy the cards folder into your image, like
www/.