Build new MCP Apps (MCP servers with React UI output) using @modelcontextprotocol/ext-apps and the M
Build new MCP Apps (MCP servers with React UI output) using @modelcontextprotocol/ext-apps and the MCP SDK. Use when asked to scaffold or implement MCP App servers, add UI-rendering tools/resources, or migrate a standard MCP server to an MCP App with Vite single-file UI bundles.
Grab the skill package ZIP file using the button above.
Extract and move the folder into your AI agent's skills directory.
Your agent now knows the skill. Just ask it to perform the task!
This is the raw instruction document consumed by your AI agent.
Create MCP Apps that expose tools with visual React UIs for ChatGPT or Claude. Follow the exact dependency versions and server/UI patterns in references/mcp-app-spec.md.
ui://.../app.html). Map each tool to one React entrypoint and one HTML file.assets/mcp-app-template/ when possible, then customize tool names, schemas, and UI. Ensure package.json uses the exact versions, plus tsconfig.json, vite.config.ts, Tailwind + PostCSS, and per-tool build scripts.registerAppTool/registerAppResource, zod schemas directly, createServer() factory per request, and createMcpExpressApp with app.all("/mcp", ...).useApp + useHostStyles, parse tool results, handle loading/error/empty states, and apply safe-area insets.npm run build, then npm run serve, then verify via a tunnel if needed.references/mcp-app-spec.md.registerAppTool/registerAppResource and zod schemas directly (not JSON Schema objects).McpServer instance per request via createServer().createMcpExpressApp and app.all("/mcp", ...).vite-plugin-singlefile.references/mcp-app-spec.md (authoritative spec, patterns, code templates, gotchas)assets/mcp-app-template/ (ready-to-copy MCP App skeleton with one tool + UI)Browse additional components, config blocks, and reference sheets included in the ZIP.
mcp-app-builder/SKILL.md
mcp-app-builder/agents/openai.yaml
mcp-app-builder/assets/mcp-app-template/.gitignore
mcp-app-builder/assets/mcp-app-template/package.json
mcp-app-builder/assets/mcp-app-template/postcss.config.js
mcp-app-builder/assets/mcp-app-template/server.ts
mcp-app-builder/assets/mcp-app-template/src/components/Card.tsx
mcp-app-builder/assets/mcp-app-template/src/index.css
mcp-app-builder/assets/mcp-app-template/src/tool-name.tsx
mcp-app-builder/assets/mcp-app-template/tailwind.config.js
mcp-app-builder/assets/mcp-app-template/tool-name.html
mcp-app-builder/assets/mcp-app-template/tsconfig.json
mcp-app-builder/assets/mcp-app-template/vite.config.ts
mcp-app-builder/references/mcp-app-spec.md
This reference defines the required dependencies, server/UI patterns, and build workflow for MCP Apps.
{
"dependencies": {
"@modelcontextprotocol/ext-apps": "^1.0.0",
"@modelcontextprotocol/sdk": "^1.24.0",
"zod": "^4.1.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"cors": "^2.8.5",
"express": "^5.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0",
"tailwindcss": "^3.4.17",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
}
@modelcontextprotocol/sdk only -> also @modelcontextprotocol/ext-appsserver.tool() -> registerAppTool()server.resource() -> registerAppResource()createServer() factory per requestcreateMcpExpressApp() helperapp.post("/mcp", ...) -> app.all("/mcp", ...)Ask:
Each tool needs:
Example mapping:
get-stock-detail{ symbol: z.string() }ui://stock-detail/app.htmlStockDetailCard.tsx{app-name}/
├── server.ts
├── package.json
├── vite.config.ts
├── tsconfig.json
├── tailwind.config.js
├── postcss.config.js
├── .gitignore
├── src/
│ ├── index.css
│ ├── {tool-name}.tsx
│ └── components/
└── {tool-name}.html
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import cors from "cors";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
// Works both from source (server.ts) and compiled (dist/server.js)
const DIST_DIR = import.meta.filename.endsWith(".ts")
? path.join(import.meta.dirname, "dist")
: import.meta.dirname;
// Resource URIs
const toolUIResourceUri = "ui://tool-name/app.html";
// Server Factory - CRITICAL: New server per request
export function createServer(): McpServer {
const server = new McpServer({
name: "App Name",
version: "1.0.0",
});
// Register tool with zod schema (NOT JSON Schema)
registerAppTool(
server,
"tool-name",
{
title: "Tool Title",
description: "When to use this tool...",
inputSchema: {
param: z.string().describe("Parameter description"),
},
_meta: { ui: { resourceUri: toolUIResourceUri } },
},
async ({ param }): Promise<CallToolResult> => {
const result = await fetchData(param);
return {
content: [{ type: "text", text: JSON.stringify(result) }],
structuredContent: result,
};
}
);
// Register UI resource
registerAppResource(
server,
toolUIResourceUri,
toolUIResourceUri,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "tool-name", "tool-name.html"),
"utf-8"
);
return {
contents: [
{
uri: toolUIResourceUri,
mimeType: RESOURCE_MIME_TYPE,
text: html,
},
],
};
}
);
return server;
}
// HTTP Server - MUST use createMcpExpressApp and app.all
const port = parseInt(process.env.PORT ?? "3001", 10);
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req, res) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
app.listen(port, () => {
console.log(`Server listening on http://localhost:${port}/mcp`);
});
import { StrictMode, useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
import "./index.css";
interface ToolData {
// Define based on tool output
}
function ToolUI() {
const [data, setData] = useState<ToolData | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const { app } = useApp({
appInfo: { name: "Tool Name", version: "1.0.0" },
onAppCreated: (app) => {
app.ontoolresult = (result) => {
setLoading(false);
const text = result.content?.find((c) => c.type === "text")?.text;
if (text) {
try {
const parsed = JSON.parse(text);
if (parsed.error) {
setError(parsed.message);
} else {
setData(parsed);
}
} catch {
setError("Failed to parse data");
}
}
};
},
});
// Apply host CSS variables for theme integration
useHostStyles(app);
// Handle safe area insets for mobile
useEffect(() => {
if (!app) return;
app.onhostcontextchanged = (ctx) => {
if (ctx.safeAreaInsets) {
const { top, right, bottom, left } = ctx.safeAreaInsets;
document.body.style.padding = `${top}px ${right}px ${bottom}px ${left}px`;
}
};
}, [app]);
if (loading) return <LoadingSkeleton />;
if (error) return <ErrorDisplay message={error} />;
if (!data) return <EmptyState />;
return <DataVisualization data={data} />;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ToolUI />
</StrictMode>
);
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
background: var(--color-background-primary, #ffffff);
color: var(--color-text-primary, #1a1a1a);
}
.card {
background: var(--color-background-primary, #ffffff);
border: 1px solid var(--color-border-primary, #e5e5e5);
border-radius: var(--border-radius-lg, 12px);
padding: 1.5rem;
}
.card-inner {
background: var(--color-background-secondary, #f5f5f5);
border-radius: var(--border-radius-md, 8px);
padding: 1rem;
}
.text-secondary {
color: var(--color-text-secondary, #525252);
}
.text-tertiary {
color: var(--color-text-tertiary, #737373);
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
export default defineConfig({
plugins: [react(), viteSingleFile()],
build: {
outDir: "dist",
rollupOptions: {
input: process.env.INPUT,
},
},
});
Build scripts (one entry point per tool):
{
"scripts": {
"build": "npm run build:tool1 && npm run build:tool2",
"build:tool1": "INPUT=tool1.html vite build --outDir dist/tool1",
"build:tool2": "INPUT=tool2.html vite build --outDir dist/tool2",
"serve": "tsx server.ts",
"dev": "npm run build && npm run serve"
}
}
zod@^4.1.13 is required. Older versions cause v3Schema.safeParseAsync is not a function errors.inputSchema uses Zod directly, not JSON Schema objects.async ({ symbol }) not async (args).app.all (not app.post).createServer() factory is required per request.createMcpExpressApp, not manual Express setup.viteSingleFile.RESOURCE_MIME_TYPE from @modelcontextprotocol/ext-apps/server.useHostStyles(app) in the React UI.npm run buildnpm run servenpx cloudflared tunnel --url http://127.0.0.1:3001