Jake Shore 8d65417afe Add 11 MCP agent skills to repo — 550KB of encoded pipeline knowledge
Skills added:
- mcp-api-analyzer (43KB) — Phase 1: API analysis
- mcp-server-builder (88KB) — Phase 2: Server build
- mcp-server-development (31KB) — TS MCP patterns
- mcp-app-designer (85KB) — Phase 3: Visual apps
- mcp-apps-integration (20KB) — structuredContent UI
- mcp-apps-official (48KB) — MCP Apps SDK
- mcp-apps-merged (39KB) — Combined apps reference
- mcp-localbosses-integrator (61KB) — Phase 4: LocalBosses wiring
- mcp-qa-tester (113KB) — Phase 5: Full QA framework
- mcp-deployment (17KB) — Phase 6: Production deploy
- mcp-skill (exa integration)

These skills are the encoded knowledge that lets agents build
production-quality MCP servers autonomously through the pipeline.
2026-02-06 06:36:37 -05:00

48 KiB
Raw Blame History

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:

  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

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.

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:

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

  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

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

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.