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.
The MCP-UI resources are not directly related to the Apps SDK widgets, we recommend using the Apps SDK widgets instead.
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