Skip to main content
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.

Choosing the Right Widget System

mcp-use supports three widget systems. Choose based on your needs:
SystemUse WhenInteractivityChatGPT SupportMCP Apps Support
MCP AppsYou want maximum compatibilityFull React with state & tool calls✅ Yes (dual-protocol)✅ Yes (standard)
ChatGPT Apps SDKChatGPT-only features neededFull React with state & tool calls✅ Yes (native)❌ No
MCP UI (this page)Simple, static contentLimited (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

Building React Widgets

Project Structure

my-mcp-server/
├── resources/
│   ├── kanban-board.tsx
├── src/
   └── server.ts

Example: Kanban Board Widget

// 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>
  );
}

Widget Styling

/* 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