MCP Apps: Build Interactive UIs in Your MCP Server — ContentBuffer guide

MCP Apps: Build Interactive UIs in Your MCP Server

K
Kodetra Technologies··4 min read Intermediate

Summary

Render interactive UIs from your MCP server with SEP-1865 and ext-apps.

MCP servers used to be text-only. You called a tool, got back JSON or a string, and the model formatted it in chat. That changed in early 2026 with SEP-1865 (MCP Apps), an official extension that lets your server ship a real, sandboxed UI right inside Claude, ChatGPT, or any host that supports the spec.

This guide builds a tiny “Get Server Time” MCP App from scratch in TypeScript. You will register a tool that links to a UI resource, bundle that UI as a single HTML file, and watch Claude render it in a sandboxed iframe with a working button. The same pattern scales to dashboards, color pickers, payment forms, anything you would normally build in React.

Prerequisites

  • Node.js 18+ and npm
  • Familiarity with MCP tools and resources
  • An MCP host that supports MCP Apps (Claude web/desktop, or the local basic-host)

Step 1: Scaffold the project

Create the folder layout. Server code lives at the root; UI code lives in src/. Vite bundles the UI into one HTML file so you do not have to fight CSP for assets.

mkdir get-time-app && cd get-time-app
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors
npm install -D typescript vite vite-plugin-singlefile tsx \
  @types/express @types/cors

Add the build/serve scripts to package.json:

"scripts": {
  "build": "INPUT=mcp-app.html vite build",
  "serve": "tsx server.ts"
}

Minimal vite.config.ts using vite-plugin-singlefile:

import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    outDir: "dist",
    rollupOptions: { input: process.env.INPUT },
  },
});

Step 2: Register the tool and the UI resource

An MCP App is two MCP primitives wired together. A tool declares _meta.ui.resourceUri, and a resource at that URI returns the bundled HTML. The host fetches the UI on first call and renders it in a sandboxed iframe.

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import express from "express";
import cors from "cors";
import fs from "node:fs/promises";
import path from "node:path";

const server = new McpServer({ name: "Get Time App", version: "1.0.0" });
const resourceUri = "ui://get-time/mcp-app.html";

registerAppTool(
  server,
  "get-time",
  {
    title: "Get Time",
    description: "Returns the current server time.",
    inputSchema: {},
    _meta: { ui: { resourceUri } },
  },
  async () => ({
    content: [{ type: "text", text: new Date().toISOString() }],
  }),
);

registerAppResource(
  server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => {
    const html = await fs.readFile(
      path.join(import.meta.dirname, "dist", "mcp-app.html"),
      "utf-8",
    );
    return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
  },
);

const app = express();
app.use(cors()); app.use(express.json());
app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
app.listen(3001, () => console.log("MCP server: http://localhost:3001/mcp"));

Step 3: Build the UI

The UI is plain HTML + a TypeScript module. The App class talks to the host over the JSON-RPC bridge that MCP Apps standardizes. ontoolresult fires when the host pushes the initial tool result; callServerTool lets the UI call any tool itself.

<!-- mcp-app.html -->
<!DOCTYPE html>
<html>
  <body>
    <p><strong>Server time:</strong> <code id="t">Loading&hellip;</code></p>
    <button id="btn">Refresh</button>
    <script type="module" src="/src/mcp-app.ts"></script>
  </body>
</html>
// src/mcp-app.ts
import { App } from "@modelcontextprotocol/ext-apps";

const t = document.getElementById("t")!;
const btn = document.getElementById("btn")!;

const app = new App({ name: "Get Time App", version: "1.0.0" });
app.connect();

// 1) Initial render: host pushes the first tool result
app.ontoolresult = (result) => {
  t.textContent = result.content?.find((c) => c.type === "text")?.text ?? "[ERROR]";
};

// 2) User-driven refresh: UI calls the tool itself
btn.addEventListener("click", async () => {
  const result = await app.callServerTool({ name: "get-time", arguments: {} });
  t.textContent = result.content?.find((c) => c.type === "text")?.text ?? "[ERROR]";
});

Step 4: Run it and connect a host

npm run build && npm run serve
# in another terminal, expose to the internet for Claude:
npx cloudflared tunnel --url http://localhost:3001

Take the generated https://<random>.trycloudflare.com URL and add it as a custom connector in Claude (Settings → Connectors → Add custom connector). Then open a new chat and ask: “What time is it on my server?” Claude calls get-time, fetches your UI, and renders it inline.

Example tool call result (text channel):

{
  "content": [
    { "type": "text", "text": "2026-05-12T03:14:07.123Z" }
  ]
}

The text result is also what your ontoolresult handler sees in the UI. That dual delivery, both into the model context and into your iframe, is what makes MCP Apps useful: the model can reason about the same data the user is clicking on.

Common pitfalls

  • Forgetting _meta.ui.resourceUri — without it, the tool runs but no UI ever renders.
  • Asset 404s in the iframe — the iframe ships with a deny-by-default CSP. Bundle everything with vite-plugin-singlefile or configure CSP per the spec.
  • Calling app.connect() twice — do it exactly once at module load. Multiple connects break the JSON-RPC channel.
  • Ignoring round-trip latencycallServerTool hits the network. Show a spinner; do not freeze the button.
  • Hardcoding ports — in dev, your cloudflared URL changes every restart. Re-add the connector or use a stable tunnel.

Quick reference

SymbolWhereWhat it does
registerAppToolserverRegisters a tool with _meta.ui.resourceUri.
registerAppResourceserverServes the bundled HTML at the ui:// URI.
new App({...})UICreates the host-bridge client.
app.connect()UIOpens the JSON-RPC channel. Call once.
app.ontoolresultUIFires on initial tool result push.
app.callServerToolUILets the UI proactively invoke a server tool.

Next steps

  • Swap the vanilla HTML for React; the App class is framework-agnostic.
  • Use app.setStructuredContent() to push state into the model’s context as the user clicks.
  • Read the official MCP Apps build guide for color-picker and dashboard examples.
  • Look at modelcontextprotocol/ext-apps for the local basic-host tester.

Comments

Subscribe to join the conversation...

Be the first to comment