MCP Apps
An official MCP extension that enables tools to return interactive UI components—dashboards, forms, visualizations—that render directly in conversations instead of plain text.
What are MCP Apps?
Traditional MCP tools return structured data that models summarize as text. MCP Apps close "the context gap between what tools can do and what users can see" by enabling tools to return rich, interactive UI components that users can directly manipulate.
Interactive Dashboards
Charts, tables, and visualizations users can filter, sort, and explore
Configuration Wizards
Multi-step forms with validation, dependent fields, and previews
Document Viewers
PDFs, images, and documents with annotation and highlighting
Real-time Monitors
Live updating displays for system metrics, logs, and status
Why MCP Apps Matter
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ MCP CLIENT (Claude, VS Code) │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Conversation │ │
│ │ │ │
│ │ User: "Show me Q4 sales by region" │ │
│ │ │ │
│ │ Assistant: Analyzing Q4 sales data... │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ SANDBOXED IFRAME (ui://) │ │ │
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ Q4 Sales by Region │ │ │ │
│ │ │ │ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ │ │ │
│ │ │ │ │ NA │ │ EU │ │APAC│ │LATAM│ [Export] [Filter]│ │ │ │
│ │ │ │ │1.2M│ │0.6M│ │0.3M│ │0.2M│ │ │ │ │
│ │ │ │ └────┘ └────┘ └────┘ └────┘ │ │ │ │
│ │ │ │ Click any region to drill down │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ │ ▲ │ │ │ │
│ │ │ │ JSON-RPC│ │ │ │
│ │ │ └─────────┘ │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
│ Tool response with _meta.ui
│
┌─────────────────────────────────────────────────────────────────┐
│ MCP SERVER │
│ │
│ Tool: "analyze_sales" │
│ → Queries database │
│ → Returns data + UI metadata │
│ │
│ Resource: "ui://sales-dashboard" │
│ → Serves bundled HTML/JS application │
└─────────────────────────────────────────────────────────────────┘ How It Works
- 1 Tool returns standard content plus
_meta.uiwith aresourceUrianddata - 2 Host fetches the UI resource (bundled HTML/JS) via the
ui://scheme - 3 Host renders the UI in a sandboxed iframe with restricted permissions
- 4 UI receives data and can communicate back via JSON-RPC (update context, call tools)
Basic Implementation
Tools declare UI components by including _meta.ui in their response:
# MCP Apps: Returning Interactive UI from Tools
# Traditional MCP tool returns text
function searchData(query):
results = database.query(query)
return { text: "Found " + results.count + " records" }
# User sees: "Found 42 records"
# Context gap: User can't explore the data
# MCP Apps tool returns UI metadata
function searchDataWithUI(query):
results = database.query(query)
return {
content: [{ type: "text", text: "Found " + results.count + " records" }],
_meta: {
ui: {
# Reference to UI resource
resourceUri: "ui://data-explorer",
# Data passed to the UI
data: {
results: results,
query: query,
interactive: true,
allowExport: true
}
}
}
}
# User sees: Interactive dashboard with filtering, sorting, export
# UI resource served via ui:// scheme
resource "ui://data-explorer":
type: "text/html"
content: bundledReactApp # HTML/JS bundle
sandbox: ["display", "user-input"] # Limited permissions import { App } from "@modelcontextprotocol/ext-apps";
// Client-side: Connect to MCP App host
const app = new App();
await app.connect();
// Listen for tool results with UI components
app.ontoolresult = (result) => {
if (result._meta?.ui) {
// Host renders UI in sandboxed iframe
console.log("UI component available:", result._meta.ui.resourceUri);
}
};
// Server-side: Define tool that returns UI
server.tool("analyze_sales", {
description: "Analyze sales data with interactive visualization",
inputSchema: {
type: "object",
properties: {
region: { type: "string" },
period: { type: "string", enum: ["Q1", "Q2", "Q3", "Q4", "YTD"] }
},
required: ["period"]
}
}, async (args) => {
const data = await salesDB.query(args);
return {
content: [{
type: "text",
text: "Sales analysis for " + args.period + ": " + data.total + " revenue"
}],
_meta: {
ui: {
resourceUri: "ui://sales-dashboard",
data: {
records: data.records,
summary: data.summary,
chartConfig: {
type: "bar",
groupBy: "region",
metric: "revenue"
}
}
}
}
};
});
// Register UI resource
server.resource("ui://sales-dashboard", {
mimeType: "text/html",
read: async () => ({
contents: salesDashboardBundle // Your bundled HTML/JS app
})
}); from mcp.server import Server
from mcp.types import Tool, TextContent, Resource
from pathlib import Path
server = Server("analytics-server")
# Tool that returns interactive UI
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "analyze_sales":
data = await sales_db.query(
region=arguments.get("region"),
period=arguments["period"]
)
return {
"content": [
TextContent(
type="text",
text=f"Sales analysis for {arguments['period']}: {data['total']:,.2f} revenue"
)
],
"_meta": {
"ui": {
"resourceUri": "ui://sales-dashboard",
"data": {
"records": data["records"],
"summary": data["summary"],
"chartConfig": {
"type": "bar",
"groupBy": "region",
"metric": "revenue"
}
}
}
}
}
@server.list_tools()
async def list_tools():
return [
Tool(
name="analyze_sales",
description="Analyze sales data with interactive visualization",
inputSchema={
"type": "object",
"properties": {
"region": {"type": "string"},
"period": {
"type": "string",
"enum": ["Q1", "Q2", "Q3", "Q4", "YTD"]
}
},
"required": ["period"]
}
)
]
# UI resource - serves bundled HTML/JS application
@server.read_resource()
async def read_resource(uri: str):
if uri == "ui://sales-dashboard":
bundle_path = Path(__file__).parent / "ui" / "sales-dashboard.html"
return {
"uri": uri,
"mimeType": "text/html",
"text": bundle_path.read_text()
}
@server.list_resources()
async def list_resources():
return [
Resource(
uri="ui://sales-dashboard",
name="Sales Dashboard",
description="Interactive sales data visualization",
mimeType="text/html"
)
] Building UI Resources
UI resources are HTML/JavaScript bundles served via the ui:// scheme. Use the @modelcontextprotocol/ext-apps package to communicate with the host:
import { App } from "@modelcontextprotocol/ext-apps";
// Initialize the MCP App client
const app = new App();
// Handle incoming data from server
app.ondata = (data) => {
// data contains what was passed in _meta.ui.data
console.log("Received data:", data);
renderVisualization(data);
};
// Connect to the host (Claude, VS Code, etc.)
await app.connect();
// Update model context based on user interactions
// This adds information to the conversation
async function handleUserSelection(selectedItem) {
await app.updateModelContext({
content: [{
type: "text",
text: "User selected: " + selectedItem.name
}]
});
}
// Call server tools from within the UI
async function refreshData(filters) {
const result = await app.callServerTool({
name: "fetch_sales_data",
arguments: {
filters: filters,
format: "detailed"
}
});
// Result may include new UI data
if (result._meta?.ui) {
renderVisualization(result._meta.ui.data);
}
}
// Request user confirmation for actions
async function confirmDelete(itemId) {
const confirmed = await app.requestUserConfirmation({
title: "Delete Item",
message: "Are you sure you want to delete item " + itemId + "?",
confirmLabel: "Delete",
cancelLabel: "Cancel"
});
if (confirmed) {
await app.callServerTool({
name: "delete_item",
arguments: { id: itemId }
});
}
} API Reference
| Method | Direction | Purpose |
|---|---|---|
app.connect() | - | Initialize connection to host |
app.ondata | Server → UI | Receive initial data from tool result |
app.onupdate | Server → UI | Receive pushed updates from server |
app.updateModelContext() | UI → Model | Add information to conversation context |
app.callServerTool() | UI → Server | Invoke another tool on the server |
app.requestUserConfirmation() | UI → Host | Show confirmation dialog to user |
app.downloadFile() | UI → Host | Trigger file download |
Use Cases
# Multi-step configuration with dependent fields
tool "configure_deployment":
return {
content: [{ text: "Starting deployment configuration..." }],
_meta: {
ui: {
resourceUri: "ui://config-wizard",
data: {
steps: [
{
id: "environment",
title: "Select Environment",
type: "select",
options: ["development", "staging", "production"]
},
{
id: "region",
title: "Select Region",
type: "select",
dependsOn: "environment",
optionsFrom: "getRegions(environment)"
},
{
id: "resources",
title: "Configure Resources",
type: "form",
fields: [
{ name: "cpu", type: "slider", min: 1, max: 16 },
{ name: "memory", type: "slider", min: 1, max: 64 },
{ name: "replicas", type: "number", min: 1, max: 10 }
]
},
{
id: "review",
title: "Review & Deploy",
type: "summary"
}
]
}
}
}
} # PDF viewer with annotation capabilities
tool "review_document":
document = storage.getDocument(documentId)
return {
content: [{ text: "Opening document for review..." }],
_meta: {
ui: {
resourceUri: "ui://document-viewer",
data: {
documentUrl: document.url,
documentType: "pdf",
features: {
highlighting: true,
annotations: true,
comments: true,
search: true
},
existingAnnotations: document.annotations,
# Callback when user adds annotation
onAnnotate: {
tool: "save_annotation",
args: ["documentId", "annotation"]
}
}
}
}
} # Live monitoring dashboard with auto-refresh
tool "monitor_system":
return {
content: [{ text: "Opening system monitor..." }],
_meta: {
ui: {
resourceUri: "ui://system-monitor",
data: {
refreshInterval: 5000, # 5 seconds
metrics: [
{ id: "cpu", label: "CPU Usage", type: "gauge" },
{ id: "memory", label: "Memory", type: "gauge" },
{ id: "requests", label: "Requests/sec", type: "line" },
{ id: "errors", label: "Error Rate", type: "line" }
],
alerts: [
{ metric: "cpu", threshold: 80, severity: "warning" },
{ metric: "errors", threshold: 5, severity: "critical" }
],
# Endpoint for fetching updates
dataSource: {
tool: "get_metrics",
pollInterval: 5000
}
}
}
}
} Bidirectional Communication
MCP Apps support full bidirectional communication between the UI and server via JSON-RPC:
┌──────────────────┐ ┌──────────────────┐
│ UI (iframe) │ │ MCP Server │
└────────┬─────────┘ └────────┬─────────┘
│ │
│◄────── ondata ─────────────│ Initial data
│ │
│◄────── onupdate ───────────│ Real-time updates
│ │
│─── updateModelContext ────►│ Add to conversation
│ │
│─── callServerTool ────────►│ Invoke tool
│◄────── result ─────────────│
│ │
│─── requestConfirmation ───►│ User dialog
│◄────── confirmed ──────────│
│ │ // Bidirectional communication between UI and server
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App();
// === RECEIVING DATA FROM SERVER ===
// Initial data when UI loads
app.ondata = (data) => {
initializeUI(data);
};
// Server pushes updates (e.g., real-time data)
app.onupdate = (update) => {
applyUpdate(update);
};
// === SENDING DATA TO SERVER ===
// Update model context (adds to conversation)
await app.updateModelContext({
content: [{
type: "text",
text: "User filtered results to show only Q4 data"
}]
});
// Call a tool on the server
const result = await app.callServerTool({
name: "export_data",
arguments: {
format: "csv",
filters: currentFilters
}
});
// Request file download
await app.downloadFile({
data: result.content,
filename: "export.csv",
mimeType: "text/csv"
});
// === USER INTERACTIONS ===
// Request confirmation dialog
const proceed = await app.requestUserConfirmation({
title: "Confirm Action",
message: "This will modify 47 records. Continue?",
confirmLabel: "Modify",
cancelLabel: "Cancel",
destructive: true
});
// Show toast notification
await app.showNotification({
type: "success",
message: "Export completed successfully",
duration: 3000
});
await app.connect(); from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("interactive-server")
# Tool that handles UI callbacks
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "export_data":
# Handle export request from UI
data = await db.query(arguments.get("filters", {}))
if arguments["format"] == "csv":
content = to_csv(data)
else:
content = to_json(data)
return {
"content": [
TextContent(type="text", text=content)
]
}
if name == "save_annotation":
# Handle annotation from document viewer UI
await db.save_annotation(
document_id=arguments["documentId"],
annotation=arguments["annotation"]
)
return {
"content": [
TextContent(
type="text",
text=f"Saved annotation on document {arguments['documentId']}"
)
]
}
if name == "get_metrics":
# Real-time metrics for monitoring UI
metrics = await monitoring.get_current_metrics()
return {
"content": [
TextContent(type="text", text="Metrics updated")
],
"_meta": {
"ui": {
"update": { # Push update to existing UI
"metrics": metrics,
"timestamp": time.time()
}
}
}
} Security Model
| Layer | Protection |
|---|---|
| Iframe Sandboxing | UI runs in isolated context with restricted permissions (no direct DOM access to host) |
| Content Security Policy | Strict CSP prevents XSS, limits external resource loading |
| JSON-RPC Boundary | All communication is auditable structured messages, not arbitrary code execution |
| Template Review | UI bundles can be audited before deployment; no dynamic code injection |
| User Consent | Tool invocation still requires explicit user approval |
| Origin Isolation | Each UI resource runs in its own origin, preventing cross-UI attacks |
Bundle Security
Data Validation
_meta.ui.data as it may be visible in network logs.
Client Support
MCP Apps is currently supported in:
| Client | Status | Notes |
|---|---|---|
| Claude (Web) | Available | Full support for all UI features |
| Claude (Desktop) | Available | Full support for all UI features |
| Goose | Available | Full support |
| VS Code Insiders | Available | Via GitHub Copilot MCP extension |
| ChatGPT | Rolling out | Gradual rollout in progress |
Getting Started
Quick Start
- 1 Install ext-apps:
npm install @modelcontextprotocol/ext-apps - 2 Create your UI bundle (HTML/JS) using any framework
- 3 Register the bundle as a
ui://resource in your MCP server - 4 Return
_meta.uifrom your tool with resourceUri and data