DEV Community

Cover image for Visual UIs Are Now Possible in MCP Servers
Jason Peterson
Jason Peterson

Posted on

Visual UIs Are Now Possible in MCP Servers

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.

ISS Tracker demo

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 } };
  },
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Key Gotcha: Dynamic Script Loading

Static <script src=""> tags don't work in srcdoc iframes. You have to 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);
  });
}
Enter fullscreen mode Exit fullscreen mode

This caught me off guard—took a while to figure out why Leaflet wasn't loading.

Try It Yourself

  • Clone: git clone https://github.com/JasonMakes801/iss-tracker-mcp
  • Build: 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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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?

Top comments (0)