Visual UIs Are Now Possible in MCP Servers
Source: Dev.to

MCP servers can now render interactive UIs directly in Claude Desktop’s chat window. Not just text responses—actual HTML with JavaScript, maps, charts, anything.
What Changed
The @modelcontextprotocol/ext-apps library lets MCP tools return visual UIs. When you call a tool, instead of just getting text back, you get an interactive iframe rendered inline in the conversation.
This means your AI assistant can show you things, not just tell you about them.
Resources
How It Works
The architecture has two parts: a server that fetches data and declares the UI, and a client‑side app that renders it.
Server Side
Register a tool with UI metadata pointing to an HTML resource:
import { registerAppTool, registerAppResource } from "@modelcontextprotocol/ext-apps/server";
const resourceUri = "ui://iss-tracker/mcp-app.html";
// Register the UI resource (bundled HTML)
registerAppResource(server, resourceUri, "text/html", () => APP_HTML);
// Register the tool with UI metadata
registerAppTool(server, "where_is_iss", {
description: "Show ISS location on a live map",
uiResourceUri: resourceUri,
csp: {
connectDomains: ["https://*.openstreetmap.org", "https://unpkg.com"],
resourceDomains: ["https://*.openstreetmap.org", "https://unpkg.com"],
},
execute: async () => {
const [iss, path, geo] = await Promise.all([
fetch("https://api.wheretheiss.at/v1/satellites/25544").then(r => r.json()),
fetch(`https://api.wheretheiss.at/v1/satellites/25544/positions?timestamps=${timestamps}`).then(r => r.json()),
fetch("http://ip-api.com/json/").then(r => r.json()),
]);
return { iss, path, user: { latitude: geo.lat, longitude: geo.lon, city: geo.city } };
},
});
The csp field is important—it declares which external domains your UI needs to access. Without this, Leaflet tiles and scripts would be blocked.
Client Side
The UI receives tool results and renders them:
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "ISS Tracker", version: "1.0.0" });
app.ontoolresult = (result) => {
const data = result.structuredContent;
// Update your UI with the data
updateMap(data.iss, data.user);
};
app.connect();
Key Gotcha: Dynamic Script Loading
Static <script> tags don’t work in srcdoc iframes. Load external libraries dynamically:
async function loadLeaflet(): Promise<void> {
if (typeof L !== "undefined") return;
// Load CSS
const cssLink = document.createElement("link");
cssLink.rel = "stylesheet";
cssLink.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(cssLink);
// Load JS
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Leaflet"));
document.head.appendChild(script);
});
}
This caught many developers off guard—Leaflet won’t load unless injected this way.
Try It Yourself
git clone https://github.com/JasonMakes801/iss-tracker-mcp
bun install && bun run build
Add to Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"iss-tracker": {
"command": "/path/to/bun",
"args": ["/path/to/iss-tracker/dist/index.js", "--stdio"]
}
}
}
- Restart Claude Desktop.
- Ask: “Where is the ISS?”
What’s Next
Maps are just the start. Dashboards, charts, forms, data visualizations—anything you can build in HTML can now live inside your AI conversation.
What would you build with this?
