# MCP Apps Integration — Building Servers with Rich UI **When to use this skill:** Adding rich UI components (structuredContent) to MCP servers. Use when tool results benefit from visual presentation beyond plain text/JSON. **What this covers:** Integrating MCP Apps with server tools, based on 11 production GHL apps (Contact Grid, Pipeline Board, Calendar View, Invoice Preview, etc.). --- ## 1. What Are MCP Apps? **MCP Apps = Tools that return `structuredContent`** (HTML-based UI components that render in Claude Desktop) **Use cases:** - **Data grids:** Contact lists, search results - **Dashboards:** Stats, metrics, KPIs - **Cards:** Opportunity cards, invoice previews - **Timelines:** Activity feeds, history - **Forms:** Quick actions embedded in UI - **Visualizations:** Charts, graphs, calendars **When to use apps vs regular tools:** - ✅ Use apps: Visual data (grids, cards, timelines) - ❌ Skip apps: Simple CRUD operations, plain JSON responses --- ## 2. Architecture Pattern ### Server + Apps Integration ``` mcp-server-myservice/ ├── src/ │ ├── index.ts # Main server (or server.ts) │ ├── clients/ │ │ └── api-client.ts # API client │ ├── apps/ │ │ └── index.ts # Apps manager + tool definitions │ ├── ui/ │ │ ├── contact-grid.html │ │ ├── dashboard.html │ │ └── ... │ └── types/ │ └── index.ts # Shared TypeScript types ├── dist/ │ ├── index.js # Compiled server │ ├── apps/ │ ├── app-ui/ # Compiled HTML files (copied during build) │ └── ... ├── package.json ├── tsconfig.json └── README.md ``` **Key points:** - Apps manager lives in `src/apps/index.ts` - HTML UI files live in `src/ui/` or `app-ui/` - Compiled UI files must be accessible at runtime (copy during build) --- ## 3. Apps Manager Pattern ### Basic MCPAppsManager Class ```typescript import { Tool } from '@modelcontextprotocol/sdk/types.js'; import { MyAPIClient } from '../clients/api-client.js'; import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; export interface AppToolResult { content: Array<{ type: 'text'; text: string }>; structuredContent?: Record; } export interface AppResourceHandler { uri: string; mimeType: string; getContent: () => string; } // ESM __dirname equivalent const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); function getUIBuildPath(): string { // When compiled, this file is at dist/apps/index.js // UI files are at dist/app-ui/ const fromDist = path.resolve(__dirname, '..', 'app-ui'); if (fs.existsSync(fromDist)) { return fromDist; } // Fallback return fromDist; } export class MCPAppsManager { private apiClient: MyAPIClient; private resourceHandlers: Map = new Map(); private uiBuildPath: string; constructor(apiClient: MyAPIClient) { this.apiClient = apiClient; this.uiBuildPath = getUIBuildPath(); this.registerResourceHandlers(); } /** * Register all UI resource handlers */ private registerResourceHandlers(): void { const resources: Array<{ uri: string; file: string }> = [ { uri: 'ui://myservice/contact-grid', file: 'contact-grid.html' }, { uri: 'ui://myservice/dashboard', file: 'dashboard.html' }, ]; for (const resource of resources) { this.resourceHandlers.set(resource.uri, { uri: resource.uri, mimeType: 'text/html;profile=mcp-app', getContent: () => this.loadUIResource(resource.file), }); } } /** * Load UI resource from build directory */ private loadUIResource(filename: string): string { const filePath = path.join(this.uiBuildPath, filename); try { return fs.readFileSync(filePath, 'utf-8'); } catch (error) { console.error(`UI resource not found: ${filePath}`); return this.getFallbackHTML(filename); } } /** * Generate fallback HTML when UI resource is not built */ private getFallbackHTML(filename: string): string { const componentName = filename.replace('.html', ''); return ` ${componentName}

UI component "${componentName}" is loading...

`.trim(); } /** * Get tool definitions for all app tools */ getToolDefinitions(): Tool[] { return [ { name: 'view_contact_grid', description: 'Display contact search results in a data grid. Returns a visual UI component.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query' }, limit: { type: 'number', description: 'Max results (default: 25)' }, }, }, }, // ... more app tools ]; } /** * Get resource handlers (for server registration) */ getResourceHandlers(): Map { return this.resourceHandlers; } /** * Handle app tool calls */ async handleAppTool(name: string, args: Record): Promise { switch (name) { case 'view_contact_grid': return this.viewContactGrid(args); default: throw new Error(`Unknown app tool: ${name}`); } } /** * Example: Contact Grid App */ private async viewContactGrid(args: Record): Promise { const { query = '', limit = 25 } = args; // Call API to get data const contacts = await this.apiClient.searchContacts({ query, limit: Number(limit) }); // Return structuredContent pointing to UI resource return { content: [{ type: 'text', text: `Found ${contacts.length} contacts` }], structuredContent: { type: 'ui', uri: 'ui://myservice/contact-grid', data: { contacts, query, timestamp: new Date().toISOString(), }, }, }; } } ``` --- ## 4. Server Integration ### In `src/index.ts` or `src/server.ts` ```typescript import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { MyAPIClient } from './clients/api-client.js'; import { MCPAppsManager } from './apps/index.js'; async function main() { // Initialize API client const apiClient = new MyAPIClient(process.env.API_KEY!); // Initialize apps manager const appsManager = new MCPAppsManager(apiClient); // Create MCP server const server = new Server( { name: 'myservice-mcp', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } } // ✅ Enable resources ); // List tools (regular tools + app tools) server.setRequestHandler(ListToolsRequestSchema, async () => { const regularTools = [ // ... your regular tools ]; const appTools = appsManager.getToolDefinitions(); return { tools: [...regularTools, ...appTools], }; }); // Handle tool calls server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { // Check if it's an app tool const appTools = appsManager.getToolDefinitions().map(t => t.name); if (appTools.includes(name)) { return await appsManager.handleAppTool(name, args || {}); } // Handle regular tools const result = await handleRegularTool(apiClient, name, args || {}); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true, }; } }); // List resources (UI files) server.setRequestHandler(ListResourcesRequestSchema, async () => { const handlers = appsManager.getResourceHandlers(); const resources = Array.from(handlers.values()).map(h => ({ uri: h.uri, mimeType: h.mimeType, name: h.uri.split('/').pop() || h.uri, })); return { resources }; }); // Read resources (serve UI HTML) server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; const handler = appsManager.getResourceHandlers().get(uri); if (!handler) { throw new Error(`Resource not found: ${uri}`); } return { contents: [{ uri, mimeType: handler.mimeType, text: handler.getContent(), }], }; }); // Start server const transport = new StdioServerTransport(); await server.connect(transport); console.error('MyService MCP server with apps running on stdio'); } main().catch(console.error); ``` **Key additions for apps:** 1. `capabilities: { tools: {}, resources: {} }` — Enable resources 2. `ListResourcesRequestSchema` handler — List UI files 3. `ReadResourceRequestSchema` handler — Serve UI HTML 4. Check if tool is an app tool before routing --- ## 5. HTML UI Component Template ### Example: Contact Grid (`src/ui/contact-grid.html`) ```html Contact Grid
Contacts
Name Email Phone Status
``` **Key patterns:** - Self-contained (all CSS/JS inline) - `window.addEventListener('message', ...)` to receive data - `event.data.type === 'mcp-app-init'` to detect init - `event.data.data` contains the structuredContent.data object - Escape HTML to prevent XSS - Clean, modern styling --- ## 6. Common UI Patterns ### 1. Data Grid (List View) **Use for:** Contact lists, search results, transaction history **Components:** Table, sorting, pagination indicators **Example apps:** Contact Grid, Pipeline Board ### 2. Card View (Detail View) **Use for:** Single item details, opportunity cards, invoices **Components:** Card container, labeled fields, actions **Example apps:** Opportunity Card, Invoice Preview ### 3. Dashboard (Stats/Metrics) **Use for:** Analytics, KPIs, performance metrics **Components:** Stat cards, charts (use Chart.js), progress bars **Example apps:** Campaign Stats, Agent Stats ### 4. Timeline (Activity Feed) **Use for:** History, activity logs, event streams **Components:** Timeline with timestamps, event types, icons **Example apps:** Contact Timeline, Workflow Status ### 5. Calendar View **Use for:** Appointments, events, schedules **Components:** Calendar grid, event markers, time slots **Example apps:** Calendar View --- ## 7. Build Configuration ### package.json Scripts ```json { "scripts": { "build": "npm run build:ts && npm run build:ui", "build:ts": "tsc", "build:ui": "node scripts/copy-ui.js", "dev": "tsx src/index.ts", "start": "node dist/index.js" } } ``` ### scripts/copy-ui.js ```javascript import fs from 'fs-extra'; import path from 'path'; const uiSource = path.join(process.cwd(), 'src', 'ui'); const uiDest = path.join(process.cwd(), 'dist', 'app-ui'); console.log('Copying UI files...'); console.log(`From: ${uiSource}`); console.log(`To: ${uiDest}`); // Ensure dist/app-ui exists fs.ensureDirSync(uiDest); // Copy all HTML files from src/ui to dist/app-ui fs.copySync(uiSource, uiDest, { overwrite: true }); console.log('✅ UI files copied successfully'); ``` **Install fs-extra:** ```bash npm install --save-dev fs-extra @types/fs-extra ``` ### tsconfig.json ```json { "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "node", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/ui"] } ``` **Note:** Exclude `src/ui` from TypeScript compilation (HTML files don't need compiling) --- ## 8. Testing Apps ### 1. Build the server ```bash npm run build ``` ### 2. Add to Claude Desktop config ```json { "mcpServers": { "myservice": { "command": "node", "args": ["/absolute/path/to/dist/index.js"], "env": { "API_KEY": "your_key_here" } } } } ``` ### 3. Restart Claude Desktop ### 4. Call an app tool ``` Can you show me the contact grid for "john"? ``` Claude will call `view_contact_grid` → Server returns `structuredContent` → UI renders in Claude Desktop --- ## 9. When to Use Apps vs Regular Tools | Scenario | Use App | Use Regular Tool | |----------|---------|------------------| | Display contact list | ✅ Grid UI | ❌ JSON dump | | Show dashboard stats | ✅ Dashboard UI | ❌ Plain numbers | | Get single contact by ID | ❌ Overkill | ✅ JSON response | | Create a new record | ❌ No UI needed | ✅ POST + return result | | Search + display results | ✅ Grid UI | Maybe (depends on result size) | | Calendar of appointments | ✅ Calendar UI | ❌ JSON dates hard to parse | | Invoice details | ✅ Card UI | Maybe | **Rule of thumb:** If the result benefits from visual formatting, use an app. If it's pure data/CRUD, use a regular tool. --- ## 10. Common Pitfalls ### ❌ UI files not copied to dist/ **Solution:** Add `build:ui` script that copies HTML from `src/ui/` to `dist/app-ui/` ### ❌ UI path resolution fails **Solution:** Use `fileURLToPath` for ESM `__dirname` equivalent + check `fs.existsSync()` ### ❌ Data not showing in UI **Solution:** Check `event.data.type === 'mcp-app-init'` and log `event.data.data` to console ### ❌ Resources not registered **Solution:** Add `capabilities: { resources: {} }` and implement `ListResourcesRequestSchema` + `ReadResourceRequestSchema` ### ❌ HTML escaping issues **Solution:** Always escape user data with `escapeHtml()` function --- ## 11. App Tool Naming Convention **Pattern:** `view_` or `show_` prefix for app tools - `view_contact_grid` → Display contact grid - `show_dashboard` → Display dashboard - `view_opportunity_card` → Display opportunity card - `show_calendar` → Display calendar **Why:** - Differentiates app tools from regular tools - Signals to Claude that result is visual - Clear intent (viewing vs fetching) --- ## 12. Example: Complete App Tool ```typescript { name: 'view_pipeline_board', description: 'Display sales pipeline board with opportunities grouped by stage. Returns an interactive visual component.', inputSchema: { type: 'object', properties: { pipelineId: { type: 'string', description: 'Pipeline ID (optional, defaults to main pipeline)' }, includeWon: { type: 'boolean', description: 'Include won deals (default: false)' }, }, }, } ``` ```typescript private async viewPipelineBoard(args: Record): Promise { const { pipelineId, includeWon = false } = args; // Fetch pipeline data from API const pipeline = await this.apiClient.getPipeline(pipelineId); const opportunities = await this.apiClient.getOpportunities({ pipelineId, status: includeWon ? 'all' : 'active', }); // Group by stage const groupedByStage = opportunities.reduce((acc, opp) => { if (!acc[opp.stageId]) acc[opp.stageId] = []; acc[opp.stageId].push(opp); return acc; }, {} as Record); return { content: [{ type: 'text', text: `Pipeline Board: ${pipeline.name} (${opportunities.length} opportunities)`, }], structuredContent: { type: 'ui', uri: 'ui://myservice/pipeline-board', data: { pipeline, opportunities, groupedByStage, includeWon, timestamp: new Date().toISOString(), }, }, }; } ``` --- ## 13. Resources - **MCP Apps Docs:** https://modelcontextprotocol.io/docs/apps - **Example Apps:** `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/ghl-mcp-apps-only/` - **GHL MCP Server:** `/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/GoHighLevel-MCP/` --- ## Summary **To add apps to an MCP server:** 1. Create `MCPAppsManager` class in `src/apps/index.ts` 2. Build HTML UI components in `src/ui/` 3. Register resource handlers in apps manager 4. Add `capabilities: { resources: {} }` to server 5. Implement `ListResourcesRequestSchema` and `ReadResourceRequestSchema` 6. Return `structuredContent` from app tool handlers 7. Copy UI files to `dist/app-ui/` during build **Benefits:** - Rich visual presentation of data - Better UX in Claude Desktop - Interactive components (grids, cards, dashboards) - Clear separation of regular tools vs visual tools Follow this pattern and your apps will integrate seamlessly with your MCP server.