Back in May, I wrote about the limitations of text-only AI interactions and my vision for on-demand UI generation. I explored how AI could dynamically generate the right interface components at the right time - forms for data collection, buttons for choices, tables for comparisons. The idea was simple but powerful: why force users to describe everything in text when AI could generate proper UIs on the fly?
Now, that vision is becoming reality. The Model Context Protocol community has formalized this concept into MCP Apps - an official extension that enables exactly what I was prototyping. The specification and SDK are now available in the official MCP Apps repository, providing types, examples, and a draft specification (SEP-1865) that standardizes how MCP servers can display interactive UI elements in conversational AI clients.
Today, we’ll explore how MCP Apps brings interactive user interfaces to AI conversations, turning theoretical possibilities into practical implementations.
The Problem: When Text Isn’t Enough
The Model Context Protocol (MCP) has revolutionized how AI assistants interact with external systems. As I’ve covered in previous posts, MCP provides powerful capabilities through resources, tools, and prompts. But as I identified in my earlier exploration of AI-generated UIs, there’s been one significant limitation: everything is text.
Imagine asking an AI to show you weather data. Currently, you’d get something like:
Current weather in San Francisco:
Temperature: 68°F
Conditions: Partly cloudy
Wind: 12 mph NW
Humidity: 65%
But wouldn’t it be better to see an actual weather widget with icons, colors, and maybe even an interactive forecast chart? That’s where MCP Apps comes in.
What Are MCP Apps?
MCP Apps is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to AI hosts (like Claude, ChatGPT, or Cursor). Instead of just returning text or structured data, MCP servers can now provide rich HTML interfaces that users can see and interact with.
Think of it as giving your MCP server the ability to create mini web applications that appear right inside your AI conversation. These aren’t just static displays – they can be fully interactive widgets with buttons, forms, charts, and real-time updates.
Demo: See MCP Apps in Action (first)
Video: OpenAI-compatible MCP App demo (openai-oss.mp4)
How MCP Apps Works: The Three Key Concepts
MCP Apps introduces three fundamental concepts that work together:
1. UI Resources (The Templates)
UI Resources are HTML templates that define how your interface looks. They use a special URI scheme ui:// and are declared just like any other MCP resource:
// Declaring a UI resource
{
uri: "ui://weather/widget",
name: "Weather Widget",
description: "Interactive weather display",
mimeType: "text/html+mcp" // Special MIME type for MCP UI
}
2. Tool-UI Linkage (The Connection)
Tools can now reference UI resources through metadata. When a tool is called, the host knows to render the associated UI:
// Tool that uses the UI resource
{
name: "show_weather",
description: "Display weather with interactive widget",
_meta: {
"ui/resourceUri": "ui://weather/widget" // Links to the UI resource
}
}
3. Bidirectional Communication (The Interaction)
The UI can communicate back to the host using MCP’s standard JSON-RPC protocol over postMessage. This allows for dynamic updates and user interactions:
// Inside the UI (HTML/JavaScript)
// Send a JSON-RPC request to the host
window.parent.postMessage({
jsonrpc: "2.0",
method: "tools/call",
params: {
name: "refresh_weather",
arguments: { city: "New York" }
},
id: 1
}, "*");
// Listen for the response
window.addEventListener("message", (event) => {
if (event.data.id === 1) {
// Handle the response from the tool call
console.log(event.data.result);
}
});
Building Your First MCP Apps Server
Let’s build a simple MCP server that displays an interactive counter widget. This example will demonstrate all the key concepts of MCP Apps in action.
Setting Up the Project
First, create a new project and install dependencies:
mkdir mcp-apps-demo
cd mcp-apps-demo
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
Update your package.json to include the module type:
{
"name": "mcp-apps-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node build/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.22.0",
"zod": "^4.1.12"
},
"devDependencies": {
"typescript": "^5.9.3",
"@types/node": "^24.10.1"
}
}
The Complete Server Implementation
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Initialize MCP server
const server = new McpServer({
name: "counter-ui-demo",
version: "1.0.0"
}, {
capabilities: {
resources: {},
tools: {}
}
});
// Store counter state
let counterValue = 0;
// HTML template for our counter UI
const COUNTER_UI_HTML = `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 400px;
margin: 40px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: white;
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 28px;
}
.counter-display {
background: rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 30px;
text-align: center;
backdrop-filter: blur(10px);
}
.count {
font-size: 72px;
font-weight: bold;
margin: 20px 0;
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
}
.buttons {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 20px;
}
button {
background: white;
color: #667eea;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s, box-shadow 0.1s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
button:active {
transform: translateY(0);
}
.message {
margin-top: 20px;
padding: 10px;
background: rgba(255, 255, 255, 0.2);
border-radius: 6px;
text-align: center;
font-size: 14px;
}
</style>
</head>
<body>
<h1>🎯 Interactive Counter</h1>
<div class="counter-display">
<div class="count" id="count">0</div>
<div class="buttons">
<button onclick="updateCounter('decrement')">−</button>
<button onclick="updateCounter('reset')">Reset</button>
<button onclick="updateCounter('increment')">+</button>
</div>
<div class="message" id="message"></div>
</div>
<script>
// MCP communication via postMessage and JSON-RPC
let requestId = 0;
const pendingRequests = new Map();
// Send JSON-RPC request to host
function sendRequest(method, params) {
return new Promise((resolve, reject) => {
const id = ++requestId;
pendingRequests.set(id, { resolve, reject });
window.parent.postMessage({
jsonrpc: "2.0",
method: method,
params: params,
id: id
}, "*");
// Timeout after 10 seconds
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error("Request timeout"));
}
}, 10000);
});
}
// Listen for responses from host
window.addEventListener("message", (event) => {
const message = event.data;
if (message.jsonrpc === "2.0" && message.id) {
const pending = pendingRequests.get(message.id);
if (pending) {
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
}
}
});
// Function to call MCP tools
async function callTool(toolName, args) {
return await sendRequest("tools/call", {
name: toolName,
arguments: args
});
}
// Function to update counter
async function updateCounter(action) {
try {
// Call the MCP tool to update counter
const result = await callTool("update_counter", { action });
// The tool returns the new value
const data = JSON.parse(result.content[0].text);
document.getElementById('count').textContent = data.value;
// Show a fun message based on the value
const messageEl = document.getElementById('message');
if (data.value === 0) {
messageEl.textContent = "Counter reset! Start fresh 🌟";
} else if (data.value > 10) {
messageEl.textContent = "Wow, that's a big number! 🚀";
} else if (data.value < -10) {
messageEl.textContent = "Going negative! ❄️";
} else if (data.value === 7) {
messageEl.textContent = "Lucky number 7! 🍀";
} else {
messageEl.textContent = "";
}
} catch (error) {
console.error('Error updating counter:', error);
}
}
// Initialize display with current value
async function init() {
try {
const result = await callTool("get_counter", {});
const data = JSON.parse(result.content[0].text);
document.getElementById('count').textContent = data.value;
} catch (error) {
console.error('Error initializing:', error);
}
}
// Initialize when page loads
init();
</script>
</body>
</html>
`;
// Register the UI resource
server.registerResource(
'counter-widget',
'ui://counter/widget',
{
title: 'Interactive counter widget UI',
description: 'An interactive HTML counter widget',
mimeType: 'text/html'
},
async (uri) => {
return {
contents: [{
uri: uri.href,
text: COUNTER_UI_HTML
}]
};
}
);
// Register tools
server.registerTool(
'show_counter',
{
title: 'Show counter',
description: 'Display an interactive counter widget',
inputSchema: {}
},
async () => {
return {
content: [{
type: "text" as const,
text: `Current counter value: ${counterValue}`
}],
// This tells the host to use the UI resource
_meta: {
"ui/resourceUri": "ui://counter/widget"
}
};
});
server.registerTool(
'get_counter',
{
title: 'Get counter',
description: 'Get the current counter value',
inputSchema: {}
},
async () => {
return {
content: [{
type: "text" as const,
text: JSON.stringify({ value: counterValue })
}]
};
});
server.registerTool(
'update_counter',
{
title: 'Update counter',
description: 'Update the counter value',
inputSchema: {
action: z.enum(["increment", "decrement", "reset"]).describe("The action to perform on the counter")
} as any
},
async ({ action }: any) => {
// Update based on action
switch (action) {
case "increment":
counterValue++;
break;
case "decrement":
counterValue--;
break;
case "reset":
counterValue = 0;
break;
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({ value: counterValue })
}]
};
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Counter UI MCP server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});
Understanding the Code
Let’s break down what’s happening in this example:
-
Server Setup: We use the modern
Serverclass from the MCP SDK with proper capability declarations for resources and tools. -
Resource Registration: We use
server.registerResource()to register our UI resource atui://counter/widgetwith the HTML template. - Tool Registration: We use the
server.registerTool()method to register three tools with Zod schemas:show_counter: Returns the counter value with UI metadata to trigger the widget displayget_counter: Simple tool that returns the current counter value (called by the UI)update_counter: Updates the counter based on an action (increment/decrement/reset)
- The HTML Template: Contains:
- Beautiful gradient UI with hover effects
- Interactive buttons for increment/decrement/reset
- JavaScript that communicates with the host using postMessage and JSON-RPC
- Dynamic messages based on counter value
- Bidirectional Communication: The UI communicates with the host using MCP’s JSON-RPC protocol over postMessage:
- UI sends requests to
window.parent.postMessage()with JSON-RPC format - Host processes the request and sends back responses
- The UI listens for messages and matches responses to pending requests by ID
- This follows the standard MCP protocol, just over postMessage instead of stdio or HTTP
- UI sends requests to
Testing Your MCP Apps Server
Build and test your server:
# Build the TypeScript code
npm run build
# Test with MCP Inspector
npx @modelcontextprotocol/inspector node build/index.js
In the MCP Inspector:
- Connect to your server
- Go to the “Tools” tab
- Find and execute the
show_countertool
Note: Not all MCP clients support the UI extension yet. The server will still work with text-only clients - they’ll just see the text response without the interactive UI.
Best Practices for Secure MCP Apps
- Validate all inputs from the UI before processing
- Use CSP declarations to limit external resources
- Keep UI logic simple and server-side validation strong
- Avoid sensitive data in UI templates
Real-World Use Cases
MCP Apps opens up exciting possibilities. In my earlier post, I demonstrated a shipping company support system prototype where AI generated forms for address changes, buttons for confirmations, and tables for comparing options. Now with MCP Apps, these concepts can be implemented in a standardized way:
Data Visualization
Instead of describing data trends, show interactive charts:
- Stock price graphs with zoom and pan
- System metrics dashboards
- Analytics reports with drill-down capabilities
Form Interfaces
Create proper forms for complex inputs:
- Configuration wizards
- Multi-step surveys
- Settings panels with live preview
Media Displays
Show rich media content:
- Image galleries with thumbnails
- Audio players with controls
- Video previews with playback
Interactive Tools
Build mini-applications:
- Calculator with history
- Color picker for design work
- Code formatter with syntax highlighting
- File browsers with preview
Comparing MCP Capabilities
Here’s how MCP Apps fits with other MCP features:
| Feature | Purpose | User Experience |
|---|---|---|
| Resources | Provide static data | AI reads and describes content |
| Tools | Execute actions | AI performs operations and reports results |
| Prompts | Generate text | AI creates formatted content |
| MCP Apps | Interactive UIs | User sees and interacts with visual interfaces |
What’s Next for MCP Apps?
The MCP Apps specification is still evolving. Future enhancements might include:
- External URLs: Embedding existing web applications
- State persistence: Saving widget state between sessions
- Widget communication: Multiple widgets talking to each other
- More content types: Native components beyond HTML
Conclusion
MCP Apps represents a significant leap forward for the Model Context Protocol. It’s exciting to see the ideas I explored in my earlier post about on-demand UI generation now formalized into an official MCP extension. What started as experimental prototypes showing forms, buttons, and tables generated by AI has evolved into a standardized protocol that any MCP server can implement.
By enabling rich, interactive user interfaces, MCP Apps bridges the gap between conversational AI and traditional applications.
The ability to show actual interfaces instead of just describing them makes AI assistants far more useful for real-world tasks. Whether you’re building a weather widget, a data dashboard, or an interactive form, MCP Apps provides the foundation for creating engaging experiences within AI conversations.
As the ecosystem grows and more hosts adopt MCP Apps support, we’ll see increasingly sophisticated integrations that blur the line between chatting with an AI and using a full application.
The example we built today is just the beginning. With MCP Apps, your AI tools are no longer limited to text – they can now provide the rich, visual experiences users expect in modern applications.
What’s Next?
In my next post, I’ll show you how to build interactive UI apps specifically for OpenAI’s ChatGPT using their Apps SDK. We’ll explore how ChatGPT’s implementation of MCP Apps works and build a practical example that runs directly in ChatGPT conversations.
Resources
This article was proofread and edited with AI assistance.