Securing MCP Apps: Interactive UI in Tool Results Without a New Attack Surface

Until recently an MCP tool result was text, structured content, or a resource link. The host decided how to render it. The MCP Apps extension changes that: a tool can return a reference to an HTML resource that the host loads in a sandboxed frame, and that frame can call back into the same MCP session. A picker, a chart, a confirmation dialog, a form.

It is a useful capability, and it is an extension, not part of the base protocol. Treat it as an opt-in surface with its own review questions. If you are considering it, the honest first question is whether the interaction needs a UI at all. Most do not. A well-designed tool with an enumerated argument beats a form. Ship the UI only where a human actually has to see something before deciding.

This post is about what changes in the security story when you do.

What the extension actually adds

Three things, roughly:

  1. A UI resource. The server declares HTML as a resource, conventionally under a ui:// URI, and a tool's result points at it. The host fetches the resource through the normal MCP resource read.
  2. A host-controlled frame. The host renders that HTML in an isolated browsing context. Sandboxing, network policy, and whether the frame is shown at all are the host's decisions, not yours.
  3. A message channel. Inside the frame, the code speaks JSON-RPC back to the host over postMessage. That is how the UI reads the tool result, calls another tool, or reports a user action.

The important structural point: the frame is a client, not part of your server. Anything it asks for goes through the host, and the host may refuse, prompt, or rewrite it. Your server never sees the DOM.

The authorization rule does not move

Everything a reviewer already asks about tool calls applies unchanged to calls that originate in your UI frame. An interface element is not an authorization decision.

  • A call made from the frame arrives at your server as an ordinary tool call with an access token. Validate the audience, resolve the user and tenant from the token claims, and enforce scope in the handler. Same code path, no exceptions for "our own UI".
  • Do not add a tool that exists only to serve the UI and skips checks because "only our HTML calls it". Any client can call any tool it can see.
  • Do not put identifiers in the UI and trust them on the way back. If the picker sends account_id, that is a caller-supplied argument. Authorize it against the token subject like any other.
  • A button labelled "Confirm deletion" proves nothing to your server. If a destructive action needs a human, keep the server-side gate: a short-lived confirmation token issued by a preview call, bound to the subject, the tool, and the exact arguments. The UI can be the pleasant way to obtain it. It cannot be the thing that grants it.

The UI resource is code you ship

Once you serve HTML, you have taken on a small web application, and it inherits web application problems.

Injection into the template. If the UI renders data from your database, another tenant's content, or a third-party system, it is rendering untrusted strings into a document. Escape on output, prefer passing data as structured JSON the script reads rather than interpolating into markup, and do not build the frame's HTML by concatenating record fields.

External dependencies. A CDN script tag inside your UI resource is a third-party with read access to whatever that frame holds. Inline or self-host the assets, pin versions, and include the UI bundle in the same supply-chain controls as the rest of the server: lockfiles, SBOM entry, review on change.

Content Security Policy. Declare a restrictive policy for the resource even though the host also sandboxes. Defence in depth is cheap here, and a reviewer who opens the resource will look for it.

Change control. The HTML is part of the server's interface, like a tool definition. Version it, review diffs, and make it reproducible from the repository. "The UI is generated at runtime from a template a support agent can edit" is a finding.

Do not put secrets in the frame

The frame runs on the user's machine, inside a host application you do not control, and its contents are inspectable. So:

  • No access tokens, refresh tokens, API keys, or signing keys in the HTML, in the initial data payload, or in any message the UI receives.
  • No data the user is not already entitled to see. The filter belongs in the tool handler, not in the JavaScript that hides a row.
  • No full records where a display subset will do. Minimization applies to the UI payload exactly as it applies to a tool result, because it is a tool result.

If the UI needs to reach your API directly, it does not. Route it back through a tool call so the server stays the enforcement point and the audit record stays complete.

Prompt injection gets a new shape

Text returned by a tool can carry instructions aimed at the model. A UI resource can carry instructions aimed at the user: a rendered panel that looks like a system notice, asks for a code, or mislabels a button. Two mitigations that hold up:

  • Keep the content of the frame derived from data your server already authorizes, and never render arbitrary third-party HTML fragments inside it.
  • Assume the host may show your frame alongside other content. Do not design an interface that depends on the user believing the frame is the host itself, and do not ask the user for credentials in it. Ever.

In the other direction, treat messages arriving from the frame as untrusted input. Validate them against a schema, check the origin, and ignore anything that does not match a message type you defined.

Audit the frame's calls, and say where they came from

Your existing audit event already has subject, tenant, client, tool, outcome, and correlation ID. Add a field that records that a call originated from a UI frame, and which UI resource. Two reasons:

  • An incident responder reconstructing a session needs to distinguish "the model decided to call this" from "a person clicked a button". They are different stories about intent.
  • A reviewer asking whether human confirmation actually happened wants an event, not a screenshot.

Emit the resource read for the ui:// URI too. It is the moment your code entered the host.

What reviewers will ask

Expect some version of these, and have the answers written down before the questionnaire arrives:

  • Which tools return UI resources, and what does each one render?
  • Is the UI sandboxed, and what does it load from the network at runtime?
  • Can anything the UI does bypass a server-side check? Show the handler.
  • What data is sent into the frame, and how is it minimized?
  • How is the HTML built, reviewed, and versioned?
  • Are UI-originated tool calls distinguishable in the audit log?
  • What happens in a host that does not support the extension?

That last one matters more than it looks. The extension is optional and host support varies. Every tool that returns a UI resource must also return a complete, usable non-UI result: structured content the model can work with and text a person can read. If your server only works in one host, you have built an integration, not an MCP server, and a reviewer at a company standardizing on a different client will notice.

Test it before handover

Add these to the pre-handover plan:

  • Call every UI-backing tool directly, with no frame involved, using another tenant's identifiers. Confirm denial.
  • Replay a confirmation token from one user's session as another user, and after expiry.
  • Send malformed and unexpected messages from the frame side and confirm the host-facing contract rejects them.
  • Render records containing markup, scripts, and instruction-like text and confirm they appear as inert data.
  • Load the server in a host with the extension disabled and confirm every tool still returns something useful.

The short version

MCP Apps move rendering closer to the server, not authority. The frame is a client, the HTML is code you now maintain, and every check still has to live in the tool handler. Adopt it where a human decision genuinely needs a surface, keep a text-and-structured-content path for every tool, and extend the audit schema before you ship rather than after the first incident question.

This is the kind of surface we review in an MCP security audit, and we design it in from the first week on a server build.