48 KiB
| name | description |
|---|---|
| Create MCP App | 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:
- Tool - Called by the LLM/host, returns data
- Resource - Serves the bundled HTML UI that displays the data
- Link - The tool's
_meta.ui.resourceUrireferences 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.
// 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.
// 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.
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.
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.
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.
_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:
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:
- Host must declare
serverTools: {}inhostCapabilitiesduringui/initialize - Host must register
oncalltoolhandler onAppBridge - Host must relay
tools/callrequests to the real MCP server
Known failure modes:
srcdociframes get origin"null"— usedocument.write()instead- Init handshake (
ui/initialize) never completing —callServerToolhangs forever - Double-iframe
event.sourcemismatch — 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 — first official MCP extension
Clone the SDK repository for working examples and API documentation:
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
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.
// 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):
// 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
"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():
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
// 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:
_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:
import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react";
const { app } = useApp({ appInfo, capabilities, onAppCreated });
useHostStyles(app);
CSS variables available after applying:
.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
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:
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:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) animation.play();
else animation.pause();
});
});
observer.observe(document.querySelector(".main"));
Fullscreen Mode
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
- Dynamic JSON tree rendering — Use direct component composition per app instead
- Replacing entire UI tree on every tool result — Merge trees or use stable component structure
- Relying on
callServerToolfor basic interactivity — Use client-side state first - No capability detection — Always check
app.getHostCapabilities()?.serverToolsbefore usingcallServerTool - Handlers after connect() — Register ALL handlers BEFORE
app.connect() - Missing single-file bundling — Must use
vite-plugin-singlefile - Forgetting resource registration — Both tool AND resource must be registered
- No text fallback — Always provide
contentarray for non-UI hosts - Hardcoded styles — Use host CSS variables for theme integration
- No streaming for large inputs — Use
ontoolinputpartialfor progress - No timeout on
callServerTool— Always wrap inPromise.racewith 5s timeout; degrade to read-only on failure (see Graceful Degradation section) - Sending
sendMessageon every micro-edit — Batch changes locally, submit once on explicit save action - Inconsistent status colors across apps — Use the shared
StatusBadgewith standard green/yellow/red/blue convention - No dirty-state indicator on config UIs — Users must know they have unsaved changes; use
useDirtyStatehook +SaveBarcomponent
Testing
Using basic-host
# 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
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:
# Add a dev mode that uses mock data
VITE_DEV_MODE=true npm run dev
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)
{
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
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
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
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
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
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.
// 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:
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:
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.