mcp-app-builder
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.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install openclaw-skills-mcp-app-builder
Repository
Skill path: skills/hollaugo/mcp-app-builder
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.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack, Frontend, Backend, Integration.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: openclaw.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install mcp-app-builder into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/openclaw/skills before adding mcp-app-builder to shared team environments
- Use mcp-app-builder for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: mcp-app-builder
description: 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.
---
# MCP App Builder
## Overview
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`.
## Workflow
1. Clarify requirements: what data to visualize, UI pattern (card, table, chart, dashboard, form), data source, and how many tools (start with 1-2).
2. Design tools and UI mapping: define tool names, zod input schemas, output shape, and UI resource URIs (`ui://.../app.html`). Map each tool to one React entrypoint and one HTML file.
3. Scaffold the project: start from `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.
4. Implement the server: use `registerAppTool`/`registerAppResource`, zod schemas directly, `createServer()` factory per request, and `createMcpExpressApp` with `app.all("/mcp", ...)`.
5. Implement the UI: use `useApp` + `useHostStyles`, parse tool results, handle loading/error/empty states, and apply safe-area insets.
6. Build and test: run `npm run build`, then `npm run serve`, then verify via a tunnel if needed.
## Hard Requirements
- Use the exact dependency versions listed in `references/mcp-app-spec.md`.
- Use `registerAppTool`/`registerAppResource` and zod schemas directly (not JSON Schema objects).
- Create a new `McpServer` instance per request via `createServer()`.
- Use `createMcpExpressApp` and `app.all("/mcp", ...)`.
- Bundle UI into single-file HTML via `vite-plugin-singlefile`.
- Use host CSS variables for theme compatibility.
## References
- `references/mcp-app-spec.md` (authoritative spec, patterns, code templates, gotchas)
## Assets
- `assets/mcp-app-template/` (ready-to-copy MCP App skeleton with one tool + UI)
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/mcp-app-spec.md
```markdown
# MCP App Specification (Authoritative)
This reference defines the required dependencies, server/UI patterns, and build workflow for MCP Apps.
## Critical Dependencies (Exact Versions Required)
```json
{
"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"
}
}
```
## Key Differences from Standard MCP Servers
- UI Output: Text/JSON only -> Visual React components
- Package: `@modelcontextprotocol/sdk` only -> also `@modelcontextprotocol/ext-apps`
- Schema: JSON Schema objects -> Zod schemas directly
- Tool registration: `server.tool()` -> `registerAppTool()`
- Resource registration: `server.resource()` -> `registerAppResource()`
- Server pattern: single instance -> `createServer()` factory per request
- Express setup: manual -> `createMcpExpressApp()` helper
- HTTP handler: `app.post("/mcp", ...)` -> `app.all("/mcp", ...)`
## Workflow Summary
### Phase 1: Understand the App
Ask:
- What data will this visualize?
- What UI pattern (card, chart, table, dashboard, form)?
- What API/data source (REST API, database, generated data)?
- How many tools (start with 1-2)?
### Phase 2: Design Tools With UIs
Each tool needs:
- Tool definition with Zod input schema
- Visual React component for rendering results
- Resource URI linking tool to UI
Example mapping:
- Tool: `get-stock-detail`
- Input: `{ symbol: z.string() }`
- Resource URI: `ui://stock-detail/app.html`
- Component: `StockDetailCard.tsx`
### Phase 3: Project Structure
```
{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
```
### Phase 4: Server Implementation (Required Pattern)
```ts
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`);
});
```
### Phase 5: React UI Implementation (Required Pattern)
```tsx
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>
);
```
### Phase 6: Host CSS Variable Integration
```css
@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);
}
```
### Phase 7: Vite Single-File Bundling
```ts
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):
```json
{
"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"
}
}
```
## Common Gotchas
- `zod@^4.1.13` is required. Older versions cause `v3Schema.safeParseAsync is not a function` errors.
- `inputSchema` uses Zod directly, not JSON Schema objects.
- Handler args are destructured: `async ({ symbol })` not `async (args)`.
- Use `app.all` (not `app.post`).
- `createServer()` factory is required per request.
- Use `createMcpExpressApp`, not manual Express setup.
- UI must be bundled into a single HTML file with `viteSingleFile`.
- Import `RESOURCE_MIME_TYPE` from `@modelcontextprotocol/ext-apps/server`.
- Call `useHostStyles(app)` in the React UI.
## Testing
1. Build the UI: `npm run build`
2. Start the server: `npm run serve`
3. Expose locally: `npx cloudflared tunnel --url http://127.0.0.1:3001`
4. Connect to Claude/ChatGPT via the tunnel URL
5. Invoke the tool and verify UI renders
```
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### _meta.json
```json
{
"owner": "hollaugo",
"slug": "mcp-app-builder",
"displayName": "Mcp App Builder",
"latest": {
"version": "0.1.0",
"publishedAt": 1772174926683,
"commit": "https://github.com/openclaw/skills/commit/97760bf2c074a9ed0d3c7101e4d8d820e73ba4a6"
},
"history": []
}
```
### assets/mcp-app-template/package.json
```json
{
"name": "mcp-app-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "npm run build:tool-name",
"build:tool-name": "INPUT=tool-name.html vite build --outDir dist/tool-name",
"serve": "tsx server.ts",
"dev": "npm run build && npm run serve"
},
"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"
}
}
```
### assets/mcp-app-template/postcss.config.js
```javascript
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
```
### assets/mcp-app-template/server.ts
```typescript
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;
const TOOL_NAME = "tool-name";
const toolUIResourceUri = `ui://${TOOL_NAME}/app.html`;
async function fetchData(param: string) {
return {
param,
generatedAt: new Date().toISOString(),
score: Math.max(1, param.length * 7),
notes: `Sample data for ${param}`,
};
}
// Server Factory - CRITICAL: New server per request
export function createServer(): McpServer {
const server = new McpServer({
name: "MCP App Template",
version: "1.0.0",
});
registerAppTool(
server,
TOOL_NAME,
{
title: "Tool Name",
description: "Describe 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,
};
}
);
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`);
});
```
### assets/mcp-app-template/src/index.css
```css
@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);
}
```
### assets/mcp-app-template/src/tool-name.tsx
```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>
);
```
### assets/mcp-app-template/tailwind.config.js
```javascript
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{ts,tsx}", "./tool-name.html"],
theme: {
extend: {},
},
plugins: [],
};
```
### assets/mcp-app-template/tool-name.html
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tool Name</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/tool-name.tsx"></script>
</body>
</html>
```
### assets/mcp-app-template/tsconfig.json
```json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["src", "server.ts", "vite.config.ts", "tool-name.html"]
}
```
### assets/mcp-app-template/vite.config.ts
```typescript
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,
},
},
});
```