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