Make a tool render its own result

Goal: a tool whose result appears as an interface — a table, a grid, a preview — instead of as JSON the model has to describe.

The chat surface does not learn what your result looks like. The tool ships the HTML, and the host runs it in a sandbox with no cookies, no network and no access to the page around it.

Read MCP Apps first if you have not: this is the one place where code we did not write executes in the user's browser, and four separate mechanisms assume you know that.

1. Write the app as a resource

Apps live in mcp-nextcloud/src/apps/. Each is a template literal of HTML, CSS and JavaScript, registered as a ui:// resource whose mime type is exactly text/html;profile=mcp-app.

Never put a backtick in one. A CSS comment did once, and took the server down at boot. test/apps.test.js is what catches it.

2. Point the tool at it

_meta: { ui: { resourceUri: "ui://tamebi/notes-list", visibility: ["model", "app"] } },

visibility is a security control, not a display preference:

ValueMeaning
["model", "app"]the agent may call it, and an app may call it back
["model"]the agent may call it; no frame can
["app"]the agent never sees it — it is absent from the tool set entirely

The third row is how "the agent cannot send mail" is a fact rather than a promise. Omitting "model" does not hide the tool from the model; it removes it.

3. Let a click ask for more

A frame may send exactly three requests, all parsed at one chokepoint in the island:

  • tools/call — reaches a relay that runs app-declared, read-only tools only, under the signed identity. A click is not an approval, and an approval is signed against a concrete model-issued call that an app does not have. Verified: writing tools answer 403 from there.
  • host/open — takes a path or an id, never a URL. The host builds the URL. An app that could hand over a URL would be a phishing primitive wearing the product's chrome, and //evil.example passes a naive startsWith("/") check. The test that caught that stays.
  • host/resize — clamped, because a frame's own content decides its height and is not trusted to be sane.

4. Do not put an action button in the app

If the result needs a Send, a Confirm or a Delete, the button belongs to the island, not to the app.

A frame's script can call a tool the moment it loads, and no host can observe a click inside a sandboxed iframe. So a button drawn inside an app would make "the click is the approval" unenforceable — the host would have to take the frame's word for it. The mail preview works exactly this way: the app renders the message, the island draws Send and Save as draft.

5. Expect the fingerprint log

App HTML is executable code, so it is fingerprinted like a tool definition and a change is logged loudly. Seeing that line after you edit an app is the system working; seeing it when you did not edit anything is worth investigating.

Testing it

cd mcp-nextcloud && npm test        # includes the app template checks

The CSP is built from a hash of the document, so an app that renders blank in the browser with a console complaint about script-src almost always means the served HTML and the hashed HTML differ — a proxy, a transform, or a stale container. Rebuild before debugging anything else.