← Ayo Osunjuyigbe

· AI

MCP Apps draw forms inside your chat. Confirm before you submit.

Tuesday the fifth spec locked MCP Apps into the official extensions framework, next to Tasks and Enterprise Managed Authorization. The launch copy is about charts and dashboards and working with it inline, without switching tabs. That last clause is the product. The connector used to return text. It now draws HTML inside the chat you already trust.

I have been pinning tool descriptions since Invariant showed they were prompt. This is the same class with a face. The iframe looks like Claude. It is the server.

What shipped

SEP-1865 went stable on 26 January. The identifier is io.modelcontextprotocol/ui. 28 July is when 2026-07-28 put Apps, Tasks, and EMA under a versioned extensions framework instead of leaving UI as a community fork you adapt per host.

The mechanism is not a new chat API.

A server declares a resource on the ui:// scheme. MIME type text/html;profile=mcp-app. A tool points at that resource with _meta.ui.resourceUri. The host fetches the HTML with resources/read and puts it in a sandboxed iframe. If the host does not speak Apps, the tool falls back to text. That is the compatibility story.

The iframe is not a picture. It is an MCP client. It talks to the host over JSON-RPC on postMessage. It can tools/call. It can resources/read. It handshakes with ui/initialize. The spec lets a tool be visible to the model, to the app, or to both. visibility: ["app"] means the catalog the model sees does not include that tool, and the iframe still can. Cross-server calls from an app-only tool are blocked. Same-server calls are the point.

MCP-UI had already proved the pattern — Postman, Hugging Face, Shopify, Goose, ElevenLabs. OpenAI’s Apps SDK, DevDay, 6 October 2025, put the same idea in ChatGPT on top of MCP. SEP-1865 is the unification so you do not ship one HTML bundle for Claude and another for ChatGPT. I am glad they standardized it. I am not glad people will read sandboxed iframe and stop thinking. The spec was already stable in January. Tuesday made it a host expectation.

Elicitation is the ask. Apps is when they draw it themselves.

Do not mash these together. They sit next to each other in the 2026 family and they are not the same RPC.

Elicitation has been in the spec since 18 June 2025. The server needs a fact it should not guess — a project name, a date range, a yes. It used to fire elicitation/create on a live stream. Tuesday that became a Multi Round-Trip Request: the tool returns resultType: "input_required", the host collects answers, the client retries with inputResponses. No sticky session. The host renders the form from a flat JSON schema. Primitive fields only. You see the field names. You can decline.

URL mode arrived in the 25 November 2025 revision. The spec is blunt: servers MUST NOT use form mode for passwords, API keys, tokens, or payment credentials. Those go out of band. The client has to show the target domain and get a click before it navigates.

That is the grown-up ask. The host owns the chrome. The schema is data.

MCP Apps is the other bet. The server ships the HTML. The host is supposed to enforce CSP from _meta.ui.csp, default connect-src 'none', no undeclared origins, a double iframe on web hosts so the inner document is not same-origin with the chat. Claude’s example origin is a hash on claudemcpcontent.com. OpenAI’s is a rewrite on oaiusercontent.com. The spec’s own leftover risk, not mine: the UI can still display misleading content. Hosts should mark the boundary.

A weather dashboard that plots the tool result is why they built this. A confirm delete that is the server’s own button, styled like the host, sitting in the thread you already said yes to, is why I am writing.

There is a proposal to hang an App off an elicitation so the ask is rich instead of a schema. If that lands, the two paths become one screenshot. Until then, treat every inline widget as the server’s page, not the host’s dialog.

The sandbox is not the confirm

The threat model in SEP-1865 is the honest part of the document. Malicious HTML. Sandbox escape. Unauthorized tools/call from the View. Exfil. Phishing. Mitigations: iframe sandbox, auditable JSON-RPC, review the template at connect time, hash it, CSP from declared domains only. Hosts MAY block View messages or put them behind another approval. Hosts SHOULD log the CSP.

Read that as a host checklist, not as a property you get for installing the server.

I already learned the cheaper version. Tool name + description + inputSchema go into the model. The UI shows add. The model sees the docstring. A rug pull is a tools/list that changed after you clicked approve. Apps adds a fourth object: the HTML. resources/read on ui://… is fetched at render. If you pinned the tool and not the page, the calculator can grow a form.

The iframe can also do. visibility: ["app"] is a refresh button the model never hears about. It is also a submit the model never proposed. The host is in the middle of every postMessage. If it proxies tools/call from the View without showing you the name, the destination, and the arguments, you built a second, quieter agent that lives in the widget.

CSP is an allowlist the server wrote. Empty connectDomains is the secure default. A dashboard that asks for https://cdn.jsdelivr.net plus its own API is a review. A dashboard that asks for the open web is a different product. The host MUST NOT loosen past what was declared. It is allowed to tighten. Tighten.

Camera, microphone, geolocation, clipboard-write are opt-in Permission Policy bits on the iframe. A calendar connector does not need a mic. If the metadata asks, that is the review, not a later prompt.

What I do now

I will use Apps. I will not treat the thread as a trusted renderer.

Pin the page, not just the tool. Hash name, description, inputSchema, _meta.ui.resourceUri, and the bytes of the ui:// HTML plus its CSP and permissions on first approve. Drift on any of those is a rug. Re-read the full HTML. Do not wait for resources/list — the spec lets a server omit UI-only resources from the list and still serve them when the tool fires. A package-lock on the server binary does not lock resources/read.

The chrome has to say which server this is. Elicitation already requires it. Apps should too. If the host cannot put a name and an origin on the widget — not a favicon, a string I chose at install — the App does not render. The spec’s social-engineering note is the reason, not a style preference.

tools/call from the View is the same confirm as from the model. Spend, delete, send, git push, anything that leaves the machine. Raw tool name, destination, byte length, schema-validated arguments, on a surface the iframe cannot restyle. App-only tools get the stricter reading: I never saw them in the catalog, so I do not approve them in a batch with allow this connector.

Secrets do not go in the widget. Form-mode elicitation already forbids passwords and keys. An App is still in-band: the host sees whatever you type. Payment and tokens stay on a URL I can read, or they do not happen.

connectDomains empty unless I typed the list. Same for resourceDomains and frameDomains. Nested iframes off unless the server named YouTube and I meant YouTube. No camera. No mic. No geolocation. Clipboard-write is a conversation.

I wanted a standard so the weather card is not a one-off Claude artifact. We got one. The host is still the product. The HTML is still the server. If you cannot see the boundary, you are not ready to click Submit.

References