MCP Apps: Bringing Interactive UIs to AI Conversations

1 hour ago 2

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:

  1. Server Setup: We use the modern Server class from the MCP SDK with proper capability declarations for resources and tools.

  2. Resource Registration: We use server.registerResource() to register our UI resource at ui://counter/widget with the HTML template.

  3. 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 display
    • get_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)
  4. 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
  5. 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

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:

  1. Connect to your server
  2. Go to the “Tools” tab
  3. Find and execute the show_counter tool

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

  1. Validate all inputs from the UI before processing
  2. Use CSP declarations to limit external resources
  3. Keep UI logic simple and server-side validation strong
  4. 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.

Read Entire Article