---
name: Create MCP App
description: This skill should be used when the user asks to "create an MCP App", "add a UI to an MCP tool", "build an interactive MCP View", "scaffold an MCP App", or needs guidance on MCP Apps SDK patterns, UI-resource registration, MCP App lifecycle, or host integration. Provides comprehensive guidance for building MCP Apps with interactive UIs.
---
# Create MCP App
Build interactive UIs that run inside MCP-enabled hosts like Claude Desktop, Goose, VS Code. An MCP App combines an MCP tool with an HTML resource to display rich, interactive content.
## Core Concept: Tool + Resource
Every MCP App requires two parts linked together:
1. **Tool** - Called by the LLM/host, returns data
2. **Resource** - Serves the bundled HTML UI that displays the data
3. **Link** - The tool's `_meta.ui.resourceUri` references the resource
```
Host calls tool → Server returns result → Host renders resource UI → UI receives result
```
## Architecture Decision: Direct Composition vs Dynamic Rendering
### ✅ RECOMMENDED: Direct Component Composition (Per-App Files)
Each MCP App is a standalone Vite-bundled HTML file that directly imports and composes the components it needs. The layout is hardcoded — no runtime interpretation layer.
```tsx
// pipeline-board-app.tsx
import { PageHeader } from '../components/layout/PageHeader';
import { KanbanBoard } from '../components/data/KanbanBoard';
import { MetricCard } from '../components/data/MetricCard';
export function PipelineBoardApp({ data }) {
return (
);
}
```
**Why this works:**
- Each app knows exactly what it renders — no dynamic interpretation
- React state is stable (no tree replacement issues)
- Easy to debug — one app, one file, one purpose
- Shared component library across all apps via imports
- Vite bundles each app into a single HTML file
### ❌ AVOID: Dynamic JSON Tree Rendering
Do NOT build a universal renderer that receives a JSON UI tree at runtime and dynamically maps type strings to components. This approach causes:
- **State destruction** — Every tool result replaces the entire tree, unmounting all React components and destroying local state (forms, drag positions, open dropdowns)
- **Key instability** — Dynamically generated keys cause React to unmount/remount entire subtrees
- **Silent failures** — Registry lookups for unknown types fail silently
- **Debugging nightmare** — Issues are in the interpretation layer, not the components
If you MUST use dynamic rendering (e.g., AI-generated UIs), apply these mitigations:
- Use `mergeUITrees(prev, next)` instead of wholesale tree replacement
- Ensure ALL element keys are semantic and deterministic (`"pipeline-kanban"` not `"el-${i}"`)
- Separate data-result tool calls from navigation-intent tool calls (only navigation should replace the tree)
## Interactivity Patterns (Ranked by Reliability)
### Pattern 1: Client-Side State Only ⭐ MOST RELIABLE
Send all data upfront via `ontoolresult`. The UI handles ALL interactions locally with React state. No server calls needed for sorting, filtering, drag-drop, tab switching, form editing, etc.
```tsx
// All interactivity is local — works on EVERY host
const [items, setItems] = useState(data.items);
const onDragEnd = (draggedId, targetColumn) => {
setItems(prev => prev.map(item =>
item.id === draggedId ? { ...item, column: targetColumn } : item
));
};
```
**Use for:** DataTable sorting/filtering, KanbanBoard drag-drop, TabGroup switching, form editing, chart interactions — anything that doesn't NEED fresh server data.
### Pattern 2: `callServerTool` for On-Demand Data
Only use when the UI needs fresh data FROM the server (not for writes). Requires host support.
```tsx
const loadMore = async () => {
const result = await app.callServerTool({
name: "search_contacts",
arguments: { query, offset: page * 25 }
});
setContacts(prev => [...prev, ...result.contacts]);
};
```
**Use for:** Pagination, search-as-you-type, expanding tree nodes, loading related records.
### Pattern 3: `updateModelContext` for Background Sync
Silently inform the model what the user did in the UI. No visible message appears in chat. The model has this context for its next interaction.
```tsx
const onUserAction = (action) => {
// Update local state immediately
setLocalState(newState);
// Silently tell the model
app.updateModelContext({
text: `User moved Deal "${deal.name}" to stage "${newStage}"`
});
};
```
**Use for:** Tracking user interactions, keeping model informed of UI state changes.
### Pattern 4: `sendMessage` for Triggering Model Actions
Sends a visible message in the conversation that triggers the model to respond and take action.
```tsx
const onSave = () => {
app.sendMessage({
text: `Please save these changes:\n${getChangesSummary()}`
});
};
```
**Use for:** Explicit save/submit actions, batch syncing changes to server via the model.
### Pattern 5: App-Only Tools (`visibility: ["app"]`)
Tools hidden from the model, only callable from the UI. Useful for UI-specific server operations.
```tsx
_meta: { ui: { resourceUri, visibility: ["app"] } }
```
**Use for:** Polling, refresh buttons, pagination controls, form submissions.
## Hybrid Interactivity (Recommended for Write Operations)
For operations that need to persist to a server (creating invoices, updating deals, etc.), use capability detection to choose the best path:
```tsx
function useSmartAction() {
const { app } = useMCPApp();
const canCallTools = !!app?.getHostCapabilities()?.serverTools;
const executeAction = async (toolName, args, description) => {
if (canCallTools) {
// Direct path: call server tool immediately
try {
return await app.callServerTool({ name: toolName, arguments: args });
} catch (err) {
// Fallback on failure
app.updateModelContext({ text: `Action failed, please retry: ${description}` });
}
} else {
// Fallback: track locally, auto-save via sendMessage after debounce
trackChange({ toolName, args, description });
app.updateModelContext({ text: `User action: ${description}` });
}
};
return { executeAction, canCallTools };
}
```
**Flow on hosts WITH `callServerTool`:** button click → server call → instant result
**Flow on hosts WITHOUT `callServerTool`:** button click → local state update → auto-save after 3s idle → model executes writes
## Host Compatibility Matrix
As of **2026-01-26** (MCP Apps v1.0.1 — first official stable release):
| Host | Renders UI | `callServerTool` | `updateModelContext` | `sendMessage` | Transport |
|------|-----------|-------------------|----------------------|---------------|-----------|
| Claude Desktop | ✅ | ✅ | ✅ | ✅ | stdio |
| Claude Web | ✅ | ✅ | ✅ | ✅ | HTTP |
| ChatGPT | ✅ | ✅ | ✅ | ✅ | HTTP |
| VS Code Insiders | ✅ | ✅ | ✅ | ✅ | stdio |
| Goose | ✅ | ⚠️ Partial | ✅ | ✅ | stdio/HTTP |
| Postman | ✅ | ✅ | ✅ | ✅ | HTTP |
| MCPJam | ✅ | ✅ | ✅ | ✅ | HTTP |
| JetBrains IDEs | 🔜 Coming | — | — | — | stdio |
**Rule:** Design for Pattern 1 (client-side state) first. Layer on `callServerTool` as progressive enhancement.
**Transport rule:** Support BOTH stdio and HTTP in your server entry point — Claude Desktop and VS Code use stdio, web hosts use HTTP.
## PostMessage Bridge Protocol
MCP Apps use **JSON-RPC 2.0 over `window.postMessage`**:
- App → Host: `{ jsonrpc: "2.0", id: N, method: "tools/call", params: { name, arguments } }`
- Host → App: `{ jsonrpc: "2.0", id: N, result: { content: [...] } }`
- Validates `event.source` (Window identity, NOT origin string)
- Invalid messages are silently dropped (Zod validation)
**Key requirements for `callServerTool` to work:**
1. Host must declare `serverTools: {}` in `hostCapabilities` during `ui/initialize`
2. Host must register `oncalltool` handler on `AppBridge`
3. Host must relay `tools/call` requests to the real MCP server
**Known failure modes:**
- `srcdoc` iframes get origin `"null"` — use `document.write()` instead
- Init handshake (`ui/initialize`) never completing — `callServerTool` hangs forever
- Double-iframe `event.source` mismatch — messages silently dropped
## Quick Start Decision Tree
### Framework Selection
| Framework | SDK Support | Best For |
|-----------|-------------|----------|
| React | `useApp` hook provided | Teams familiar with React |
| Vanilla JS | Manual lifecycle | Simple apps, no build complexity |
| Vue/Svelte/Preact/Solid | Manual lifecycle | Framework preference |
### Project Structure (Multi-App Server)
For servers with many views (like a CRM), share components across apps:
```
src/
├── components/ # Shared component library
│ ├── layout/ # PageHeader, Card, SplitLayout, StatsGrid, Section
│ ├── data/ # DataTable, KanbanBoard, MetricCard, Timeline, etc.
│ ├── charts/ # BarChart, LineChart, PieChart, FunnelChart
│ ├── interactive/ # ContactPicker, InvoiceBuilder, FormGroup
│ └── shared/ # ActionButton, SearchBar, Toast, Modal
├── hooks/ # useCallTool, useSmartAction, useHostCapabilities
├── styles/ # base.css, interactive.css
├── apps/ # Individual app entry points
│ ├── contact-grid/ # Contact list app
│ │ ├── App.tsx
│ │ ├── index.html
│ │ └── vite.config.ts
│ ├── pipeline-board/ # Kanban board app
│ │ ├── App.tsx
│ │ ├── index.html
│ │ └── vite.config.ts
│ └── invoice-preview/ # Invoice view app
│ ├── App.tsx
│ ├── index.html
│ └── vite.config.ts
├── server.ts # MCP server with all tools + resources
└── package.json
```
Each app has its own `vite.config.ts` with `vite-plugin-singlefile`, outputting to `dist/app-ui/{app-name}.html`.
## Getting Reference Code
**SDK Version:** `@modelcontextprotocol/ext-apps` v1.0.1 (Stable spec: 2026-01-26)
**Spec:** [SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) — first official MCP extension
Clone the SDK repository for working examples and API documentation:
```bash
git clone --branch "v$(npm view @modelcontextprotocol/ext-apps version)" --depth 1 https://github.com/modelcontextprotocol/ext-apps.git /tmp/mcp-ext-apps
```
### Framework Templates
Learn and adapt from `/tmp/mcp-ext-apps/examples/basic-server-{framework}/`:
| Template | Key Files |
|----------|-----------|
| `basic-server-vanillajs/` | `server.ts`, `src/mcp-app.ts`, `mcp-app.html` |
| `basic-server-react/` | `server.ts`, `src/mcp-app.tsx` (uses `useApp` hook) |
| `basic-server-vue/` | `server.ts`, `src/App.vue` |
| `basic-server-svelte/` | `server.ts`, `src/App.svelte` |
| `basic-server-preact/` | `server.ts`, `src/mcp-app.tsx` |
| `basic-server-solid/` | `server.ts`, `src/mcp-app.tsx` |
### API Reference (Source Files)
Read JSDoc documentation directly from `/tmp/mcp-ext-apps/src/`:
| File | Contents |
|------|----------|
| `src/app.ts` | `App` class, handlers, lifecycle |
| `src/server/index.ts` | `registerAppTool`, `registerAppResource`, visibility |
| `src/spec.types.ts` | All types: `McpUiHostContext`, CSS variables, display modes |
| `src/styles.ts` | `applyDocumentTheme`, `applyHostStyleVariables`, `applyHostFonts` |
| `src/react/useApp.tsx` | `useApp` hook for React apps |
| `src/react/useHostStyles.ts` | `useHostStyles`, `useHostStyleVariables`, `useHostFonts` |
### Advanced Examples
| Example | Pattern Demonstrated |
|---------|---------------------|
| `examples/shadertoy-server/` | Streaming partial input + visibility-based pause/play |
| `examples/wiki-explorer-server/` | `callServerTool` for interactive data fetching |
| `examples/system-monitor-server/` | Polling pattern with interval management |
| `examples/video-resource-server/` | Binary/blob resources |
| `examples/sheet-music-server/` | `ontoolinput` - processing args before execution |
| `examples/threejs-server/` | `ontoolinputpartial` - streaming/progressive rendering |
| `examples/map-server/` | `updateModelContext` - keeping model informed of UI state |
| `examples/transcript-server/` | `updateModelContext` + `sendMessage` - background updates + user messages |
| `examples/cohort-heatmap-server/` | Complex data visualization (heatmap grid) |
| `examples/scenario-modeler-server/` | Multi-parameter interactive modeling |
| `examples/budget-allocator-server/` | Form with interdependent calculated fields |
| `examples/customer-segmentation-server/` | Data filtering + visualization combo |
| `examples/pdf-server/` | Document rendering in iframe |
| `examples/qr-server/` | Python MCP server (non-TypeScript example) |
| `examples/say-server/` | Simple demo — minimal MCP App |
| `examples/quickstart/` | Official quickstart tutorial (start here) |
| `examples/basic-host/` | Reference host implementation using `AppBridge` |
## Critical Implementation Notes
### Adding Dependencies
```bash
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk express cors zod
npm install -D typescript tsx vite vite-plugin-singlefile @types/express @types/cors @types/node concurrently cross-env
```
### Server-Side Registration (Official Pattern — v1.0.1)
**ALWAYS use `registerAppTool()` and `registerAppResource()`** from `@modelcontextprotocol/ext-apps/server`. Do NOT manually register tools and resources separately — the helpers handle proper metadata linkage, MIME types, and resource registration.
```typescript
// server.ts
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import fs from "node:fs/promises";
import path from "node:path";
const DIST_DIR = path.join(import.meta.dirname, "dist");
export function createServer(): McpServer {
const server = new McpServer({
name: "My MCP App Server",
version: "1.0.0",
});
const resourceUri = "ui://my-server/contact-grid.html";
// Register tool WITH UI metadata
registerAppTool(
server,
"view_contact_grid",
{
title: "Contact Grid", // Human-readable title
description: "Display contact search results",
inputSchema: { query: { type: "string" } },
_meta: { ui: { resourceUri } }, // Links tool → resource
},
async (args) => {
const contacts = await fetchContacts(args.query);
return {
content: [{ type: "text", text: JSON.stringify(contacts) }], // Text fallback
};
},
);
// Register resource that serves the bundled HTML
registerAppResource(
server,
resourceUri, // URI to match
"contact-grid", // Resource name
{ mimeType: RESOURCE_MIME_TYPE }, // ALWAYS use this constant
async () => {
const html = await fs.readFile(
path.join(DIST_DIR, "contact-grid.html"), "utf-8"
);
return {
contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }],
};
},
);
return server;
}
```
### Server Entry Point (HTTP + Stdio)
Servers should support BOTH HTTP (for web/testing) and stdio (for Claude Desktop, VS Code):
```typescript
// main.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import express from "express";
import { createServer } from "./server.js";
async function main() {
if (process.argv.includes("--stdio")) {
// Stdio transport — for Claude Desktop, VS Code
await createServer().connect(new StdioServerTransport());
} else {
// HTTP transport — for web hosts, testing
const port = parseInt(process.env.PORT ?? "3001", 10);
const app = express();
app.use(cors());
app.use(express.json());
app.all("/mcp", async (req, res) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => { transport.close(); server.close(); });
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
app.listen(port, () => console.log(`MCP server: http://localhost:${port}/mcp`));
}
}
main().catch(console.error);
```
### Package Scripts
```json
"scripts": {
"build": "tsc --noEmit && vite build",
"start": "concurrently 'vite build --watch' 'tsx watch main.ts'",
"serve": "tsx main.ts",
"stdio": "tsx main.ts --stdio"
}
```
### Handler Registration Order
Register ALL handlers BEFORE calling `app.connect()`:
```typescript
const app = new App({ name: "My App", version: "1.0.0" });
app.ontoolinput = (params) => { /* handle input */ };
app.ontoolresult = (result) => { /* handle result */ };
app.onhostcontextchanged = (ctx) => { /* handle context */ };
app.onteardown = async () => { return {}; };
await app.connect();
```
### Tool Visibility
```typescript
// Default: visible to both model and app
_meta: { ui: { resourceUri, visibility: ["model", "app"] } }
// UI-only (hidden from model) - for refresh, form submissions
_meta: { ui: { resourceUri, visibility: ["app"] } }
// Model-only (app cannot call)
_meta: { ui: { resourceUri, visibility: ["model"] } }
```
### Content Security Policy (CSP) & Permissions
If your app needs to load external resources (CDN scripts, map tiles, API endpoints) or access device capabilities (microphone, camera), declare them in `_meta.ui`:
```typescript
_meta: {
ui: {
resourceUri,
// Allow loading from specific external origins
csp: {
"script-src": ["https://cdn.example.com"],
"img-src": ["https://tiles.mapbox.com", "https://api.mapbox.com"],
"connect-src": ["https://api.example.com"],
},
// Request device permissions (host will prompt user for consent)
permissions: ["microphone", "camera"],
},
}
```
**Default:** Apps run in a sandboxed iframe with NO external access. If you don't declare CSP, all external requests are blocked. Only declare what you actually need.
### Host Styling Integration
**React:**
```typescript
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app);
```
**CSS variables available after applying:**
```css
.container {
background: var(--color-background-secondary);
color: var(--color-text-primary);
font-family: var(--font-sans);
border-radius: var(--border-radius-md);
}
```
### Safe Area Handling
```typescript
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`;
}
};
```
### Streaming Partial Input
For large tool inputs, use `ontoolinputpartial` to show progress:
```typescript
app.ontoolinputpartial = (params) => {
const args = params.arguments; // Healed partial JSON - always valid
preview.textContent = JSON.stringify(args, null, 2);
};
app.ontoolinput = (params) => {
render(params.arguments); // Final complete input
};
```
### Visibility-Based Resource Management
Pause expensive operations when scrolled out of viewport:
```typescript
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) animation.play();
else animation.pause();
});
});
observer.observe(document.querySelector(".main"));
```
### Fullscreen Mode
```typescript
app.onhostcontextchanged = (ctx) => {
if (ctx.availableDisplayModes?.includes("fullscreen")) {
fullscreenBtn.style.display = "block";
}
if (ctx.displayMode) {
container.classList.toggle("fullscreen", ctx.displayMode === "fullscreen");
}
};
async function toggleFullscreen() {
const result = await app.requestDisplayMode({
mode: currentMode === "fullscreen" ? "inline" : "fullscreen"
});
currentMode = result.mode;
}
```
## Common Mistakes to Avoid
1. **Dynamic JSON tree rendering** — Use direct component composition per app instead
2. **Replacing entire UI tree on every tool result** — Merge trees or use stable component structure
3. **Relying on `callServerTool` for basic interactivity** — Use client-side state first
4. **No capability detection** — Always check `app.getHostCapabilities()?.serverTools` before using `callServerTool`
5. **Handlers after connect()** — Register ALL handlers BEFORE `app.connect()`
6. **Missing single-file bundling** — Must use `vite-plugin-singlefile`
7. **Forgetting resource registration** — Both tool AND resource must be registered
8. **No text fallback** — Always provide `content` array for non-UI hosts
9. **Hardcoded styles** — Use host CSS variables for theme integration
10. **No streaming for large inputs** — Use `ontoolinputpartial` for progress
11. **No timeout on `callServerTool`** — Always wrap in `Promise.race` with 5s timeout; degrade to read-only on failure (see Graceful Degradation section)
12. **Sending `sendMessage` on every micro-edit** — Batch changes locally, submit once on explicit save action
13. **Inconsistent status colors across apps** — Use the shared `StatusBadge` with standard green/yellow/red/blue convention
14. **No dirty-state indicator on config UIs** — Users must know they have unsaved changes; use `useDirtyState` hook + `SaveBar` component
## Testing
### Using basic-host
```bash
# Terminal 1: Build and run your server
npm run build && npm run serve
# Terminal 2: Run basic-host
cd /tmp/mcp-ext-apps/examples/basic-host
npm install
SERVERS='["http://localhost:3001/mcp"]' npm run start
# Open http://localhost:8080
```
### Debug with sendLog
```typescript
await app.sendLog({ level: "info", data: "Debug message" });
await app.sendLog({ level: "error", data: { error: err.message } });
```
### Standalone Testing
Build and test each app as a standalone web page with hardcoded data before integrating with the MCP server. This catches rendering/interactivity bugs before the MCP lifecycle adds complexity:
```bash
# Add a dev mode that uses mock data
VITE_DEV_MODE=true npm run dev
```
```tsx
const data = import.meta.env.VITE_DEV_MODE
? MOCK_DATA
: useToolResult();
```
---
## Real-World Examples from Production Apps
**Reference:** 11 GHL MCP Apps built in `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/ghl-mcp-apps-only/`
### 1. Contact Grid (Data Table)
**Tool:** `view_contact_grid`
**Use case:** Display contact search results in sortable grid
**Pattern:** Client-side sorting/filtering of initial dataset
**Components:** DataTable with column headers, row selection, status badges
**Data flow:** Send all contacts upfront → All interactions are local (React state)
```typescript
{
name: 'view_contact_grid',
description: 'Display contact search results in a data grid',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results (default: 25)' },
},
},
_meta: {
ui: { resourceUri: 'ui://ghl/contact-grid' },
},
}
```
### 2. Pipeline Board (Kanban)
**Tool:** `view_pipeline_board`
**Use case:** Visual sales pipeline with drag-drop
**Pattern:** Hybrid — Client-side drag-drop + `updateModelContext` to track moves
**Components:** KanbanBoard, OpportunityCard, StageColumn
**Interactivity:** Drag opportunity → Update local state → Silently inform model via `updateModelContext`
```typescript
const onDragEnd = (opportunityId: string, newStageId: string) => {
// Update local state immediately
setOpportunities(prev => prev.map(opp =>
opp.id === opportunityId ? { ...opp, stageId: newStageId } : opp
));
// Inform model (no visible message)
app?.updateModelContext({
text: `User moved opportunity "${opp.name}" to stage "${newStage.name}"`
});
};
```
### 3. Calendar View
**Tool:** `view_calendar`
**Use case:** Monthly appointment calendar
**Pattern:** Client-side navigation between months
**Components:** CalendarGrid, EventMarker, MonthNav
**State:** Month/year navigation is purely client-side
### 4. Opportunity Card (Detail View)
**Tool:** `view_opportunity_card`
**Use case:** Single opportunity detail card
**Pattern:** Static display (no interactivity needed)
**Components:** Card, LabeledField, StatusBadge, Timeline
**Data:** All details sent upfront
### 5. Invoice Preview
**Tool:** `view_invoice`
**Use case:** Invoice detail with line items
**Pattern:** Static display
**Components:** InvoiceHeader, LineItemTable, TotalsSummary
**Layout:** Fixed header + scrollable line items + sticky totals
### 6. Campaign Stats Dashboard
**Tool:** `show_campaign_stats`
**Use case:** Marketing campaign performance metrics
**Pattern:** Client-side stat calculations
**Components:** MetricCard, ProgressBar, TrendIndicator
**Calculations:** CTR, conversion rate, ROI calculated in UI from raw data
### 7. Agent Stats Dashboard
**Tool:** `show_agent_stats`
**Use case:** Agent performance leaderboard
**Pattern:** Client-side sorting/ranking
**Components:** LeaderboardTable, PerformanceBadge, RankIndicator
**Interactivity:** Sort by different metrics (calls, revenue, close rate)
### 8. Contact Timeline
**Tool:** `view_contact_timeline`
**Use case:** Activity feed for a contact
**Pattern:** Static display with expandable items
**Components:** TimelineItem, EventIcon, ExpandableDetails
**Layout:** Vertical timeline with timestamps
### 9. Workflow Status
**Tool:** `view_workflow_status`
**Use case:** Workflow execution progress
**Pattern:** Hybrid — Display current state + optional refresh
**Components:** WorkflowSteps, ProgressIndicator, StepStatus
**Refresh:** Optional `callServerTool` to re-fetch if host supports it
### 10. Quick Book (Appointment Booking)
**Tool:** `show_quick_book`
**Use case:** Embedded appointment booking form
**Pattern:** Hybrid — Form state local, submission via `sendMessage`
**Components:** DateTimePicker, ContactSelector, ServiceDropdown
**Flow:** User fills form → Click submit → `sendMessage` with booking details → Model executes booking
```typescript
const onSubmit = () => {
app?.sendMessage({
text: `Please book this appointment:\nContact: ${contact.name}\nDate: ${selectedDate}\nService: ${service}`
});
};
```
### 11. Dashboard (Multi-Widget)
**Tool:** `view_dashboard`
**Use case:** Overview dashboard with multiple widgets
**Pattern:** Client-side layout with multiple components
**Components:** PageHeader, MetricCard, RecentActivity, QuickActions
**Layout:** Grid layout with responsive columns
### 12. Estimate Builder (Complex Form with Calculations)
**Tool:** `build_estimate`
**Use case:** Multi-line estimate/quote builder with live price calculations
**Pattern:** Client-side form state with derived calculations + `sendMessage` on submit
**Components:** FormGroup, LineItemEditor, CalculatedTotal, TaxSelector
**Interactivity:** Add/remove line items → recalculate subtotals, tax, total in real-time → Submit via model
**Key lesson:** All math runs client-side — server only needed at final submission
```typescript
const [lineItems, setLineItems] = useState([]);
const subtotal = useMemo(() => lineItems.reduce((sum, li) => sum + li.qty * li.price, 0), [lineItems]);
const tax = subtotal * taxRate;
const total = subtotal + tax;
// Submit only when user clicks "Send Estimate"
const onSubmit = () => app?.sendMessage({ text: `Create estimate:\n${JSON.stringify({ lineItems, total })}` });
```
### 13. Duplicate Checker (Comparison UI with Merge Actions)
**Tool:** `check_duplicates`
**Use case:** Side-by-side comparison of potential duplicate records with merge/dismiss
**Pattern:** Client-side pair navigation + `sendMessage` for merge action
**Components:** ComparisonCard, FieldDiffHighlight, MergeSelector, DismissButton
**Interactivity:** Navigate pairs locally → Select winning fields per row → Submit merge decision via model
**Key lesson:** Highlight field-level differences with color coding (green = match, yellow = conflict, red = missing)
### 14. Media Library (Async Asset Grid)
**Tool:** `view_media_library`
**Use case:** Browsable grid of uploaded images/files with preview
**Pattern:** Client-side grid with lazy thumbnail loading + `callServerTool` for pagination
**Components:** AssetGrid, ThumbnailCard, PreviewModal, FilterBar, UploadDropzone
**Interactivity:** Click thumbnail → expand preview modal (local) | Load more → `callServerTool` pagination
**Key lesson:** Use `loading="lazy"` on `
` tags and intersection observer for progressive loading — don't load 200 thumbnails at once
```typescript
const loadPage = async (page: number) => {
if (!canCallTools) return; // Degrade: show "load more in chat" button
const result = await withTimeout(app.callServerTool({
name: "list_media", arguments: { offset: page * 50, limit: 50 }
}), 5000);
setAssets(prev => [...prev, ...result.assets]);
};
```
### 15. Inventory Dashboard (Multi-Widget Composition)
**Tool:** `view_inventory`
**Use case:** Stock levels, low-stock alerts, category breakdown in one view
**Pattern:** Client-side widget composition with independent state per widget
**Components:** StockLevelGauge, LowStockAlert, CategoryBreakdownChart, ReorderQueue
**Layout:** CSS Grid with responsive breakpoints — 3 columns on desktop, 1 on mobile
**Key lesson:** Each widget manages its own state independently; parent only provides data. No cross-widget state coupling.
### 16. Conversation Thread (Chat-Style Feed)
**Tool:** `view_conversation`
**Use case:** Message history displayed as chat bubbles with sender alignment
**Pattern:** Static display with scroll-to-bottom + optional `callServerTool` for older messages
**Components:** MessageBubble, SenderAvatar, TimestampDivider, AttachmentPreview
**Layout:** Flex column with `flex-direction: column-reverse` for natural scroll behavior
**Key lesson:** Distinguish inbound vs outbound messages via alignment (left/right) and color, not just labels
### 17. Free Slots Finder (Interactive Scheduling)
**Tool:** `find_free_slots`
**Use case:** Display available time slots for booking, filterable by date/duration
**Pattern:** Client-side date navigation + slot selection + `sendMessage` to book
**Components:** DateStrip, SlotGrid, DurationFilter, SelectedSlotSummary
**Interactivity:** Swipe dates (local) → Filter by duration (local) → Select slot → "Book This" sends to model
**Key lesson:** Send a full week of slots upfront to enable instant date switching without server calls
### 18. Custom Fields Manager (Configuration/Settings UI)
**Tool:** `manage_custom_fields`
**Use case:** CRUD interface for custom field definitions (add, reorder, edit types)
**Pattern:** Client-side list management with drag-to-reorder + batch `sendMessage` save
**Components:** FieldRow, TypeSelector, DragHandle, AddFieldButton, SaveBar
**Interactivity:** All adds/edits/reorders happen locally → Sticky save bar appears with change count → Submit all changes at once
**Key lesson:** Track a `dirty` flag and show unsaved changes indicator — users need to know they have pending edits
```typescript
const [fields, setFields] = useState(data.fields);
const [originalFields] = useState(data.fields);
const isDirty = JSON.stringify(fields) !== JSON.stringify(originalFields);
// Sticky save bar only appears when isDirty
```
### 19. Pipeline Analytics (Visualization Dashboard)
**Tool:** `view_pipeline_analytics`
**Use case:** Funnel visualization, conversion rates, stage duration metrics
**Pattern:** Client-side chart rendering with time-range selector
**Components:** FunnelChart, ConversionRateCard, StageDurationBar, TimeRangeSelector
**Interactivity:** Switch time ranges locally (7d/30d/90d) → Charts recalculate from full dataset
**Key lesson:** Send raw data for all time ranges upfront, let the UI slice — avoids server roundtrips for every filter change
---
## Common Patterns from Real Apps (65 Production Apps)
### Pattern: Static Display (No Interactivity)
**Use for:** Detail views, invoices, timelines
**Apps:** Opportunity Card, Invoice Preview, Contact Timeline
**Approach:** Send all data upfront, render with zero client logic
### Pattern: Client-Side Interactivity
**Use for:** Sorting, filtering, navigation, local forms
**Apps:** Contact Grid, Calendar View, Agent Stats
**Approach:** All interactions via React state, no server calls
### Pattern: Drag-Drop with Silent Sync
**Use for:** Kanban boards, reordering
**Apps:** Pipeline Board
**Approach:** Update local state + `updateModelContext` (no visible message)
### Pattern: Form + Submit via Model
**Use for:** Booking, creating records
**Apps:** Quick Book
**Approach:** Local form state, `sendMessage` on submit, model handles server write
### Pattern: Dashboard with Multiple Widgets
**Use for:** Overview screens, analytics
**Apps:** Dashboard, Campaign Stats, Inventory Dashboard, Revenue Dashboard, Location Dashboard
**Approach:** Grid layout, each widget self-contained, calculations client-side
### Pattern: Complex Form Builder
**Use for:** Creating/editing multi-field records with calculations
**Apps:** Estimate Builder, Invoice Builder, Contact Creator, Message Composer, Social Post Composer
**Approach:** All form state + derived calculations are client-side; `sendMessage` only on final submit. Show live totals/previews as user edits.
### Pattern: Comparison / Deduplication
**Use for:** Side-by-side record comparison, merge decisions
**Apps:** Duplicate Checker
**Approach:** Present pairs with field-level diff highlighting. User selects winning values locally, submits merge decision via model.
### Pattern: Asset Grid / Gallery
**Use for:** Browsable collections of images, files, templates
**Apps:** Media Library, Template Library
**Approach:** Lazy-loading grid with thumbnail cards. Preview modal on click (local). Pagination via `callServerTool` as progressive enhancement.
### Pattern: Master → Detail Navigation
**Use for:** List views that link to detail views
**Apps:** Company List → Company Detail, Product Catalog → Product Detail, Funnel List → Funnel Detail, Course Catalog → Course Detail, Order List → Order Detail, Invoice List → Invoice Preview
**Approach:** List view sends all summary data upfront; clicking an item either expands inline (local state) or triggers a new tool call for the detail view via `sendMessage`.
### Pattern: Analytics / Visualization
**Use for:** Charts, funnels, conversion tracking
**Apps:** Pipeline Analytics, Pipeline Funnel, Revenue Dashboard, Reviews Dashboard
**Approach:** Send raw data for all time ranges/filters upfront. All chart rendering, time-range switching, and metric calculations happen client-side. Avoid server calls for filter changes.
### Pattern: Configuration / Settings
**Use for:** Managing field definitions, tags, accounts, team members
**Apps:** Custom Fields Manager, Tags Manager, Social Accounts, Subscription Manager, Team Management
**Approach:** Local CRUD with dirty-state tracking. Sticky save bar with change count. Batch submit all changes via single `sendMessage`.
### Pattern: Feed / Conversation
**Use for:** Chat history, activity logs, notification streams
**Apps:** Conversation List, Conversation Thread, Message Detail, Call Log
**Approach:** Chronological display with sender differentiation. Use `flex-direction: column-reverse` for auto-scroll-to-bottom. Lazy-load older messages via `callServerTool` if supported.
### Pattern: Interactive Scheduling
**Use for:** Time slot selection, calendar-based booking
**Apps:** Free Slots Finder, Calendar Resources, Social Calendar, Appointment Booker
**Approach:** Send a full week/month of slots upfront for instant navigation. Date/duration filtering is local. Selection triggers `sendMessage` to book via model.
---
## Lessons Learned from 65 Production Apps
### 1. Send All Data Upfront When Possible
**Why:** Avoids host compatibility issues, works everywhere
**Pattern:** Contact Grid sends all 25 results → All sorting/filtering is local
**Extended:** Estimate Builder sends tax rates, product list, and customer info upfront — all calculations are instant
### 2. Use `updateModelContext` for Silent Tracking
**Why:** Keeps model informed without cluttering chat
**Pattern:** Pipeline Board silently tracks every drag-drop move
**Extended:** Custom Fields Manager tracks all add/edit/reorder/delete actions silently until explicit save
### 3. Reserve `sendMessage` for Explicit Actions
**Why:** Visible messages should be intentional user requests
**Pattern:** Quick Book only sends message when user clicks "Book Appointment"
**Extended:** Estimate Builder, Invoice Builder, Contact Creator all follow this — form state is local, submission is explicit
### 4. Static Views Are Valid (Not Everything Needs Buttons)
**Why:** Sometimes you just need to display data beautifully
**Pattern:** Invoice Preview, Opportunity Card are pure display
**Extended:** Call Detail, Order Detail, User Detail, Estimate Preview — roughly 20% of all 65 apps are pure static display
### 5. Avoid Premature `callServerTool` Optimization
**Why:** Not all hosts support it, adds complexity
**Pattern:** Build client-side first, layer on `callServerTool` for refresh/pagination only if needed
**Extended:** Only ~5 of 65 apps actually need `callServerTool` (Media Library pagination, Conversation Thread history loading). The other 60 work perfectly with upfront data.
### 6. Shared Component Library = Consistency Win
**Why:** Reusable UI components across all 65 apps
**Components:** See **Shared Component Catalog** section below
**Location:** Shared `components/` directory imported by all apps
### 7. Inline HTML Works Great for Simple Apps
**Why:** For apps under 200 lines, skip React and use vanilla HTML
**Pattern:** Several GHL apps use inline HTML with minimal JavaScript
**Benefits:** Zero build step, instant preview, easy to debug
### 8. Track Dirty State for Settings/Config UIs
**Why:** Users need to know they have unsaved changes
**Pattern:** Custom Fields Manager, Tags Manager show a sticky save bar with change count when edits are pending
**Implementation:** Compare current state to original snapshot; show "X unsaved changes" indicator
### 9. Batch Changes, Don't Spam the Model
**Why:** Sending a `sendMessage` for every micro-edit floods the conversation
**Pattern:** Custom Fields Manager, Team Management collect all changes locally → single batch submit
**Anti-pattern:** ╳ Sending `sendMessage` on every field edit, every drag, every toggle
### 10. Master-Detail Can Be One App or Two
**Why:** Some detail views are complex enough to warrant separate apps
**Decision:** If detail view is <100 lines → expand inline (Company List with accordion). If detail view is >100 lines or has its own interactivity → separate app (Invoice List → Invoice Preview)
**Pattern:** 6 master-detail pairs in the 65 apps, split roughly 50/50 between inline and separate
### 11. Pre-Calculate ALL Time Ranges
**Why:** Users expect instant filter switching; server roundtrips feel broken
**Pattern:** Pipeline Analytics, Revenue Dashboard send raw data for 7d/30d/90d/all → UI slices and re-renders charts locally
**Anti-pattern:** ╳ Calling `callServerTool` every time user switches from "7 days" to "30 days"
### 12. Color-Code Status Consistently Across Apps
**Why:** Users build muscle memory for what green/yellow/red mean
**Convention used across 65 apps:**
- Green: active, complete, paid, healthy
- Yellow/amber: pending, in-progress, due soon
- Red: overdue, failed, critical, inactive
- Blue: informational, new, neutral
---
## Shared Component Catalog
Reusable components available across all apps. Import from `../components/` when building new apps.
### Layout Components (`components/layout/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `PageHeader` | Title bar with optional subtitle, actions, breadcrumbs | Nearly all apps |
| `Card` | Bordered container with optional header/footer | Detail views, dashboard widgets |
| `SplitLayout` | Two-panel side-by-side layout (list + detail) | Duplicate Checker, Master-Detail pairs |
| `StatsGrid` | Responsive grid for metric cards (auto 1-4 columns) | All dashboard/analytics apps |
| `Section` | Collapsible section with header | Settings UIs, long forms |
| `StickyFooter` | Fixed bottom bar for save/submit actions | Form builders, config UIs |
### Data Components (`components/data/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `DataTable` | Sortable, filterable table with column headers | Contact Grid, Invoice List, Order List, Transaction List |
| `KanbanBoard` | Drag-drop column board | Pipeline Kanban, Task Board |
| `MetricCard` | Single stat with label, value, trend indicator | All dashboards |
| `Timeline` | Vertical chronological event list | Contact Timeline, Workflow Status |
| `StatusBadge` | Colored pill badge (green/yellow/red/blue) | Everywhere — status display |
| `LeaderboardTable` | Ranked table with position indicators | Agent Stats |
| `ComparisonCard` | Side-by-side field comparison with diff highlighting | Duplicate Checker |
| `FieldDiffHighlight` | Color-coded field match/conflict/missing indicator | Duplicate Checker |
### Chart Components (`components/charts/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `BarChart` | Horizontal/vertical bar chart | Campaign Stats, Agent Stats |
| `LineChart` | Time-series line chart | Revenue Dashboard, Pipeline Analytics |
| `PieChart` | Pie/donut chart | Inventory Dashboard, Category breakdowns |
| `FunnelChart` | Conversion funnel visualization | Pipeline Funnel, Pipeline Analytics |
| `ProgressBar` | Horizontal progress indicator | Campaign Stats, Workflow Status |
| `TrendIndicator` | Up/down arrow with percentage change | MetricCard companion |
| `StockLevelGauge` | Fill-level indicator (0-100%) | Inventory Dashboard |
### Interactive Components (`components/interactive/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `ContactPicker` | Searchable contact selector dropdown | Quick Book, Message Composer |
| `InvoiceBuilder` | Line item editor with add/remove/reorder | Invoice Builder, Estimate Builder |
| `FormGroup` | Label + input + validation error display | All form apps |
| `DateTimePicker` | Date and time selection | Quick Book, Free Slots Finder |
| `DurationFilter` | Duration range selector (15min/30min/1hr) | Free Slots Finder |
| `DateStrip` | Horizontal scrollable date selector | Free Slots Finder, Social Calendar |
| `SlotGrid` | Time slot grid with selection state | Free Slots Finder, Calendar Resources |
| `LineItemEditor` | Add/remove/edit rows with calculated totals | Estimate Builder, Invoice Builder |
| `TypeSelector` | Dropdown for field type selection | Custom Fields Manager |
| `DragHandle` | Drag affordance for reorderable lists | Custom Fields Manager, Task Board |
### Shared Components (`components/shared/`)
| Component | Purpose | Used In |
|-----------|---------|---------|
| `ActionButton` | Primary/secondary/danger button variants | All interactive apps |
| `SearchBar` | Debounced search input with clear button | Contact Grid, Media Library, Smartlist Viewer |
| `Toast` | Temporary notification popup | Form submissions, error feedback |
| `Modal` | Overlay dialog with backdrop | Media Library preview, confirmation dialogs |
| `EmptyState` | Illustration + message when no data | All list/grid apps |
| `LoadingSpinner` | Consistent loading indicator | Apps using `callServerTool` |
| `SaveBar` | Sticky bar showing "X unsaved changes" + Save/Discard | Config UIs (Custom Fields, Tags Manager) |
| `FilterBar` | Horizontal filter chips/dropdowns | Media Library, Smartlist Viewer, List apps |
| `ThumbnailCard` | Image card with overlay info | Media Library, Template Library |
| `MessageBubble` | Chat-style message with sender alignment | Conversation Thread |
| `TimestampDivider` | "Today" / "Yesterday" divider in feeds | Conversation Thread, Call Log |
### Hooks (`hooks/`)
| Hook | Purpose |
|------|---------|
| `useCallTool` | Wrapper for `callServerTool` with loading/error state |
| `useSmartAction` | Capability-detected action dispatch (direct vs fallback) |
| `useHostCapabilities` | Read host capabilities once on mount |
| `useDirtyState` | Track original vs current state, expose `isDirty` and `changeCount` |
| `useDebounce` | Debounce value changes (search input, auto-save) |
| `useLazyLoad` | Intersection observer for lazy loading grid items |
---
## Graceful Degradation & Timeout Strategy
### `callServerTool` Timeout (MANDATORY)
If `ui/initialize` never completes or `callServerTool` hangs, apps must degrade gracefully within 5 seconds — not spin forever.
```typescript
// hooks/useCallTool.ts
function withTimeout(promise: Promise, ms: number): Promise {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`callServerTool timed out after ${ms}ms`)), ms)
),
]);
}
function useCallTool() {
const { app } = useMCPApp();
const canCallTools = !!app?.getHostCapabilities()?.serverTools;
const callTool = async (name: string, args: Record) => {
if (!canCallTools) return { ok: false, reason: 'unsupported' as const };
try {
const result = await withTimeout(
app!.callServerTool({ name, arguments: args }),
5000 // 5 second hard timeout
);
return { ok: true, data: result };
} catch (err) {
return { ok: false, reason: 'timeout' as const, error: err };
}
};
return { callTool, canCallTools };
}
```
### Degradation Tiers
| Tier | Condition | Behavior |
|------|-----------|----------|
| Full | Host supports `callServerTool` + responds in <5s | All features enabled |
| Read-Only | `callServerTool` times out or errors | Display data from initial `ontoolresult` only; disable pagination/refresh; show "Data loaded at [time]" |
| Fallback | Host doesn't support `callServerTool` at all | Same as Read-Only; show "Load more in chat" button that uses `sendMessage` |
| Text-Only | Host doesn't render UI | Return `content` array with formatted text (ALWAYS provide this) |
### Init Handshake Timeout
If `ui/initialize` hasn't completed within 3 seconds, assume limited host and proceed in read-only mode:
```typescript
const [initComplete, setInitComplete] = useState(false);
const [timedOut, setTimedOut] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
if (!initComplete) setTimedOut(true);
}, 3000);
return () => clearTimeout(timer);
}, [initComplete]);
// In render:
if (timedOut && !initComplete) {
return ;
}
```
### Text Fallback (ALWAYS required)
Every tool MUST return a `content` array with meaningful text, even when a UI resource exists. Non-UI hosts (CLI tools, API consumers) only see this:
```typescript
return {
content: [
{ type: 'text', text: `Found ${contacts.length} contacts matching "${query}":\n${contacts.map(c => `- ${c.name} (${c.email})`).join('\n')}` }
],
_meta: { ui: { resourceUri: 'ui://ghl/contact-grid' } },
};
```
---
## Reference Implementations
**Full source code (65 apps):**
- `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/GoHighLevel-MCP/src/ui/react-app/src/apps/`
- 65 complete apps across 14 pattern categories
- Shared component library (`components/`)
- Shared hooks library (`hooks/`)
- Build scripts for copying HTML to dist/
**Standalone app reference (11 apps with structuredContent):**
- `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/ghl-mcp-apps-only/`
**Server integration:**
- `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/GoHighLevel-MCP/src/apps/index.ts`
- MCPAppsManager class pattern
- Resource handler registration
- Tool handler implementation
Use these as templates when building new MCP Apps.