Appendix: MCP Apps — Rich UI in chat (experimental)

tech
prompt-engineering
github-copilot
mcp
Appendix covering MCP Apps — how to return interactive HTML UIs in chat using @anthropic-ai/ext-apps, including architecture, the promise pattern, and implementation examples.
Author

Dario Airoldi

Published

March 7, 2026

Appendix: MCP Apps — Rich UI in Chat (Experimental)

Parent article: How to Create MCP Servers for GitHub Copilot


MCP servers can go beyond text responses by returning interactive HTML UIs directly in the chat panel. The @anthropic-ai/ext-apps package enables this through a ui:// resource scheme, turning text-only tool responses into full interactive experiences.

Status: Experimental — API may change. Enable in VS Code: Settings → search “MCP apps” → toggle on the experimental feature.

Architecture

An MCP app consists of three parts bundled together:

Component Purpose Technology
HTML file UI layout and structure Standard HTML + CSS (e.g., Pico CSS)
TypeScript file Client-side logic and server communication DOM APIs + @anthropic-ai/ext-apps
MCP server Tool registration and resource handling @modelcontextprotocol/sdk

Vite with vite-plugin-single-file bundles the HTML and TypeScript into a single self-contained HTML file that the chat client renders inline.

Project structure

my-mcp-app/
+-- src/
├   +-- index.ts          # MCP server (tools + resource registration)
├   +-- mcpapp.html       # UI layout
└   +-- mcpapp.ts         # Client-side logic
+-- package.json
+-- tsconfig.json
+-- vite.config.ts

Required dependencies

{
  "dependencies": {
    "@modelcontextprotocol/sdk": "latest",
    "@anthropic-ai/ext-apps": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "vite": "latest",
    "vite-plugin-single-file": "latest",
    "cross-env": "latest"
  }
}

How it works

  1. The MCP server declares a ui:// resource using the ext-apps framework
  2. A tool handler returns a reference to the UI resource instead of plain text
  3. The chat client renders the HTML/CSS/JS inline in the conversation
  4. The app can call server tools, and the server can respond back—bidirectional communication

Registering a UI resource

In the server’s index.ts, register the app resource and a tool that triggers it:

import { createApp } from "@anthropic-ai/ext-apps";

// Register the UI resource
server.resource("ui://my-app", async () => {
  const html = fs.readFileSync("dist/mcpapp.html", "utf8");
  return {
    contents: [{ uri: "ui://my-app", mimeType: "text/html", text: html }]
  };
});

// Register a tool that shows the UI
server.tool("show-my-app", "Show the interactive control panel", {}, async () => {
  return {
    content: [{ type: "resource", resource: { uri: "ui://my-app" } }]
  };
});

Client-side tool calls

In the app’s TypeScript file, use callServerTool() to invoke server tools from the UI:

// mcpapp.ts
import { callServerTool } from "@anthropic-ai/ext-apps";

document.getElementById("submit")?.addEventListener("click", async () => {
  const name = (document.getElementById("name") as HTMLInputElement).value;
  const result = await callServerTool("hello", { name });
  document.getElementById("output")!.textContent = result;
});

The promise pattern — forcing chat to wait

By default, when a tool returns a UI, the chat finishes immediately—it doesn’t wait for user interaction. The promise pattern solves this by returning an unresolved promise that blocks until the user submits input.

How it works

  1. Declare a promise variable at the top of the server file
  2. Return await promise from the tool handler—the chat blocks
  3. Create an app-only tool (invisible to chat) that the UI calls on submit
  4. The app-only tool’s handler resolves the promise, unblocking the chat
// Server-side: index.ts
let resolvePromise: (value: string) => void;

// Tool visible to chat → shows the form
server.tool("show-get-name", "Show name input form", {}, async () => {
  const promise = new Promise<string>((resolve) => {
    resolvePromise = resolve;
  });

  // Return UI resource → chat renders the form
  // Await the promise → chat blocks until resolved
  return {
    content: [
      { type: "resource", resource: { uri: "ui://get-name" } },
      { type: "text", text: await promise }  // Blocks here
    ]
  };
});

// App-only tool → invisible to chat, only the MCP app can call it
server.tool(
  "submit-name",
  "Submit name from form",
  { name: z.string() },
  async ({ name }) => {
    const greeting = figlet.textSync(`Hello, ${name}!`);
    resolvePromise(greeting);  // Unblocks the chat
    return { content: [{ type: "text", text: greeting }] };
  },
  { visibility: "mcp-app-only" }  // Hidden from chat
);

App-only tool visibility

Tools with visibility: "mcp-app-only" don’t appear in the chat’s tool list but remain callable from MCP apps. This pattern:

  • Keeps the chat’s tool list clean and focused
  • Prevents unintended invocations by the model
  • Creates a clear separation between user-facing and internal tools

When to use MCP Apps

Use case Example
Disambiguation Show a form when the user hasn’t specified enough parameters
Interactive controls Color pickers, configuration panels, light controllers
Data visualization Charts, graphs, and interactive dashboards
Multi-step workflows Forms requiring user choices before proceeding
Rich content Org charts, diagrams, media previews

MCP Apps will appear wherever AI shows up—not just VS Code. Expect rich interfaces in web, mobile, and other IDE environments as the standard matures.

For a practical walkthrough building an MCP app from scratch, see Burke Holland — MCP Apps.