From mcp-ui-expert
This skill should be used when the user asks to "build an MCP UI", "add interactive UI to an MCP tool", "create an MCP App on Cloudflare Workers", "set up viewUUID state", "add cross-session hydration", "register UI resources", "bundle React app for MCP", or needs guidance on production MCP App architecture with two-tier tool patterns, single-file bundling, session state, and host integration.
How this skill is triggered — by the user, by Claude, or both
Slash command
/mcp-ui-expert:build-mcp-uiThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Build production-grade MCP Apps with interactive UIs. This skill extends the basic `create-mcp-app` skill with battle-tested patterns for Cloudflare Workers deployment, two-tier tool architecture, cross-session state persistence, and proper build isolation.
Build production-grade MCP Apps with interactive UIs. This skill extends the basic create-mcp-app skill with battle-tested patterns for Cloudflare Workers deployment, two-tier tool architecture, cross-session state persistence, and proper build isolation.
An MCP App = Server-side tools + Bundled HTML resource + Client-side SDK.
Model calls tool → Server returns result + _meta.viewUUID
→ Host renders resource UI (iframe)
→ UI receives tool input/result via SDK callbacks
→ UI calls app-only tools via callServerTool()
→ UI updates model context via updateModelContext()/sendMessage()
| Target | When to Use | Key Differences |
|---|---|---|
| Cloudflare Workers | Production, remote MCP, OAuth needed | Durable Objects, KV, R2; HTML imported as text module |
| Node.js (stdio) | Local dev, Claude Desktop only | tsx to run TS; simpler setup |
| Node.js (HTTP) | Remote without Cloudflare | Standard HTTP server; deploy anywhere |
| Framework | Import | Best For |
|---|---|---|
| React | @modelcontextprotocol/ext-apps/react | Rich UIs, component state, familiar DX |
| Vanilla JS | @modelcontextprotocol/ext-apps | Simple UIs, minimal bundle, full lifecycle control |
For Cloudflare Workers with React UI:
project/
├── src/ # Worker code (NO DOM types)
│ ├── index.ts # McpAgent Durable Object, fetch handler
│ ├── server.ts # Tool & resource registration
│ └── env.d.ts # Env interface extensions
├── app/ # React UI (separate tsconfig with DOM libs)
│ ├── index.html # Entry HTML template
│ ├── vite.config.ts # Vite + singlefile plugin
│ ├── tsconfig.json # DOM libs enabled
│ └── src/
│ ├── main.tsx # React entry point
│ ├── App.tsx # Main component with useApp hook
│ └── components/ # UI components
├── tsconfig.json # Root config (EXCLUDES app/)
├── wrangler.jsonc # Durable Objects, KV, rules
└── package.json
Critical: TypeScript isolation. Root tsconfig.json must exclude app/ to prevent DOM vs Worker type conflicts. The app/tsconfig.json includes DOM and DOM.Iterable libs.
The entire React app must bundle into a single HTML file for import as a text module.
app/vite.config.ts:
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
root: __dirname,
plugins: [react(), tailwindcss(), viteSingleFile()],
build: { outDir: "dist", emptyOutDir: true },
});
wrangler.jsonc — enable HTML text imports:
"rules": [{ "type": "Text", "globs": ["**/*.html"], "fallthrough": true }]
Import in server code:
import htmlContent from "../app/dist/index.html" with { type: "text" };
This is the core architecture. Register two linked tools:
_meta.viewUUID for state tracking_meta.ui.resourceUrivisibility: ["app"]app.callServerTool()See references/two-tier-tool-pattern.md for complete server-side code.
import { RESOURCE_MIME_TYPE, registerAppResource, registerAppTool } from "@modelcontextprotocol/ext-apps/server";
import htmlContent from "../app/dist/index.html" with { type: "text" };
const RESOURCE_URI = "ui://my-app/mcp-app";
registerAppResource(
server,
"My App UI",
RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async (uri) => ({
contents: [{ uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: htmlContent }],
}),
);
import { useApp, useDocumentTheme, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
function App() {
const { app, error } = useApp({ appInfo: { name: "MyApp", version: "1.0.0" }, capabilities: {} });
useHostStyles(app, app?.getHostContext());
const theme = useDocumentTheme();
const isDark = theme === "dark";
const handleAction = async () => {
const result = await app.callServerTool({ name: "my-app-tool", arguments: { ... } });
// Process result.content, result.structuredContent
};
}
Register ALL handlers BEFORE app.connect():
const app = new App({ name: "MyApp", version: "1.0.0" });
app.ontoolinput = (params) => { /* Receive tool args from model */ };
app.ontoolresult = (result) => {
// Receive tool result — extract viewUUID for state hydration
if (result._meta?.viewUUID) {
hydrateFromView(result._meta.viewUUID);
}
};
app.onhostcontextchanged = (ctx) => { /* Apply theme, styles, safe areas */ };
app.onteardown = async () => ({});
await app.connect();
The viewUUID pattern enables cross-session image/data persistence:
viewUUID = randomUUID() and returns it in _metaviewUUID as parameter, stores view:{viewUUID} → dataId in KVontoolresult, uses it to hydrate existing dataview:{viewUUID} to retrieve data across sessionsSee references/viewuuid-state-pattern.md for the complete flow.
Two mechanisms for the UI to inform the model:
// Update model's context silently (no user message)
await app.updateModelContext({
content: [{ type: "text", text: `Generated item with id "${itemId}"` }],
});
// Send a message as the user (triggers model response)
await app.sendMessage({
role: "user",
content: [{ type: "text", text: `Load item "${itemId}"` }],
});
{
"scripts": {
"build:app": "vite build --config app/vite.config.ts",
"dev:app": "vite build --config app/vite.config.ts --watch",
"dev": "bun run build:app && concurrently --kill-others \"bun run dev:app\" \"wrangler dev\"",
"deploy": "bun run build:app && wrangler deploy"
}
}
vite-plugin-singlefile — Without it, Vite outputs multiple files that cannot be imported as text@cloudflare/workers-typesapp.connect()_meta.ui.resourceUrivisibility: ["app"] — App-only tools must be hidden from the modelcontent array with text for non-UI hostsctx.safeAreaInsets in onhostcontextchangedbuild:app must run before wrangler deployvar(--color-background-*), var(--color-text-*)) for theme integrationreferences/two-tier-tool-pattern.md — Complete server-side tool registration with model-facing and app-only toolsreferences/viewuuid-state-pattern.md — Full viewUUID lifecycle for cross-session data persistencereferences/cloudflare-workers-setup.md — Wrangler config, Durable Objects, KV, R2, environment typesreferences/react-app-template.md — Complete React app template with all hooks and componentsClone for API docs and examples:
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
npx claudepluginhub codewithpassion/plugin-marketplace --plugin mcp-ui-expert