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
skills/mcp-app-builder/assets/mcp-app-template/src/tool-name.tsx
import { StrictMode, useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
import { Card } from "./components/Card";
import "./index.css";
interface ToolData {
param: string;
generatedAt: string;
score: number;
notes: string;
}
function LoadingSkeleton() {
return (
<div className="min-h-screen p-4">
<Card>
<div className="h-5 w-40 animate-pulse rounded bg-gray-200" />
<div className="mt-4 space-y-2">
<div className="h-4 w-full animate-pulse rounded bg-gray-200" />
<div className="h-4 w-3/4 animate-pulse rounded bg-gray-200" />
</div>
</Card>
</div>
);
}
function ErrorDisplay({ message }: { message: string }) {
return (
<div className="min-h-screen p-4">
<Card className="border-red-200">
<div className="text-sm font-semibold text-red-600">Error</div>
<div className="mt-2 text-sm text-secondary">{message}</div>
</Card>
</div>
);
}
function EmptyState() {
return (
<div className="min-h-screen p-4">
<Card>
<div className="text-sm font-semibold">No data yet</div>
<div className="mt-2 text-sm text-secondary">
Invoke the tool to see results here.
</div>
</Card>
</div>
);
}
function DataVisualization({ data }: { data: ToolData }) {
const formattedDate = useMemo(() => {
try {
return new Date(data.generatedAt).toLocaleString();
} catch {
return data.generatedAt;
}
}, [data.generatedAt]);
return (
<div className="min-h-screen p-4">
<Card>
<div className="flex items-center justify-between">
<div>
<div className="text-lg font-semibold">Tool Result</div>
<div className="text-xs text-tertiary">{formattedDate}</div>
</div>
<div className="text-sm font-semibold">Score: {data.score}</div>
</div>
<div className="mt-4 card-inner">
<div className="text-sm text-secondary">Parameter</div>
<div className="text-base font-medium">{data.param}</div>
</div>
<div className="mt-4 text-sm text-secondary">{data.notes}</div>
</Card>
</div>
);
}
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) {
setError("No data returned by tool.");
return;
}
try {
const parsed = JSON.parse(text) as ToolData | { error?: string; message?: string };
if (parsed && typeof parsed === "object" && "error" in parsed) {
setError(parsed.message ?? "Tool returned an error.");
return;
}
setData(parsed as ToolData);
} catch {
setError("Failed to parse data");
}
};
},
});
useHostStyles(app);
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>
);