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 typetext/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:
- UI resources. Templates, declared up front, under the
ui://scheme. - Tool to UI linkage. A tool names the template that should render its results.
- 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.
| Date | Event | Source |
|---|---|---|
| 21 Nov 2025 | MCP Apps proposal published as SEP-1865 | MCP blog, SEP-1865 pull request |
| 26 Jan 2026 | Specification marked stable; VS Code announces support | ext-apps stable spec, VS Code blog |
| 28 Jan 2026 | SEP-1865 pull request merged into the main MCP repository | GitHub pull request metadata |
| 22 Feb 2026 | ChatGPT listed as fully compatible with the MCP Apps spec | OpenAI plugin UI changelog |
| 27 May 2026 | ChatGPT delivers tool input to views only after approval for tools that need it | OpenAI plugin UI changelog |
| 12 Jun 2026 | ChatGPT adds per-app permission controls for when apps ask before acting | OpenAI plugin UI changelog |
| 28 Jul 2026 | Core MCP 2026-07-28 released with a formal extensions framework that includes MCP Apps | MCP 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 intoconnectDomains,resourceDomains,frameDomainsandbaseUriDomains.permissions: requests for camera, microphone, geolocation or clipboard write. The host may honour them through the iframeallowattribute; 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:
| Method | Purpose |
|---|---|
tools/call | Ask the host to run a tool on the server |
resources/read | Ask the host to read a resource |
notifications/message | Send a log message to the host |
ping | Health check |
ui/initialize, then ui/notifications/initialized | Handshake, including the view's capabilities and display modes |
Then there are methods specific to MCP Apps:
| Method | Direction | What it does |
|---|---|---|
ui/open-link | View to host | Ask the host to open a URL in the user's browser |
ui/message | View to host | Add a message to the conversation, usually with role user |
ui/request-display-mode | View to host | Ask for inline, fullscreen or picture-in-picture |
ui/update-model-context | View to host | Replace the context the view contributes to future model turns |
ui/notifications/size-changed | View to host | Report the view's rendered size |
ui/notifications/tool-input | Host to view | Deliver the complete tool arguments, at most once |
ui/notifications/tool-input-partial | Host to view | Stream best-effort partial arguments while the model is still writing them |
ui/notifications/tool-result | Host to view | Deliver the finished CallToolResult |
ui/notifications/tool-cancelled | Host to view | Tell the view the call was cancelled, with a reason |
ui/notifications/host-context-changed | Host to view | Theme, display mode, size or other context changed |
ui/resource-teardown | Host to view | Warn 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:
- The host and the sandbox proxy must have different origins.
- The proxy must run with
allow-scriptsandallow-same-origin. - The proxy signals
ui/notifications/sandbox-proxy-ready, the host replies withui/notifications/sandbox-resource-readycarrying the HTML, CSP and permissions. - The proxy forwards all other messages between host and view, and should not invent requests of its own.
- 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:
| Field | CSP directives | Default when omitted |
|---|---|---|
connectDomains | connect-src (fetch, XHR, WebSocket) | No external connections |
resourceDomains | script-src, style-src, img-src, font-src, media-src | No external static resources |
frameDomains | frame-src | frame-src 'none' |
baseUriDomains | base-uri | base-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
| Risk | Where it happens | Who can fix it | Main control |
|---|---|---|---|
| Misleading UI | View markup | Host and server | Host-owned confirmation showing real tool and arguments; server-side checks |
| In-chat phishing | View markup | Host and user | Visible boundaries and attribution; no credential forms in views |
| Exfiltration | Allowed domains, frames, links | Server and host | Minimal connectDomains; no open hosts; link confirmation |
| Prompt injection | ui/message, ui/update-model-context, tool results | Server and host | Send structured facts; host shows context updates; approval for writes |
| XSS in view | View rendering code | Server | Text-only rendering; no innerHTML with tool data |
| Excessive agency | Default tool visibility | Server | Explicit visibility on every tool |
| Bundled package compromise | Build output | Server | Lockfiles, pinned versions, dependency scanning, self-hosted assets |
| Sandbox escape | Host iframe setup | Host | Separate proxy origin; never same-origin with scripts |
| Resource abuse | View scripts | Host | CPU, 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/messagesays the host may request user consent.ui/open-linkdefines 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:
- 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.
- 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.
- 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/callmay 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:
- The outer proxy iframe's origin differs from the host page's origin.
- The inner frame's effective CSP matches what you declared. With empty lists,
connect-srcshould be'none'. - 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.comis allowed inresourceDomainsbut 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
frameDomainsunless 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 results | MCP Apps | AG-UI | Separate web app | |
|---|---|---|---|---|
| What it is | Core MCP: tools return content blocks | MCP extension: server ships an HTML view | Event protocol from an agent to an app's own front end | Your own site, linked from the chat |
| Where UI code comes from | Host only | MCP server author | Application developer | Your team |
| Where it runs | Host renders text | Sandboxed iframe inside the host | The application's own page | Its own origin in a browser tab |
| What the host controls | Everything visible | Frame, CSP, routing, approvals | Nothing; the app is the host | Nothing |
| Network access | None from content | Only declared domains | Whatever the app allows | Whatever the site allows |
| Can trigger tool calls | Only the model | View via host tools/call | App decides on agent events | Only through your own backend |
| Main risks | Prompt injection in text; misleading output | Misleading UI, exfiltration via allowed domains, bundled package risk, injected context | Untrusted events rendered unsafely, spoofed approvals | Standard web risks; no in-chat context |
| Best for | Answers, data, short summaries | Charts, forms, maps, review screens inside a chat | Building your own agent product's interface | Long, 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.0with 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
connectDomainsturns 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-contextorui/messageas a pipe for external content invites prompt injection. - Acting on partial tool input. The spec says not to rely on
tool-input-partialfor 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+mcpand the flatui/resourceUrikey are from the proposal stage; usetext/html;profile=mcp-appand_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 situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull 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
- MCP blog: MCP Apps, Extending servers with interactive user interfaces (21 Nov 2025)
- SEP-1865: MCP Apps pull request, modelcontextprotocol repository
- MCP Apps extension repository (ext-apps)
- MCP Apps specification, stable 2026-01-26
- MCP Apps specification, draft
- MCP Apps quickstart: Build your first MCP App
- MCP-UI project
- Model Context Protocol specification
- MCP security best practices
- MCP specification: Tools (2025-11-25)
- MCP blog: The 2026-07-28 Specification
- OpenAI: Apps SDK overview
- OpenAI: Add UI to your MCP server (MCP Apps in ChatGPT)
- OpenAI: Security and privacy for plugins
- OpenAI: Plugin UI changelog
- Anthropic: Get started with MCP Apps in Claude
- VS Code blog: MCP Apps support in VS Code (26 Jan 2026)
- WHATWG HTML: The iframe element and the sandbox attribute
- WHATWG HTML: Cross-document messaging
- W3C: Content Security Policy Level 3 (Working Draft)
- OWASP Cheat Sheet: Content Security Policy
- OWASP Top 10 for LLM Applications 2025
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.