MCP Apps: Building Interactive UIs That Render Inside AI Chat with TypeScript
Most AI chat interfaces flatten complex interactions into text streams. MCP Apps render interactive UIs directly in the conversation—charts, forms, and dashboards that respond to user input without leaving the agent context.
MCP Apps: Building Interactive UIs That Render Inside AI Chat with TypeScript
Most AI chat interfaces flatten complex interactions into text streams. MCP Apps render interactive UIs directly in the conversation—charts, forms, and dashboards that respond to user input without leaving the agent context.
What Are MCP Apps and Why They Matter for AI Interfaces
Traditional chat responses force developers to describe visual data through text. The agent returns a JSON blob, the developer parses it, and the user squints at formatted strings pretending to be tables. This breaks down when users need to manipulate data, adjust parameters, or explore interactive visualizations.
MCP Apps solve this by embedding iframe-sandboxed HTML/JavaScript directly in the chat interface. The agent returns a resource URI instead of text, the client fetches the app bundle, and the user interacts with live UI components. This distinction is critical: the UI persists across conversation turns, maintains local state, and communicates back to the agent through structured messages.
The practical benefit is immediate. Instead of asking the agent to regenerate a chart with different parameters, users drag sliders and see results update in real time. Instead of copy-pasting form data back into the chat, users submit forms that trigger tool calls directly. The interaction model shifts from text-based negotiation to direct manipulation.

The MCP App Architecture: Server, Resource URI, and iframe Sandbox
MCP Apps extend the Model Context Protocol's resource system. The server declares a resource with mimeType: "text/html" and returns a complete HTML document. The client detects this MIME type, creates a sandboxed iframe, and injects the document. The app runs isolated from the host page but can post messages to the parent window.
%% alt: MCP App architecture showing message flow between agent, server, iframe, and user
flowchart TD
Agent[Agent sends tool call] --> Server[MCP Server]
Server --> Resource[Returns resource URI with HTML MIME type]
Resource --> Client[Client fetches resource]
Client --> Iframe[Creates sandboxed iframe]
Iframe --> App[Renders interactive app]
App --> User[User interacts with UI]
User --> Message[Posts message to parent]
Message --> Agent
classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
classDef uiComponent fill:#2a1840,stroke:#c084fc,color:#f3e8ff
classDef userAction fill:#142544,stroke:#7c9cf0,color:#eaf2ff
class Server,Client,Iframe framework
class App,User uiComponent
class Message userAction
The resource URI follows the pattern mcp://server-name/path. When the agent wants to show a chart, it calls a tool that returns mcp://analytics/chart/sales-2024. The client resolves this URI by asking the server for the resource content, receives an HTML document, and renders it. This keeps the app bundle decoupled from the tool implementation—the same chart component can be reused across multiple tools.
The sandbox attribute restricts the iframe to allow-scripts allow-same-origin. Scripts run but cannot access parent page cookies, localStorage, or DOM. Communication happens exclusively through window.postMessage. The app sends structured events like { type: "filter_changed", value: "Q4" } and the parent relays these to the agent as tool calls.
Theme synchronization requires explicit coordination. The parent posts a { type: "theme", value: "dark" } message on load and whenever the theme changes. The app listens and updates CSS variables accordingly. This pattern ensures visual consistency without tight coupling.
Building Your First MCP App: A Counter Component in TypeScript
A minimal MCP App demonstrates the core pattern. The server returns a resource containing a self-contained HTML document with inline TypeScript compiled to JavaScript. The app renders a counter and posts increment events to the parent.
// server/counter-resource.ts
import { Resource } from "@modelcontextprotocol/sdk/types.js";
export function createCounterResource(): Resource {
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
padding: 20px;
font-family: system-ui, sans-serif;
background: var(--bg, #ffffff);
color: var(--fg, #000000);
}
button {
padding: 12px 24px;
font-size: 16px;
background: #0066cc;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0052a3;
}
.count {
font-size: 32px;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="count">Count: <span id="value">0</span></div>
<button id="increment">Increment</button>
<script>
let count = 0;
const valueEl = document.getElementById('value');
const button = document.getElementById('increment');
button.addEventListener('click', () => {
count++;
valueEl.textContent = count;
window.parent.postMessage({
type: 'counter_incremented',
value: count
}, '*');
});
window.addEventListener('message', (event) => {
if (event.data.type === 'theme') {
document.documentElement.style.setProperty('--bg',
event.data.value === 'dark' ? '#1a1a1a' : '#ffffff');
document.documentElement.style.setProperty('--fg',
event.data.value === 'dark' ? '#ffffff' : '#000000');
}
});
</script>
</body>
</html>
`;
return {
uri: "mcp://counter/app",
name: "Interactive Counter",
description: "A simple counter demonstrating MCP App patterns",
mimeType: "text/html",
text: html
};
}The server tool handler returns this resource URI when the user asks to "show me a counter". The client fetches the resource, detects text/html, and renders it in an iframe. The button click posts a message, which the parent can log, forward to the agent, or use to trigger another tool call.
The implication here is that state lives in the iframe, not the agent context. If the user refreshes the page, the counter resets. Persistent state requires the app to fetch initial values from the agent on load or store data in localStorage (if allowed by sandbox policy). This tradeoff makes MCP Apps suitable for ephemeral interactions like data exploration, not long-lived application state.
Handling Events and Two-Way Communication Between UI and Agent
The power of MCP Apps lies in bidirectional communication. The app sends user actions to the agent, and the agent sends data updates back to the app. This requires a structured message protocol and clear ownership of state.
// app/chart-app.ts - Client-side code running in iframe
interface ChartConfig {
type: 'theme' | 'data_update' | 'filter_changed';
value: any;
}
class ChartApp {
private chart: any; // Assume Chart.js instance
constructor() {
this.setupMessageListener();
this.setupUserInteractions();
// Request initial data from parent
window.parent.postMessage({ type: 'ready' }, '*');
}
private setupMessageListener() {
window.addEventListener('message', (event) => {
const msg = event.data as ChartConfig;
switch (msg.type) {
case 'theme':
this.applyTheme(msg.value);
break;
case 'data_update':
this.chart.data.datasets[0].data = msg.value;
this.chart.update();
break;
}
});
}
private setupUserInteractions() {
const filterSelect = document.getElementById('filter') as HTMLSelectElement;
filterSelect.addEventListener('change', (e) => {
const target = e.target as HTMLSelectElement;
// Notify agent to refetch data with new filter
window.parent.postMessage({
type: 'filter_changed',
value: target.value
}, '*');
});
}
private applyTheme(theme: 'light' | 'dark') {
document.body.className = theme;
this.chart.options.plugins.legend.labels.color =
theme === 'dark' ? '#ffffff' : '#000000';
this.chart.update();
}
}
new ChartApp();The agent-side handler receives the filter_changed message, calls a tool to fetch new data, and posts a data_update message back to the app. This creates a reactive loop where user interactions trigger agent reasoning, which updates the UI without destroying the iframe.
The failure mode here is subtle but expensive: if the app posts unstructured messages, the parent cannot route them correctly. Define a strict message schema with TypeScript discriminated unions and validate incoming messages. Malformed messages should log errors but never crash the parent process.

MCP Apps vs Traditional Chat Responses: When to Use Each
Not every agent response should render as an MCP App. Text responses remain faster to generate and easier to reason about for simple queries. The decision hinges on interactivity requirements and data complexity.
%% alt: Comparison of traditional text responses versus MCP App UI rendering
flowchart LR
subgraph TextResponse["Traditional Text: static data"]
Query1[User asks for summary]
Agent1[Agent generates text]
Display1[Displays formatted string]
Query1 --> Agent1 --> Display1
end
subgraph AppResponse["MCP App: interactive data"]
Query2[User asks for chart]
Agent2[Agent returns resource URI]
Render[Client renders iframe with UI]
Interact[User adjusts filters in real time]
Query2 --> Agent2 --> Render --> Interact
end
classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
classDef userAction fill:#142544,stroke:#7c9cf0,color:#eaf2ff
class Agent1,Agent2,Render framework
class Interact userAction
Use traditional text responses when:
- The answer fits in 2-3 sentences
- No user interaction is needed after display
- The data is purely informational (status updates, confirmations)
- Performance is critical and the agent must respond in under 200ms
Use MCP Apps when:
- The user needs to filter, sort, or manipulate displayed data
- Visual representations (charts, graphs, maps) improve comprehension
- The interaction requires form submission or multi-step input
- The response includes real-time updates or streaming data
The overhead of creating an iframe and loading an app bundle adds 100-300ms latency. For frequently accessed apps, the server can cache compiled bundles and serve them with aggressive HTTP cache headers. This matters because users perceive delays above 200ms as sluggish.
Teams often overuse MCP Apps in early prototypes, rendering every response as a UI component. This creates visual noise and forces users to context-switch between text and interactive elements. Reserve apps for genuinely interactive workflows and default to text otherwise.
Real-World Use Cases: Charts, Forms, Dashboards, and Visualizations
Production MCP Apps solve visualization and data entry problems that text cannot address. The most common patterns are analytics dashboards, configuration forms, and multi-step workflows.
%% alt: Common MCP App use cases showing data flow through visualization types
flowchart TD
UserRequest[User requests data visualization]
AnalyzeTool[Agent calls analytics tool]
ResourceURI[Returns dashboard resource URI]
RenderDashboard[Renders interactive dashboard]
UserInteract[User adjusts date range, filters]
RefetchData[Triggers agent to refetch data]
UpdateUI[Updates charts without reload]
UserRequest --> AnalyzeTool --> ResourceURI
ResourceURI --> RenderDashboard --> UserInteract
UserInteract --> RefetchData --> UpdateUI
UpdateUI --> UserInteract
classDef userAction fill:#142544,stroke:#7c9cf0,color:#eaf2ff
classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
classDef dataStore fill:#3a2f0b,stroke:#fbbf24,color:#fef3c7
class UserRequest,UserInteract userAction
class AnalyzeTool,RenderDashboard,RefetchData framework
class ResourceURI,UpdateUI dataStore
Analytics dashboards embed Chart.js or D3.js visualizations. The agent fetches sales data, returns an MCP App with the chart pre-rendered, and the user drags time range sliders to explore different periods. Each slider change posts a message to the agent, which queries the database and pushes updated datasets back to the chart. This avoids regenerating the entire UI for parameter changes.
Configuration forms replace text-based parameter negotiation. Instead of asking "What's the retry count?" and "What's the timeout?", the agent shows a form with labeled inputs and validation. The user fills in values, clicks submit, and the app posts a form_submitted message with structured data. The agent applies the configuration and confirms success.
Multi-step workflows guide users through complex processes. A deployment wizard shows a progress bar, collects environment variables in step one, validates credentials in step two, and triggers deployment in step three. Each step posts messages to the agent, which runs validation tools and advances the workflow state. This pattern works well for onboarding, troubleshooting, and setup tasks where the user needs guardrails.
Geospatial visualizations use Leaflet or Mapbox to plot data on maps. The agent returns store locations with coordinates, the app renders markers, and the user clicks markers to see details. Clicking "Get Directions" posts a message to the agent, which calls a routing API and overlays the path on the map.
Theme Synchronization, Security Sandboxing, and Production Patterns
Production MCP Apps require careful attention to theming, security, and error handling. The default sandbox restrictions prevent most exploits, but developers must still validate all messages and sanitize user input.
%% alt: Production MCP App security and theme synchronization flow
flowchart TD
ParentLoad[Parent window loads]
SendTheme[Sends theme message to iframe]
AppReceives[App receives and applies theme]
UserChanges[User changes system theme]
ParentDetects[Parent detects theme change]
ResendTheme[Resends updated theme message]
AppUpdates[App updates CSS variables]
ParentLoad --> SendTheme --> AppReceives
UserChanges --> ParentDetects --> ResendTheme --> AppUpdates
UserInput[User triggers action in app]
PostMessage[App posts message to parent]
Validate[Parent validates message schema]
RouteAgent[Routes to agent or tool]
HandleError[Handles errors gracefully]
UserInput --> PostMessage --> Validate
Validate -->|Valid| RouteAgent
Validate -->|Invalid| HandleError
classDef framework fill:#0b3b2e,stroke:#34d399,color:#d1fae5
classDef userAction fill:#142544,stroke:#7c9cf0,color:#eaf2ff
classDef dataStore fill:#3a2f0b,stroke:#fbbf24,color:#fef3c7
class ParentLoad,ParentDetects,Validate,RouteAgent framework
class UserChanges,UserInput userAction
class SendTheme,PostMessage dataStore
Theme synchronization requires the parent to post initial theme state on iframe load. The app stores this in a CSS variable and listens for theme change events. When the user switches to dark mode, the parent posts { type: "theme", value: "dark" } and the app updates immediately. This keeps the app visually consistent with the host interface without polling or refresh.
Security sandboxing prevents the app from accessing cookies, localStorage, or making cross-origin requests. The iframe sandbox attribute must include allow-scripts but should never include allow-same-origin for untrusted apps. If the app needs to persist data, it posts a message to the parent, which stores it in the agent's conversation context. This adds latency but prevents XSS attacks.
Message validation uses a Zod schema to parse incoming messages. If the schema fails, log the error and ignore the message. Never call eval() or Function() on message data. This distinction is critical: malicious apps can craft messages that look valid but execute arbitrary code if not validated.
Error handling must gracefully degrade. If the app fails to load, show a fallback text response. If a message post fails, log locally and retry with exponential backoff. If the agent returns invalid data, display an error message in the app UI rather than crashing the iframe.
Performance optimization focuses on bundle size and render speed. Inline critical CSS in the <head> to avoid flash of unstyled content. Lazy load chart libraries only when needed. Minify JavaScript and strip development-only code in production builds. A well-optimized MCP App loads in under 200ms and renders in under 50ms.
Building Production-Ready MCP Apps: Testing, Deployment, and Best Practices
Testing MCP Apps requires separate strategies for the server resource handler and the client-side app. The server test suite validates that tools return correct resource URIs and that resource content matches expected structure. The client test suite uses Playwright or Puppeteer to load the app in a real iframe and simulate user interactions.
Deployment patterns vary by MCP server architecture. For self-hosted servers, bundle the app with the server binary and serve it from memory. For cloud-hosted servers, upload app bundles to S3 or equivalent and return signed URLs as resource content. The client fetches the bundle, caches it aggressively, and only refetches when the resource version changes.
Version management prevents stale apps from breaking. Embed a version string in the resource URI like mcp://charts/v2/sales-dashboard. When the app code changes, increment the version and return the new URI. Old versions remain accessible for a deprecation period, allowing gradual migration.
Best practices distill into four rules: keep apps small, validate all messages, test in real iframes, and default to text responses. Related patterns for building MCP servers and AI-powered refactoring workflows extend these concepts. The architecture scales to large context windows when apps need to display extensive data.
That covers the essential patterns for building MCP Apps in TypeScript. Apply these in production and the difference between text-based chat and interactive UI becomes immediately clear—users manipulate data directly instead of negotiating through conversation.