MCP Apps
MCP Apps enable servers to return interactive HTML interfaces (data visualizations, forms, dashboards) that render directly within AI conversations.
Why MCP Apps
Traditional text responses have limitations. Sometimes users need to interact with data, not just read about it. MCP Apps offer key advantages over standalone web applications:
Context Preservation
The app lives inside the conversation. Users don’t switch tabs, lose their place, or wonder which chat thread had that dashboard. The UI is right there, alongside the discussion that led to it.
Bidirectional Data Flow
Your app can call any tool on the MCP server, and the host can push fresh results to your app. This is achieved through existing MCP patterns without requiring separate APIs, authentication, or state management.
Host Integration
The app can delegate actions to the host, which then invokes the capabilities and tools the user has already connected. Instead of every app implementing direct integrations, the app requests an outcome (like “schedule this meeting”), and the host routes it through the user’s existing connected capabilities.
Security Guarantees
MCP Apps run in a sandboxed iframe controlled by the host. They can’t access the parent page, steal cookies, or escape their container. This means hosts can safely render third-party apps without trusting the server author completely.
sequenceDiagram
participant User
participant Agent
participant App as MCP App iframe
participant Server as MCP Server
User->>Agent: "show me analytics"
Note over User,App: Interactive app rendered in chat
Agent->>Server: tools/call
Server-->>Agent: tool input/result
Agent-->>App: tool result pushed to app
User->>App: user interacts
App->>Agent: tools/call request
Agent->>Server: tools/call (forwarded)
Server-->>Agent: fresh data
Agent-->>App: fresh data
Note over User,App: App updates with new data
App-->>Agent: context update
When to Use MCP Apps
| Use Case | Description |
|---|---|
| Complex Data Exploration | Interactive maps, drill-down charts, toggleable metrics |
| Multi-Option Configuration | Forms with validation, defaults, interdependent choices |
| Rich Media Viewing | PDF review, 3D models, image previews with pan/zoom |
| Real-Time Monitoring | Live dashboards, metrics, logs, system status |
| Multi-Step Workflows | Expense reports, code reviews, issue triage |
How MCP Apps Work
Traditional MCP tools return text, images, or structured data. MCP Apps extend this pattern by allowing tools to declare a reference to an interactive UI in their description that the host renders in place.
The core pattern combines two MCP primitives:
- A tool that declares a UI resource via
_meta.ui.resourceUri - A UI resource that renders data as an interactive HTML interface
Workflow
UI Preloading: The tool description includes a
_meta.ui.resourceUrifield pointing to aui://resource. The host can preload this resource before the tool is called.Resource Fetch: The host fetches the UI resource from the server. This resource contains an HTML page, often bundled with its JavaScript and CSS.
Sandboxed Rendering: Web hosts render the HTML inside a sandboxed iframe within the conversation. The sandbox restricts the app’s access to the parent page.
Bidirectional Communication: The app and host communicate through a JSON-RPC protocol. The app can request tool calls, send messages, update the model’s context, and receive data from the host.
Quick Start
Prerequisites
- Node.js 18 or higher
- Familiarity with MCP tools and resources
- Experience with MCP TypeScript SDK is helpful
Using an AI Coding Agent
The fastest way to create an MCP App is using an AI coding agent with the MCP Apps skill.
Install the Skill
For Claude Code:
/plugin marketplace add modelcontextprotocol/ext-apps
/plugin install mcp-apps@modelcontextprotocol-ext-appsOr use the Vercel Skills CLI:
npx skills add modelcontextprotocol/ext-appsCreate Your App
Ask your AI coding agent:
Create an MCP App that displays a color pickerThe agent will scaffold a complete project with server, UI, and configuration files.
Run Your App
npm install && npm run build && npm run serveManual Setup
If you prefer manual setup, follow these steps:
Project Structure
my-mcp-app/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── server.ts # MCP server with tool + resource
├── mcp-app.html # UI entry point
└── src/
└── mcp-app.ts # UI logicInstall Dependencies
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsxConfigure the Project
package.json:
{
"type": "module",
"scripts": {
"build": "INPUT=mcp-app.html vite build",
"serve": "npx tsx server.ts"
}
}tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"outDir": "dist"
},
"include": ["*.ts", "src/**/*.ts"]
}vite.config.ts:
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: process.env.INPUT,
},
},
});Building an MCP App
Let’s build a simple app that displays the current server time.
Server Implementation
The server needs to register a tool with _meta.ui.resourceUri and serve the bundled HTML as a resource.
// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
const server = new McpServer({
name: "My MCP App Server",
version: "1.0.0",
});
// ui:// scheme tells hosts this is an MCP App resource
const resourceUri = "ui://get-time/mcp-app.html";
// Register tool with UI metadata
registerAppTool(
server,
"get-time",
{
title: "Get Time",
description: "Returns the current server time.",
inputSchema: {},
_meta: { ui: { resourceUri } },
},
async () => {
const time = new Date().toISOString();
return {
content: [{ type: "text", text: time }],
};
},
);
// Register resource to serve bundled HTML
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async () => {
const html = await fs.readFile(
path.join(import.meta.dirname, "dist", "mcp-app.html"),
"utf-8",
);
return {
contents: [
{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html },
],
};
},
);
// Expose MCP server over HTTP
const expressApp = express();
expressApp.use(cors());
expressApp.use(express.json());
expressApp.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
expressApp.listen(3001, () => {
console.log("Server listening on http://localhost:3001/mcp");
});UI Implementation
mcp-app.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Get Time App</title>
</head>
<body>
<p>
<strong>Server Time:</strong>
<code id="server-time">Loading...</code>
</p>
<button id="get-time-btn">Get Server Time</button>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>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" });
// Establish communication with the host
app.connect();
// Handle initial tool result
app.ontoolresult = (result) => {
const time = result.content?.find((c) => c.type === "text")?.text;
serverTimeEl.textContent = time ?? "[ERROR]";
};
// Call tool on button click
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]";
});Testing Your App
Build and Run
npm run build && npm run serveYour server will be available at http://localhost:3001/mcp.
Testing with Claude
For local development, expose your server to the internet using cloudflared:
npx cloudflared tunnel --url http://localhost:3001Copy the generated URL and add it as a custom connector in Claude:
- Click your profile → Settings → Connectors
- Click “Add custom connector”
- Paste the URL
Testing with Basic Host
The ext-apps repository includes a test host:
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm startNavigate to http://localhost:8080 to test your app.
Security Model
MCP Apps run in a sandboxed iframe, providing strong isolation from the host application. The sandbox prevents:
- Access to the parent window’s DOM
- Reading the host’s cookies or local storage
- Navigating the parent page
- Executing scripts in the parent context
All communication goes through the postMessage API, which the App class abstracts. The host controls which capabilities your app can access.
Framework Support
MCP Apps use standard web primitives (postMessage, iframe). You can use any framework:
- React, Vue, Svelte, Preact, Solid
- Vanilla JavaScript
The App class is a convenience wrapper, not a requirement. You can implement the postMessage protocol directly if preferred.
Client Support
Currently supported by:
- Claude (web and Desktop)
- Visual Studio Code (Insiders)
- Goose, Postman, MCPJam
For building MCP client support:
- Use
@mcp-ui/clientfor React components - Build on App Bridge from the SDK for iframe rendering and message passing