MCP-UI Resources enable you to build rich, interactive user interfaces that work seamlessly with MCP servers. These widgets can be embedded in MCP-compatible clients to provide visual interfaces alongside your tools and resources.
MCP-UI resources follow the proposed MCP-UI specification here.
mcp-use supports three widget systems. Choose based on your needs:
| System | Use When | Interactivity | ChatGPT Support | MCP Apps Support |
|---|
| MCP Apps | You want maximum compatibility | Full React with state & tool calls | ✅ Yes (dual-protocol) | ✅ Yes (standard) |
| ChatGPT Apps SDK | ChatGPT-only features needed | Full React with state & tool calls | ✅ Yes (native) | ❌ No |
| MCP UI (this page) | Simple, static content | Limited (mostly display) | ❌ No | ✅ Yes |
Recommended: For new projects with interactive widgets, use MCP Apps with type: "mcpApps" to get dual-protocol support. Your widgets will work in both ChatGPT and MCP Apps clients.MCP UI is best for: Simple content display, read-only views, or lightweight embeds where full React isn’t needed.
Resource Types
1. External URL Resources
Iframe-based widgets served from your MCP server
Check an example here mcp-ui-example
server.uiResource({
type: "externalUrl",
name: "dashboard",
widget: "analytics-dashboard",
title: "Analytics Dashboard",
description: "Real-time analytics visualization",
props: {
timeRange: {
type: "string",
description: "Time range for data",
required: false,
default: "7d",
},
metric: {
type: "string",
description: "Metric to display",
required: false,
default: "revenue",
},
},
size: ["800px", "600px"],
annotations: {
audience: ["user"],
priority: 0.9,
},
});
Characteristics:
- Served as standalone HTML pages
- Isolated in iframes for security
- Can include external resources
- Full JavaScript capabilities
External URLs are automatically built and configured by the
setupWidgetRoutes function in mcp-use/server. Routes are generated based
on your widget definitions without manual setup.
2. Raw HTML Resources
Inline HTML content rendered directly:
Check an example here mcp-ui-example
server.uiResource({
type: "rawHtml",
name: "simple_form",
title: "Contact Form",
description: "Simple contact form",
htmlString: `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: -apple-system, sans-serif;
padding: 20px;
}
.form-group {
margin-bottom: 15px;
}
input, textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
button {
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<h2>Contact Us</h2>
<form id="contactForm">
<div class="form-group">
<input type="text" placeholder="Name" required>
</div>
<div class="form-group">
<input type="email" placeholder="Email" required>
</div>
<div class="form-group">
<textarea placeholder="Message" rows="5" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
<script>
document.getElementById('contactForm').onsubmit = (e) => {
e.preventDefault();
alert('Message sent successfully!');
};
</script>
</body>
</html>
`,
size: ["400px", "500px"],
});
Characteristics:
- Renders inline without iframe
- Simpler but less isolated
- Good for basic interactions
- Limited external resource loading
3. Remote DOM Resources
JavaScript-driven dynamic interfaces:
Check an example here mcp-ui-example
server.uiResource({
type: "remoteDom",
name: "interactive_chart",
title: "Interactive Chart",
description: "Dynamic data visualization",
remoteDomFramework: "react",
remoteDomCode: `
function ChartWidget() {
const [data, setData] = React.useState([]);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
// Fetch data from MCP server
fetch('/api/chart-data')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, []);
if (loading) {
return <div>Loading chart data...</div>;
}
return (
<div style={{ padding: '20px' }}>
<h2>Sales Dashboard</h2>
<div className="chart-container">
{data.map(item => (
<div key={item.id} style={{
height: item.value + 'px',
width: '50px',
background: '#007bff',
display: 'inline-block',
margin: '0 5px'
}}>
<span>{item.label}</span>
</div>
))}
</div>
</div>
);
}
ReactDOM.render(<ChartWidget />, document.getElementById('root'));
`,
props: {
refreshInterval: {
type: "number",
description: "Refresh interval in seconds",
default: 60,
},
},
});
Characteristics:
- Dynamic JavaScript execution
- React/Vue/vanilla JS support
- Real-time updates possible
- More complex interactions
Project Structure
my-mcp-server/
├── resources/
│ ├── kanban-board.tsx
├── src/
└── server.ts
// resources/kanban-board.tsx
import React, { useState, useEffect } from "react";
import "./kanban-board.css";
interface Task {
id: string;
title: string;
description: string;
status: "todo" | "in-progress" | "done";
priority: "low" | "medium" | "high";
assignee?: string;
}
export default function KanbanBoard() {
const [tasks, setTasks] = useState<Task[]>([]);
const [draggedTask, setDraggedTask] = useState<string | null>(null);
// Parse URL parameters
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const initialTasks = params.get("tasks");
if (initialTasks) {
try {
setTasks(JSON.parse(initialTasks));
} catch (e) {
console.error("Failed to parse initial tasks");
}
}
}, []);
const handleDragStart = (taskId: string) => {
setDraggedTask(taskId);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: React.DragEvent, newStatus: Task["status"]) => {
e.preventDefault();
if (!draggedTask) return;
setTasks(
tasks.map((task) =>
task.id === draggedTask ? { ...task, status: newStatus } : task,
),
);
setDraggedTask(null);
};
const columns: { status: Task["status"]; title: string }[] = [
{ status: "todo", title: "To Do" },
{ status: "in-progress", title: "In Progress" },
{ status: "done", title: "Done" },
];
return (
<div className="kanban-board">
<h1>Project Tasks</h1>
<div className="columns">
{columns.map((column) => (
<div
key={column.status}
className="column"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, column.status)}
>
<h2>{column.title}</h2>
<div className="tasks">
{tasks
.filter((task) => task.status === column.status)
.map((task) => (
<div
key={task.id}
className={`task priority-${task.priority}`}
draggable
onDragStart={() => handleDragStart(task.id)}
>
<h3>{task.title}</h3>
<p>{task.description}</p>
{task.assignee && (
<span className="assignee">{task.assignee}</span>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
}
/* resources/kanban-board.css */
.kanban-board {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.columns {
display: flex;
gap: 20px;
margin-top: 20px;
}
.column {
flex: 1;
background: white;
border-radius: 8px;
padding: 15px;
min-height: 400px;
}
.column h2 {
margin: 0 0 15px 0;
font-size: 18px;
color: #333;
}
.task {
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px;
margin-bottom: 10px;
cursor: move;
transition: transform 0.2s;
}
.task:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.task.priority-high {
border-left: 4px solid #f44336;
}
.task.priority-medium {
border-left: 4px solid #ff9800;
}
.task.priority-low {
border-left: 4px solid #4caf50;
}
.assignee {
display: inline-block;
background: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
margin-top: 8px;
}
Next Steps