2026-02-04 23:01:37 -05:00

1137 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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 (
<PageHeader title={data.pipeline.name}>
<KanbanBoard columns={data.stages} />
</PageHeader>
);
}
```
**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<LineItem[]>([]);
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 `<img>` 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<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, 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<string, unknown>) => {
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 <ReadOnlyView data={data} notice="Running in read-only mode" />;
}
```
### 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.