Safeguard
Supply Chain Security•

MCP Apps Explained: Interactive UI from MCP Servers and How to Secure It

MCP Apps let an MCP server ship an HTML view that the host renders in a sandboxed iframe. How it works, where it is weak, and how to lock it down.

Safeguard Security Team
45 min read

MCP Apps is the official Model Context Protocol extension that lets an MCP server ship interactive UI, not just text, to the chat or editor calling its tools. A tool points at an HTML template, and the host renders it in a sandboxed iframe that talks back over postMessage. Secure it by treating that HTML as untrusted third-party code.

In practice that means four habits: keep the sandbox strict, declare the smallest domain list you can, validate every message, and confirm every action a view asks for. The rest of this post shows how each one maps to the specification.

This post goes one level deeper than our earlier explainers on how MCP works on the wire, MCP server tool permissions and trust and MCP tool poisoning. Those covered tools and the text they return. This one covers what happens when a tool returns a working web page. Everything below is checked against the stable MCP Apps specification dated 26 January 2026 (SEP-1865), the current draft in the ext-apps repository, the official quickstart, and the host documentation that Anthropic and OpenAI publish for their own products.

Key takeaways

  • MCP Apps is an optional extension with the identifier io.modelcontextprotocol/ui. It was proposed on 21 November 2025 as SEP-1865 and marked stable on 26 January 2026.
  • It standardises patterns first built by the community MCP-UI project and by OpenAI's Apps SDK. Anthropic, OpenAI and the MCP-UI maintainers wrote it together.
  • A UI is a resource with a ui:// URI and the MIME type text/html;profile=mcp-app. A tool links to it with _meta.ui.resourceUri.
  • The view runs in a sandboxed iframe and speaks MCP's own JSON-RPC over postMessage. It can call tools, read resources, open links, post a chat message and update the model's context, all through the host.
  • Hosts must build a Content Security Policy from the domains the server declares, and must not allow anything undeclared. With no declaration, the view gets no network access at all.
  • Web hosts must use a double iframe: an outer sandbox proxy on a different origin from the host, and the server's HTML inside it.
  • The sandbox stops a view from touching the host page. It does not stop a view from lying to the user, nudging the model, or sending data to a domain you allowed.
  • Approvals for view-initiated tool calls are a host decision. Design your server as if the host might not ask.
  • Views are front end bundles. Every package in that bundle ships into your users' chat window, so the usual dependency hygiene applies with a higher cost of failure.

What is MCP Apps?

Plain MCP tools return content blocks: text, images, or structured JSON. That works when the answer is a sentence. It works badly when the answer is a chart someone wants to hover over, a form with ten related settings, a map, or a list someone wants to reorder by hand. Before MCP Apps, a host either had to write its own renderer for every kind of structured data or the conversation fell back to a long exchange of text prompts.

MCP Apps fixes that by letting the server bring its own interface. The announcement on the MCP blog frames the problem as fragmentation: several projects had solved interactive UI in different, incompatible ways, and a standard was needed before each host locked in its own convention. The extension defines three things:

  1. UI resources. Templates, declared up front, under the ui:// scheme.
  2. Tool to UI linkage. A tool names the template that should render its results.
  3. A communication channel. The rendered view and the host exchange JSON-RPC messages, reusing MCP's base protocol rather than inventing a new one.

The specification calls the rendered UI a View. The application that embeds it (a chat app, a desktop client, an IDE) is the Host. The MCP server that supplies both the tools and the template is simply the server. Keep those three roles in mind; nearly every security question below is about which of them is trusted to do what.

When was it proposed, and what is its status?

The proposal was published on 21 November 2025 as SEP-1865 on the Extensions track. The pull request was opened that day and merged on 28 January 2026, and its summary lists the status as Final. The full text lives in the ext-apps repository, where the stable specification carries the header "Status: Stable (2026-01-26)". A separate draft file holds work in progress for the next revision.

On the same date, 26 January 2026, the VS Code team announced support and described MCP Apps as the first official MCP extension. OpenAI's plugin UI changelog records on 22 February 2026 that ChatGPT became fully compatible with the MCP Apps specification. When the core protocol moved to its 2026-07-28 revision, the release notes listed MCP Apps among the extensions under the new formal extensions framework.

DateEventSource
21 Nov 2025MCP Apps proposal published as SEP-1865MCP blog, SEP-1865 pull request
26 Jan 2026Specification marked stable; VS Code announces supportext-apps stable spec, VS Code blog
28 Jan 2026SEP-1865 pull request merged into the main MCP repositoryGitHub pull request metadata
22 Feb 2026ChatGPT listed as fully compatible with the MCP Apps specOpenAI plugin UI changelog
27 May 2026ChatGPT delivers tool input to views only after approval for tools that need itOpenAI plugin UI changelog
12 Jun 2026ChatGPT adds per-app permission controls for when apps ask before actingOpenAI plugin UI changelog
28 Jul 2026Core MCP 2026-07-28 released with a formal extensions framework that includes MCP AppsMCP blog

What did it build on?

The announcement credits two earlier efforts.

MCP-UI, created by Ido Salomon and Liad Yosef, delivered UI as MCP resources before any standard existed. It developed the two-way messaging model and experimented with three content types: raw HTML, external URLs and "remote DOM". Its site now describes MCP-UI as standardised into MCP Apps, with its client and server packages implementing the spec and acting as a playground for future features.

OpenAI's Apps SDK let developers build interactive apps inside ChatGPT with MCP as the backbone. Its field names left visible marks on the standard. OpenAI's guide to MCP Apps in ChatGPT maps each older ChatGPT field to its standard equivalent: _meta["openai/outputTemplate"] became _meta.ui.resourceUri, window.openai.callTool became the standard tools/call, and window.openai.sendFollowUpMessage became ui/message. OpenAI tells developers to use the standard fields for new work and keep window.openai for features the shared spec does not cover.

Two things changed between proposal and stable text that matter if you read older tutorials. The November 2025 blog post showed the MIME type text/html+mcp; the stable spec requires text/html;profile=mcp-app. The proposal also used a flat _meta["ui/resourceUri"] key; the stable spec nests it as _meta.ui.resourceUri and marks the flat key deprecated.

How does MCP Apps work?

The lifecycle has four phases: discovery, initialisation, the interactive phase and teardown.

Capability negotiation

Nothing happens unless both sides agree. A host that supports the extension advertises it in its MCP initialize request under capabilities.extensions, keyed by io.modelcontextprotocol/ui, with a required mimeTypes array listing text/html;profile=mcp-app. The specification says servers should check that capability before registering UI-enabled tools, and the SDK ships a getUiCapability helper for the check.

Graceful degradation is written into the rules. Servers should provide text-only fallback behaviour for every UI-enabled tool, and tools must return a meaningful content array even when UI is available. A host that does not speak MCP Apps simply sees an ordinary tool.

UI resources and the ui:// scheme

A UI template is a normal MCP resource with a few constraints. The URI must start with ui://. The MIME type must be text/html;profile=mcp-app. The content must be a valid HTML5 document, delivered either as a string in text or base64 in blob. The resource can carry a _meta.ui object with four groups of settings:

  • csp: the external origins the view needs, split into connectDomains, resourceDomains, frameDomains and baseUriDomains.
  • permissions: requests for camera, microphone, geolocation or clipboard write. The host may honour them through the iframe allow attribute; the view should not assume they were granted.
  • domain: an optional dedicated origin for the view, in a format each host defines.
  • prefersBorder: whether the host should draw a visible border and background.

The spec reserves the ui:// prefix and the io.modelcontextprotocol/ui label for this extension.

Linking a tool to its UI

A tool declares its view through _meta.ui.resourceUri. It can also declare _meta.ui.visibility, an array containing "model", "app" or both, defaulting to both:

  • "model" means the agent can see and call the tool.
  • "app" means a view from the same server connection can call it.

The host must leave app-only tools out of the agent's tool list, must reject tools/call requests from a view for any tool that lacks "app", and must always block cross-server calls to app-only tools. This is the most useful security control in the extension for server authors, and we come back to it below.

Hosts fetch the template with resources/read, may prefetch and cache it, and may review it before any tool runs. Because templates are discovered through tool metadata, servers may leave UI-only resources out of resources/list.

Rendering in the host

When the model calls a tool whose metadata names a UI resource, a supporting host reads the template and renders it in a sandboxed iframe. It then sends the view the tool's arguments and, when the tool finishes, its result. The template is static; the data arrives separately. That split is deliberate: it lets hosts cache templates, review them once, and avoid re-rendering on every call.

OpenAI's guidance takes the split one step further. It recommends separating data tools, which fetch or compute and return results with no template, from render tools, which take final data and carry the _meta.ui.resourceUri. The model then checks the data before deciding to show anything, which also gives you a natural place to validate.

How do the embedded UI and the host talk?

The view and host talk over JSON-RPC 2.0 carried by window.postMessage. The specification describes the view as conceptually an MCP client and the host as an MCP server that can proxy the real server. The SDK provides an App class with a PostMessageTransport; the spec also shows that a view can speak the protocol by hand without any SDK.

Messages a view can send

From the standard MCP vocabulary, a view may use:

MethodPurpose
tools/callAsk the host to run a tool on the server
resources/readAsk the host to read a resource
notifications/messageSend a log message to the host
pingHealth check
ui/initialize, then ui/notifications/initializedHandshake, including the view's capabilities and display modes

Then there are methods specific to MCP Apps:

MethodDirectionWhat it does
ui/open-linkView to hostAsk the host to open a URL in the user's browser
ui/messageView to hostAdd a message to the conversation, usually with role user
ui/request-display-modeView to hostAsk for inline, fullscreen or picture-in-picture
ui/update-model-contextView to hostReplace the context the view contributes to future model turns
ui/notifications/size-changedView to hostReport the view's rendered size
ui/notifications/tool-inputHost to viewDeliver the complete tool arguments, at most once
ui/notifications/tool-input-partialHost to viewStream best-effort partial arguments while the model is still writing them
ui/notifications/tool-resultHost to viewDeliver the finished CallToolResult
ui/notifications/tool-cancelledHost to viewTell the view the call was cancelled, with a reason
ui/notifications/host-context-changedHost to viewTheme, display mode, size or other context changed
ui/resource-teardownHost to viewWarn the view before it is destroyed

The current draft adds more, including ui/download-file for host-mediated downloads (the sandbox does not set allow-downloads), ui/notifications/request-teardown, and a section on tools that a view registers itself for the agent to call. Treat draft features as subject to change.

Three of these deserve a security reading now.

ui/message lets a view put words in the user's mouth. The spec says the host should add the message to the conversation preserving the role, and may ask for consent. A view that posts "yes, go ahead and delete the old records" as a user message is speaking for the person.

ui/update-model-context lets a view write text the model will read in future turns. Each call overwrites the previous one, the host may defer it to the next user message, and the host may show it to the user. Anything the view writes here is a direct path into the prompt.

ui/notifications/tool-input-partial carries guesses. The spec states that views must not rely on partial arguments for critical operations. Render them as a preview, never act on them.

The handshake and data flow

The order is fixed. The view sends ui/initialize with its capabilities. The host replies with its own capabilities, including which sandbox permissions and CSP domains it actually approved, and a host context object for theming and layout. The view sends ui/notifications/initialized. Only after that may the host send anything else, and it must send ui/notifications/tool-input before ui/notifications/tool-result.

The host's reply is worth reading carefully in your view code. If you asked for connectDomains and the host approved fewer, your view should degrade rather than fail. The same goes for permissions: the spec tells views to use feature detection rather than assume a grant.

What does the host sandbox, and what is left to the server author?

The double iframe for web hosts

If the host is itself a web page, the spec requires a two-layer design. The host embeds an outer sandbox proxy iframe. The proxy then loads the server's raw HTML in an inner iframe with the CSP built from the resource metadata. The rules are specific:

  1. The host and the sandbox proxy must have different origins.
  2. The proxy must run with allow-scripts and allow-same-origin.
  3. The proxy signals ui/notifications/sandbox-proxy-ready, the host replies with ui/notifications/sandbox-resource-ready carrying the HTML, CSP and permissions.
  4. The proxy forwards all other messages between host and view, and should not invent requests of its own.
  5. The host may forward non-ui/ messages from the view to the MCP server, may block some, and may require user approval for others.

Rule 1 is what makes rule 2 safe. The WHATWG HTML standard warns that setting both allow-scripts and allow-same-origin on a frame that shares the parent's origin lets the framed page remove its own sandbox and reload, escaping it entirely. Putting the proxy on a separate origin, such as a per-conversation or per-server subdomain, removes that escape path. If you build a host and are tempted to serve the proxy from your main domain for convenience, this is the line you must not cross.

Desktop and native hosts have their own initialisation sequence in the spec, but the view still must run in a sandboxed iframe. The specification's mitigations section says all view content must be rendered that way.

The Content Security Policy hosts must apply

The host must construct a CSP from the declared domains. If the resource declares nothing, the host must use a restrictive default:

default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self' data:;
connect-src 'none';

The host may tighten further but must not allow undeclared domains, and should log CSP configurations for review. With declarations, each list maps to directives:

FieldCSP directivesDefault when omitted
connectDomainsconnect-src (fetch, XHR, WebSocket)No external connections
resourceDomainsscript-src, style-src, img-src, font-src, media-srcNo external static resources
frameDomainsframe-srcframe-src 'none'
baseUriDomainsbase-uribase-uri 'self'

object-src 'none' is always set. Wildcard subdomains such as https://*.example.com are allowed in resourceDomains.

Two details are easy to miss. First, script-src includes 'unsafe-inline' in both the default and the worked example, because the template is usually a single inlined HTML file. That means the CSP does not protect against script injected into the view's own DOM; your view code must not create that injection in the first place. Second, the spec's illustrative CSP builder in its security section starts connect-src with 'self', while the normative default for an undeclared resource is connect-src 'none'. In a sandboxed opaque origin 'self' matters less, but if you implement a host, follow the normative default and test that an undeclared view really cannot open a connection.

OpenAI's security and privacy guide adds host-specific behaviour for ChatGPT: widgets cannot use window.alert, window.prompt, window.confirm or navigator.clipboard; nested frames are off unless you declare frameDomains; and an embedded page uses its own CSP, so the widget's connectDomains and resourceDomains lists do not restrict what that embedded page fetches. That last point is a trap. Allowing one frame domain can quietly allow much wider network access than your connect list suggests.

Dedicated origins with ui.domain

By default a host serves views from a sandbox origin it chooses, typically per conversation. A server can ask for a stable dedicated origin with _meta.ui.domain, which helps with OAuth callbacks, CORS rules and API key allowlists. The format is host-defined. Anthropic's MCP Apps guide says that for Claude the value is the first 32 hex characters of the SHA-256 of your server URL, followed by .claudemcpcontent.com, and gives a one-line Node command to compute it. The specification's own examples also show a URL-derived pattern, www-example-com.oaiusercontent.com.

A stable origin is useful and also a responsibility. If your backend trusts requests from that origin, anything running inside your view, including every third-party script you load, carries that trust.

What is left to the server author

The host controls the frame, the CSP, the approval prompts and the message routing. The server author controls everything else:

  • The HTML and every script inside it, including bundled packages.
  • How the view handles data from tool results, which may include text from users or other systems.
  • Which domains are declared, and whether those domains can be abused.
  • Tool visibility, input validation and authorisation on the server.
  • What the view says to the user and what it sends to the model.

The host cannot tell whether your "Confirm payment" button shows the right amount. Only you can.

What are the risks?

The specification's own threat model lists five scenarios: a malicious server delivering harmful HTML, a compromised view trying to escape the sandbox, a view attempting unauthorised tool execution, a view exfiltrating host data, and a view doing phishing or social engineering. The OWASP Top 10 for LLM Applications 2025 gives useful names for several of them: LLM01 Prompt Injection, LLM03 Supply Chain, LLM05 Improper Output Handling and LLM06 Excessive Agency. Here is how each shows up in practice.

UI that misrepresents an action

The view decides what the user sees, and the host decides what actually runs. Those can disagree. A button labelled "Preview" that issues a tools/call to a write tool, a confirmation card that displays one amount while the arguments carry another, or a table that silently hides rows the tool is about to delete: all are possible inside a perfectly sandboxed frame. The spec acknowledges this directly under "Other risks", noting that UI can still display misleading content and that hosts should clearly mark sandboxed UI boundaries.

This is the risk that matters most, because no browser control addresses it. The defence is on the host side (show the real tool name and arguments in host-owned chrome before any write) and on the server side (put the authoritative check on the server, not in the view).

Phishing inside the chat

A view looks like part of the conversation. A malicious or compromised view can render a login form, a "session expired" banner or a payment prompt that appears to come from the host. The sandbox prevents it from reading the host's cookies; it does not prevent it from asking the user to type a password into the view.

Hosts reduce this with visible boundaries and attribution, as the spec recommends. Users and administrators reduce it by only connecting servers they trust. Server authors should never collect credentials inside a view; use the host's OAuth flow, which the MCP authorisation specification and OpenAI's security guide both direct you to.

Data exfiltration through allowed domains

The CSP is an allowlist, and an allowlist is only as good as its entries. If connectDomains includes a domain where anyone can host content or receive requests (a general file host, a paste service, a shared analytics collector, a wildcard on a platform where other tenants control subdomains), then script in the view can send whatever it can see to that domain. What can it see? The tool input, the tool result, the host context, anything the user types into the view, and anything returned by tools the view is allowed to call.

frameDomains widens this further because, as OpenAI's guide notes, an embedded page runs under its own CSP. ui/open-link is another channel: a URL can carry data in its query string, which is why the MCP-UI example validates the scheme before opening and why hosts may deny or confirm link opens.

Prompt injection from UI content

Two methods give a view a voice in the model's context: ui/message and ui/update-model-context. Tool results also flow to the model as normal. If any of these carry text controlled by an attacker, for example a product review, a document title or an email subject that the view displays and then summarises back to the model, that text can steer the agent. This is the same mechanism described in our post on agentic browser risks, with the view acting as the page.

The OWASP guidance for LLM01 applies directly: treat external content as untrusted, mark it clearly, and require human approval for privileged actions. In MCP Apps terms that means your view should not forward raw third-party text into ui/update-model-context, and should send structured, minimal facts instead.

Improper output handling inside the view

Tool results are data, and some of that data came from people you do not control. If your view inserts a string from a tool result with innerHTML, you have cross-site scripting inside your own sandbox. Because the default CSP allows 'unsafe-inline' scripts, the injected code runs with the view's full abilities: it can call any app-visible tool, post chat messages and send data to your declared domains. The sandbox protects the host from your bug. It does not protect your users from it.

Excessive agency through app-visible tools

By default every tool has visibility ["model", "app"], which means any view from your server can call any of your tools. If your server exposes delete_project and your chart view never needs it, the default still lets the chart view call it. Set visibility deliberately. Tools a view needs and the model should not see get ["app"]. Tools only the model should call get ["model"]. Destructive tools should usually be ["model"] only, so they go through the host's normal tool approval path rather than a view button.

Supply chain risk in bundled front end packages

The quickstart bundles the view with Vite into a single HTML file. A typical view pulls in a UI framework, a charting library, a date library, maybe a map engine, and their transitive dependencies. All of that code is inlined into the template and runs inside your users' conversations. A compromised or vulnerable package in that tree is not a build-time annoyance; it is code running next to your tool results and your allowed domains.

Two further supply chain points are specific to how MCP servers are installed. Anthropic's guide shows Claude Desktop configuration entries that run example servers with npx -y, and notes that this fetches the latest published version each time; it recommends pinning a version, such as @modelcontextprotocol/server-map@2.0.1, if you keep the entry. And the resourceDomains list often names a public CDN, which means the view loads script you did not bundle and cannot pin by lockfile.

Resource abuse

The spec lists resource consumption as a remaining risk: a view can burn CPU and memory. Hosts should set limits. Server authors should avoid tight polling loops through tools/call, which also turn into server load and cost.

Threat summary

RiskWhere it happensWho can fix itMain control
Misleading UIView markupHost and serverHost-owned confirmation showing real tool and arguments; server-side checks
In-chat phishingView markupHost and userVisible boundaries and attribution; no credential forms in views
ExfiltrationAllowed domains, frames, linksServer and hostMinimal connectDomains; no open hosts; link confirmation
Prompt injectionui/message, ui/update-model-context, tool resultsServer and hostSend structured facts; host shows context updates; approval for writes
XSS in viewView rendering codeServerText-only rendering; no innerHTML with tool data
Excessive agencyDefault tool visibilityServerExplicit visibility on every tool
Bundled package compromiseBuild outputServerLockfiles, pinned versions, dependency scanning, self-hosted assets
Sandbox escapeHost iframe setupHostSeparate proxy origin; never same-origin with scripts
Resource abuseView scriptsHostCPU, memory and call rate limits

How do approvals and user consent work for tool calls the UI triggers?

The short answer is that the spec gives hosts the hooks and leaves the policy to them. Read the relevant lines closely:

  • The sandbox proxy rules say the host may forward view messages to the server, may block some, and may subject them to further user approval.
  • The announcement lists user consent as a security layer: hosts can require explicit approval for UI-initiated tool calls.
  • ui/message says the host may request user consent.
  • ui/open-link defines an error for "Link opening denied by user".
  • The draft's section on app-provided tools lists approval options: per app instance, per server, per tool based on annotations such as readOnlyHint, or a hybrid.

Real hosts implement this differently. Anthropic's guide says Claude asks for permission before displaying an app, with Allow or Always allow for a trusted server. OpenAI's changelog records that from 12 June 2026, ChatGPT users can choose when connected apps ask for permission: always, before making changes, or only before important changes, with per-app and organisation-level settings. It also records that for tools needing approval, ChatGPT sends the view its tool input only after approval.

Three consequences for server authors:

  1. Your tool annotations matter. If a host auto-approves read-only tools, a write tool that claims to be read-only skips the prompt. Annotate honestly. The core MCP tools specification tells clients to treat annotations as untrusted unless they come from trusted servers, so a careful host will not rely on them alone, though a host may still use them when deciding whether to prompt.
  2. Approval is not authorisation. A user clicking Allow proves the user agreed to something. It does not prove the user has the right to do it in your system. Check scopes and ownership on the server for every call, as OpenAI's guide says: verify and enforce scopes on every tool call.
  3. Design for the most permissive host. You do not control which host your server runs in, or how a given user configured it. Assume a view's tools/call may reach your server without any prompt, and make that safe.

Step by step: build a minimal MCP App, render it, and tighten it

This walkthrough follows the official quickstart, then adds the security changes the quickstart leaves out for brevity. It needs Node.js 20 or later. Commands are for macOS or Linux; adapt quoting on Windows as the quickstart notes.

Step 1: create the project and install packages

mkdir my-mcp-app && cd my-mcp-app
npm init -y
npm install @modelcontextprotocol/ext-apps \
  @modelcontextprotocol/client@^2.0.0 \
  @modelcontextprotocol/server@^2.0.0 \
  @modelcontextprotocol/node@^2.0.0 \
  @modelcontextprotocol/express@^2.0.0 \
  zod@^4.2.0 express cors
npm install -D typescript vite vite-plugin-singlefile \
  @types/express @types/cors @types/node tsx concurrently cross-env
npm pkg set type=module
npm pkg set scripts.build="tsc --noEmit && tsc -p tsconfig.server.json && cross-env INPUT=mcp-app.html vite build"
npm pkg set scripts.start='concurrently --raw "cross-env NODE_ENV=development INPUT=mcp-app.html vite build --watch" "tsx watch main.ts"'

Commit the generated package-lock.json. In CI, install with npm ci so the lockfile is the only source of truth. The ext-apps README notes that the 1.x and 2.x SDKs share the same wire protocol, so a 2.x view works in a 1.x host and the other way round.

Create tsconfig.json, tsconfig.server.json and vite.config.ts exactly as in the quickstart. The Vite config uses vite-plugin-singlefile so the whole view, scripts and styles, ends up inlined in dist/mcp-app.html. That is why the default CSP needs 'unsafe-inline', and why everything in your front end dependency tree ends up inside the template.

Step 2: register the tool and its UI resource, with explicit metadata

The quickstart's server.ts registers one tool and one resource. Here is the same shape with three additions: explicit visibility, an explicit CSP block, and an explicit border preference.

// server.ts
import {
  registerAppResource,
  registerAppTool,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/server";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";

const DIST_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "dist");

export function createServer(): McpServer {
  const server = new McpServer({ name: "Quickstart MCP App Server", version: "1.0.0" });
  const resourceUri = "ui://get-time/mcp-app.html";

  // The model calls this tool; the host renders the view for its result.
  registerAppTool(
    server,
    "get-time",
    {
      title: "Get Time",
      description: "Returns the current server time. Read only.",
      inputSchema: z.object({}),
      _meta: { ui: { resourceUri, visibility: ["model", "app"] } },
    },
    async () => {
      const time = new Date().toISOString();
      // Always return meaningful text, for hosts without MCP Apps.
      return { content: [{ type: "text", text: time }] };
    },
  );

  registerAppResource(
    server,
    resourceUri,
    resourceUri,
    { mimeType: RESOURCE_MIME_TYPE },
    async () => {
      const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8");
      return {
        contents: [
          {
            uri: resourceUri,
            mimeType: RESOURCE_MIME_TYPE,
            text: html,
            _meta: {
              ui: {
                // Declare nothing you do not need. Empty lists keep the
                // restrictive defaults: no fetch, no external scripts, no frames.
                csp: { connectDomains: [], resourceDomains: [], frameDomains: [] },
                prefersBorder: true,
              },
            },
          },
        ],
      };
    },
  );

  return server;
}

RESOURCE_MIME_TYPE resolves to text/html;profile=mcp-app. Declaring empty lists explicitly is not required, since omission gives the same restrictive default, but it documents intent and makes a later addition stand out in code review.

Step 3: start the server, bound to localhost

The quickstart's main.ts creates the Express app with createMcpExpressApp({ host: "0.0.0.0" }) and calls app.use(cors()) with no options. That is fine on a laptop for a demo and wrong anywhere else: it listens on every interface and accepts cross-origin requests from any site. For local development, change two lines:

// main.ts (changes from the quickstart)
const app = createMcpExpressApp({ host: "127.0.0.1" });
app.use(cors({ origin: ["http://localhost:8080"] })); // the basic-host test page

The MCP security best practices cover the wider set of server risks, including local server compromise, session hijacking and scope minimisation. Before you deploy a remote server, add authorisation as the MCP authorisation specification describes, and check scopes inside every tool handler.

Step 4: build the view and render text as text

The quickstart's view script already does the right thing, and it is worth noticing why:

// src/mcp-app.ts
import { App } from "@modelcontextprotocol/ext-apps";

const serverTimeEl = document.getElementById("server-time")!;
const getTimeBtn = document.getElementById("get-time-btn")!;
const app = new App({ name: "Get Time App", version: "1.0.0" });

// Register handlers before connect() so the first result is not missed.
app.ontoolresult = (result) => {
  const time = result.content?.find((c) => c.type === "text")?.text;
  serverTimeEl.textContent = time ?? "[ERROR]"; // textContent, never innerHTML
};

getTimeBtn.addEventListener("click", async () => {
  const result = await app.callServerTool({ name: "get-time", arguments: {} });
  const time = result.content?.find((c) => c.type === "text")?.text;
  serverTimeEl.textContent = time ?? "[ERROR]";
});

app.connect();

textContent treats the value as text. If you switch to a framework, use its default escaped rendering and avoid any "raw HTML" escape hatch for tool data. Then build:

npm run build
ls dist/
# mcp-app.html

Step 5: render it in the test host

The repository ships a basic test host. It is an example, not a supported product, as the README says.

# Terminal 1, in my-mcp-app
npm start
# MCP server listening on http://localhost:3001/mcp

# Terminal 2
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps
npm install
cd examples/basic-host
npm start

Open http://localhost:8080, choose get-time from the tool list, click Call Tool, and the view renders in the sandbox below. Click Get Server Time and the view calls the tool through the host.

To see the same view in a production host, follow the host's own instructions. Anthropic's guide covers connecting example servers to Claude Desktop through its configuration file and setting ui.domain for Claude. OpenAI's guide covers ChatGPT. Pin the version of any server you run through npx.

Step 6: verify the sandbox and CSP yourself

Open the browser developer tools on the test host page and inspect the frames. Check three things:

  1. The outer proxy iframe's origin differs from the host page's origin.
  2. The inner frame's effective CSP matches what you declared. With empty lists, connect-src should be 'none'.
  3. A deliberate test request from the view to an undeclared domain fails with a CSP violation in the console.

Item 3 takes one line in the view during development, such as a fetch to a domain you did not declare, then removing it. Seeing the block happen is the fastest way to learn what your policy really allows.

Step 7: add a domain only when you need one, and make it narrow

Suppose the view must fetch from your own API. Add exactly that origin:

_meta: {
  ui: {
    csp: {
      connectDomains: ["https://api.example.com"],
      resourceDomains: [], // self-host fonts and scripts in the bundle instead
      frameDomains: [],
    },
  },
},

Rules of thumb for the list:

  • Name origins you control. Avoid shared hosting, paste sites, URL shorteners and general object storage buckets.
  • Avoid wildcards. https://*.example.com is allowed in resourceDomains but means every current and future subdomain.
  • Prefer bundling libraries over loading them from a public CDN. A bundled file is pinned by your lockfile; a CDN script is pinned only by its URL.
  • Do not add frameDomains unless you must embed another page, and remember that page runs under its own CSP.

Step 8: lock down tool visibility

List every tool and decide who may call it.

// Model only: the agent may call it; views may not.
registerAppTool(server, "delete-report", {
  title: "Delete report",
  description: "Permanently deletes a report. Destructive.",
  inputSchema: z.object({ reportId: z.string().uuid() }),
  annotations: { destructiveHint: true },
  _meta: { ui: { visibility: ["model"] } },
}, handler);

// App only: the view may refresh data; the model never sees this tool.
registerAppTool(server, "refresh-chart-data", {
  title: "Refresh chart data",
  description: "Returns the latest data points for the open chart. Read only.",
  inputSchema: z.object({ chartId: z.string().uuid() }),
  annotations: { readOnlyHint: true },
  _meta: { ui: { resourceUri: "ui://charts/view.html", visibility: ["app"] } },
}, refreshHandler);

Check the field names against the SDK version you install; the visibility semantics and defaults come from the stable spec, and annotations such as readOnlyHint and destructiveHint come from the core MCP tool definition. Whatever the host does with those hints, your handler still validates inputs and checks the caller's rights.

Step 9: scan what ships

Your view's dependency tree is part of the product. Run your dependency scanner of choice over the repository and the built output before you publish a server version, and fail the build on known vulnerabilities in packages that end up in dist/. With npm:

npm ci
npm ls --all > deps.txt          # what is actually installed
npm audit --omit=dev             # known issues in runtime dependencies

With the Safeguard CLI, safeguard scan --dir . scans the repository and writes JSON results under ./output. Development tools like Vite run at build time; runtime packages end up in the view. Both matter, but the runtime ones run in your users' chats.

Plain text tool results, MCP Apps, AG-UI and a separate web app compared

These four approaches are often confused because they all put something on a screen. They differ in who wrote the code that runs and who controls the frame around it.

Plain text tool resultsMCP AppsAG-UISeparate web app
What it isCore MCP: tools return content blocksMCP extension: server ships an HTML viewEvent protocol from an agent to an app's own front endYour own site, linked from the chat
Where UI code comes fromHost onlyMCP server authorApplication developerYour team
Where it runsHost renders textSandboxed iframe inside the hostThe application's own pageIts own origin in a browser tab
What the host controlsEverything visibleFrame, CSP, routing, approvalsNothing; the app is the hostNothing
Network accessNone from contentOnly declared domainsWhatever the app allowsWhatever the site allows
Can trigger tool callsOnly the modelView via host tools/callApp decides on agent eventsOnly through your own backend
Main risksPrompt injection in text; misleading outputMisleading UI, exfiltration via allowed domains, bundled package risk, injected contextUntrusted events rendered unsafely, spoofed approvalsStandard web risks; no in-chat context
Best forAnswers, data, short summariesCharts, forms, maps, review screens inside a chatBuilding your own agent product's interfaceLong, complex workflows and account settings

If your interaction is a sentence, stay with text. If someone needs to inspect, compare, edit or confirm structured data without leaving the conversation, MCP Apps fits. If you are building your own agent application end to end, an event protocol to your own front end gives you more control. If the task needs its own navigation and settings, send people to a real web app.

Common mistakes

  • Copying the quickstart's network settings to production. Binding to 0.0.0.0 with permissive CORS is a demo setting.
  • Leaving every tool at the default visibility. The default ["model", "app"] gives every view from your server access to every tool, including destructive ones.
  • Treating the sandbox as content security. It isolates the host from the view. It does not stop the view from showing false information or sending allowed data out.
  • Rendering tool results with innerHTML. With 'unsafe-inline' allowed, injected script runs with your view's full abilities.
  • Declaring a broad domain to make something work. A wildcard or a shared host in connectDomains turns the CSP into a formality.
  • Adding frameDomains without reading what it implies. An embedded page carries its own policy, not yours.
  • Forwarding raw third-party text to the model. Using ui/update-model-context or ui/message as a pipe for external content invites prompt injection.
  • Acting on partial tool input. The spec says not to rely on tool-input-partial for critical operations.
  • Using authorisation in the view. Hiding a button is not access control. Check rights on the server for every call.
  • Running unpinned servers with npx. Each launch can fetch a different version.
  • Following an old tutorial. text/html+mcp and the flat ui/resourceUri key are from the proposal stage; use text/html;profile=mcp-app and _meta.ui.resourceUri.
  • Forgetting the text fallback. Hosts without MCP Apps, and the model itself, still need meaningful text content.

FAQ

What is an MCP App in simple terms?

It is a small web page that an MCP server supplies along with a tool. When the tool runs in a host that supports the extension, the host shows the page inside the conversation, in a locked-down frame, and the page can ask the host to call tools or post messages on the user's behalf.

Is MCP Apps the same as MCP-UI?

No, but it grew out of it. MCP-UI was the community project that pioneered UI resources over MCP. MCP Apps is the official extension that standardised those patterns with input from OpenAI's Apps SDK. MCP-UI's packages now implement the MCP Apps spec.

Which hosts support MCP Apps?

The ext-apps README lists ChatGPT, Claude, VS Code, Goose, Postman and several developer tools, and points to the MCP clients page for the full list. Support and behaviour vary by host, so check each host's documentation for details such as the ui.domain format and approval prompts.

Is MCP Apps secure?

The design has sound foundations: mandatory iframe sandboxing, a required CSP built from declared domains, a restrictive default, auditable JSON-RPC messages and templates that hosts can review before use. It does not make an untrusted server trustworthy. Misleading UI, exfiltration through allowed domains and prompt injection through context updates remain, and the spec says so.

Can an MCP App read my other conversations or cookies?

A correctly sandboxed view runs in a separate origin and cannot read the host page's DOM or cookies. It sees what the host sends it (tool input, tool result, host context) and what the user types into it. The double iframe rule for web hosts exists to keep it that way.

Can an MCP App call tools without asking me?

It can request tool calls through the host. Whether you see a prompt depends on the host and your settings. Hosts may require approval for view-initiated calls, and some let you choose how often to be asked. Server authors should assume the call may arrive without a prompt and enforce rights on the server.

What is the ui:// scheme?

A URI scheme reserved by the extension for UI templates. It is not fetched over the network. The host reads ui:// resources from the MCP server with resources/read, the same way it reads any other MCP resource.

How do I restrict which websites an MCP App can contact?

Declare only the origins you need in _meta.ui.csp.connectDomains, and keep resourceDomains and frameDomains empty unless you must load or embed something external. The host must block every undeclared domain. With nothing declared, the view cannot make network connections at all.

Do I need the SDK to build an MCP App?

No. The spec shows that a view can send JSON-RPC over postMessage by hand. The SDK saves time, handles host detection and transport, and keeps you aligned with spec changes, so most teams should use it.

How is MCP Apps different from AG-UI?

MCP Apps lets a tool provider ship UI into someone else's host, inside a sandbox the host controls. AG-UI is an event protocol between an agent and an application's own interface, where the application developer writes and controls the UI.

Where Safeguard fits

Safeguard does not render MCP Apps, inspect view HTML, or police what a view displays; those are host and server design decisions covered above. The fit is in the code you ship. An MCP App is an MCP server plus a bundled front end, both built from open source packages, and the view's dependencies run inside your users' conversations. Safeguard scans repositories, container images and SBOM files for known vulnerabilities in those dependencies, shows which projects carry a given component and version, applies reachability analysis so the paths your code calls rank first, and evaluates policies and gates before a release goes out. Safeguard also publishes its own MCP server, so you can connect an MCP host to it and choose exactly which tools the assistant may use.

Two honest limits. Safeguard does not judge whether a declared domain is safe or whether a view's buttons tell the truth; that needs code review and host-side controls. And the security of a third-party host's sandbox is the host's responsibility, not something a dependency scanner can change.

An MCP App bundles a full front end tree into one HTML file that runs inside your users' chat, and the server behind it is usually a Node or Python package with its own dependencies. When one of those packages has a known vulnerability, the fastest fix is the one that reaches your build without waiting on anyone else.

Fix it without waiting for upstream

Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.

1. Autonomous remediation: pull requests, not tickets

When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.

2. Gold Open Source: the safe version in seconds, free

gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.

3. Gold Registry: start from components that are already fixed

registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.

When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.

Your situationWhat to use
A fixed version existsAutonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version
You need to know today whether you are affectedGold Open Source lookup and "Am I affected?" checker, free and without login
You want vulnerable versions to stop shippingThe Gold scan GitHub Action in CI, plus Safeguard policies and gates
Upstream has no fix yet, or will not ship oneA custom Gold request; fork and patch (early access)
You want to start clean on new projectsPull hardened components and images from Gold Registry

One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.

See how Safeguard would help

None of these demos shows an MCP App view. Each shows a Safeguard surface built on the same principles this post recommends: a person chooses which tools an assistant may use, and nothing with side effects runs without approval. You can download Safeguard and follow the CLI docs to try them.

This demo connects Claude to the Safeguard MCP server with OAuth and lets you choose which tools Claude can use. It is the host side of the visibility question from this post: a human sets the allowlist of tools before any call happens, rather than leaving every tool open by default.

Click through it at your own pace, or open it full screen.

This is a real CLI session: asked to fix the lodash CVE in a repository, the CLI reads the finding and advisory, asks before each command, edits package.json to lodash 4.17.21, runs npm install and npm test with approval, prints a summary and pushes nothing. The same flow applies to a vulnerable package in an MCP App's view bundle or server, and /permissions shows how the approval modes change what runs without a prompt.

Click through it at your own pace, or open it full screen.

This demo sets up the Safeguard runner on your own machine, approves it with its device code, then scans from the terminal. For an MCP App project, that is how you would scan the server and the view's dependency tree locally before publishing a new server version.

Click through it at your own pace, or open it full screen.

Quick check

Your web host serves the sandbox proxy from the same origin as the host page, with allow-scripts and allow-same-origin set. What is wrong?

The spec requires the host and the sandbox proxy to have different origins. The WHATWG HTML standard warns that a frame with both allow-scripts and allow-same-origin on the parent's origin can remove its own sandbox and reload. Move the proxy to a separate origin, such as a dedicated sandbox subdomain.

A chart view only needs to read data, but the server's delete tool has no visibility setting. Can the view call it?

Yes. Visibility defaults to model and app, so any view from the same server can request it through the host. Set the delete tool's visibility to model only, give the chart a separate read-only tool with app visibility, and check rights on the server for every call regardless.

Sources

Last reviewed: 24 October 2026. MCP Apps is evolving, and its draft specification adds features beyond the stable 2026-01-26 text; check the specification and your host's documentation before you rely on a specific behaviour.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.