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

Without MCP Apps, a sales analysis tool might return "Q4 revenue: $2.3M across 4 regions". With MCP Apps, users see an interactive chart where they can click regions to drill down, compare quarters, and export data—all without additional prompts.

Architecture

MCP Apps Data Flow
┌─────────────────────────────────────────────────────────────────┐
│                    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. 1 Tool returns standard content plus _meta.ui with a resourceUri and data
  2. 2 Host fetches the UI resource (bundled HTML/JS) via the ui:// scheme
  3. 3 Host renders the UI in a sandboxed iframe with restricted permissions
  4. 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 Basic Pattern
# 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:

ext-apps Client API
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

MCP Apps 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
                    }
                }
            }
        }
    }
Data Exploration Configuration Wizards Document Review Real-time Monitoring Form Builders Workflow Editors 3D Visualization Map Interfaces

Bidirectional Communication

MCP Apps support full bidirectional communication between the UI and server via JSON-RPC:

Bidirectional Communication
┌──────────────────┐         ┌──────────────────┐
│    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
// 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

Always serve UI bundles from trusted sources. Review third-party UI components before including them in your MCP server.

Data Validation

Validate all data passed to UI components. Never include sensitive data (tokens, passwords) in _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. 1 Install ext-apps: npm install @modelcontextprotocol/ext-apps
  2. 2 Create your UI bundle (HTML/JS) using any framework
  3. 3 Register the bundle as a ui:// resource in your MCP server
  4. 4 Return _meta.ui from your tool with resourceUri and data

Learn More

See the official announcement at blog.modelcontextprotocol.io for full documentation.

Related Topics