Appendix: MCP Apps — Rich UI in chat (experimental)
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
- The MCP server declares a
ui://resource using the ext-apps framework - A tool handler returns a reference to the UI resource instead of plain text
- The chat client renders the HTML/CSS/JS inline in the conversation
- 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
- Declare a promise variable at the top of the server file
- Return
await promisefrom the tool handler—the chat blocks - Create an app-only tool (invisible to chat) that the UI calls on submit
- 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.