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.
Previewing cards#
The card previewer renders your cards in a browser, without connecting an AI client. It plays the host's part: it calls the tool, loads the card and passes it the tool's arguments and result. You can try a card at different screen sizes and themes, and it checks the same rules a real host does.
Mount it after the MCP server, in development only:
server = ActionController::Server.new(port, host)
ActionController::MCPServer.mount(server, "/mcp")
server.run do
# left out of release builds
{% unless flag?(:release) %}
server.mount_mcp_card_previewer("/mcp/card/preview")
{% end %}
puts "Listening on #{server.print_addresses}"
end
Then open http://127.0.0.1:3000/mcp/card/preview.
- The index lists each card with the tools that render it, and the tools only cards
call (
visibility: :card). It warns you whenui_baseisn't set or a card file is missing. - A tool's page has a form for the tool's arguments, built from its input schema. The card renders as soon as every required argument has a value, so a tool whose arguments are all optional renders straight away.
- The toolbar switches between desktop (1280px), tablet (768px) and mobile (375px) viewports, and light and dark themes. The card is told about each change, as a host would tell it.
- The panels below the card show every message between the card and the host, the
raw tool result, and anything the card sends to the model (
ui/messageandui/update-model-context).
Note
The previewer was added in action-controller 9.1.3. It finds the MCP server from
MCPServer.mount, so mount that first. If a proxy serves your app under a prefix,
pass the path the browser uses: mount_mcp_card_previewer(mcp: "/prefix/mcp").
Authentication#
The previewer is an ordinary MCP client of your server, so requests go through your
authentication and route filters. Add request headers, such as
Authorization, in the page's header editor. They're sent with every MCP request and
kept until you close the browser tab. Your site's cookies are sent automatically.
Preview links#
Everything on a tool's page can be set in the URL, so you can share a preview or script one:
/mcp/card/preview/tools/bookings_show?id=42&viewport_size=mobile&theme=dark
| Query param | Purpose |
|---|---|
<argument> |
a tool argument by name, e.g. id=42. Write objects and arrays as JSON |
viewport_size |
desktop, tablet or mobile |
theme |
light or dark |
header.<Name> |
a request header, e.g. header.Authorization=Bearer%20token |
Tokens in URLs end up in server logs and browser history, so only put test credentials
in header. params.
What it checks#
The previewer applies the rules hosts apply to cards, so problems show up before you deploy:
- Content security policy: the card runs in a sandboxed iframe with the policy built from its card settings. A request to a domain the card hasn't declared is blocked.
- Tool calls: a card can only call the tools in the tool list, and not
visibility: :modeltools. Calls are sent with the same headers as the page. - Host messages: the previewer answers
ui/initializewith the theme, CSS variables, viewport and platform, resizes the card onui/notifications/size-changed, and supports fullscreen, opening links and downloads. Anything it doesn't support is logged and answered with an error.
It isn't a full host. There's no model, and the card's frame has no origin of its own,
so a card that uses localStorage fails in the previewer even though some hosts allow
it.
Warning
Don't mount the previewer in production. Its pages are served without
authentication and list your card tools and their descriptions. Tool calls still need
valid credentials. The pages and their scripts are only compiled into your app when
mount_mcp_card_previewer is called, so the flag?(:release) check keeps them out
of release builds.
Testing cards in a browser#
Because previews can be driven by URL, browser tests are short: open a preview link, wait for the card, then check what's in it. The examples use Playwright, but any browser automation tool works.
Set up Playwright alongside your app:
npm init -y
npm install --save-dev @playwright/test
npx playwright install chromium
Configure it to start your app (unless it's already running) and send your test credentials:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "browser",
use: {
baseURL: "http://127.0.0.1:3000",
// wide enough for the desktop viewport (1280px) beside the form
viewport: { width: 1920, height: 1080 },
// sent with every request, including the previewer's MCP requests
extraHTTPHeaders: { Authorization: `Bearer ${process.env.TEST_TOKEN}` },
},
webServer: {
// a development build, so the previewer is mounted
command: "crystal run src/app.cr",
url: "http://127.0.0.1:3000/mcp/card/preview",
reuseExistingServer: !process.env.CI,
timeout: 300_000, // the first build can be slow
},
});
Then write a test for each card:
// browser/bookings.spec.ts
import { test, expect } from "@playwright/test";
// a link to a tool's preview
const preview = (tool: string, params: Record<string, string>) =>
`/mcp/card/preview/tools/${tool}?${new URLSearchParams(params)}`;
for (const viewport of ["desktop", "tablet", "mobile"]) {
test(`booking card on ${viewport}`, async ({ page }) => {
await page.goto(preview("bookings_show", { id: "42", viewport_size: viewport }));
// ready once the card has initialized and received the tool result
await expect(page.locator("#stage")).toHaveAttribute("data-state", "ready");
const card = page.frameLocator("#card");
await expect(card.locator("h1")).toHaveText("Booking 42");
await expect(page.locator("#card")).toHaveScreenshot(`booking-${viewport}.png`);
});
}
test("checks in from the card", async ({ page }) => {
await page.goto(preview("bookings_show", { id: "42" }));
await expect(page.locator("#stage")).toHaveAttribute("data-state", "ready");
// the card calls the bookings_check_in tool through the previewer
await page.frameLocator("#card").getByRole("button", { name: "Check in" }).click();
await expect(page.frameLocator("#card").getByText("Checked in")).toBeVisible();
});
Run them with npx playwright test. On CI, install the browser with
npx playwright install --with-deps chromium and set TEST_TOKEN as a secret.
toHaveScreenshot saves a reference image of the card the first time it runs, then
fails if the card's appearance changes. The card always lays out at the viewport's full
width, and the stage scrolls when the browser window is narrower, so keep the window wide
enough for the desktop viewport. Update the images with npx playwright test
--update-snapshots.
What tests can check#
| Selector or property | Purpose |
|---|---|
#stage[data-state] |
idle (waiting for required arguments), loading, rendering, ready or error |
#stage[data-tool-error] |
present when the tool returned an error, which the card still renders |
#status |
a description of the state, such as why rendering failed |
#card |
the card's iframe, use page.frameLocator("#card") to look inside it |
window.cardPreviewer.result |
the tool result sent to the card |
window.cardPreviewer.log |
every message, as {dir, summary, data, level} |
For example, to check a card didn't log any errors:
const log = await page.evaluate(() => (window as any).cardPreviewer.log);
expect(log.filter((entry) => entry.level === "error")).toEqual([]);
To test what happens without credentials, leave them out. When the MCP server requires
authentication, the state becomes error and #status reports the
401 Unauthorized response. When only the route checks credentials, the card renders the
error response and #stage has data-tool-error.
Things to know#
- Root by default: hosts only render cards for the tools in their tool list, and
toolbox tools aren't listed (the model runs them through the proxies). So tools with
ui:are root items, always listed, unless you annotate themroot: false. Tools withvisibility: :cardare always listed, so cards can call them, and hosts hide them from the model. - 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/.