--- 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.