# 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<string, unknown>;
}

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<string, AppResourceHandler> = 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 `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>${componentName}</title>
</head>
<body>
  <div style="text-align: center; padding: 20px; color: #666;">
    <p>UI component "${componentName}" is loading...</p>
  </div>
</body>
</html>
    `.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<string, AppResourceHandler> {
    return this.resourceHandlers;
  }

  /**
   * Handle app tool calls
   */
  async handleAppTool(name: string, args: Record<string, unknown>): Promise<AppToolResult> {
    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<string, unknown>): Promise<AppToolResult> {
    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
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Contact Grid</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      padding: 20px;
      background: #f5f5f5;
    }
    .grid-container {
      background: white;
      border-radius: 8px;
      padding: 20px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
    .grid-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 20px;
      padding-bottom: 15px;
      border-bottom: 2px solid #e0e0e0;
    }
    .grid-title {
      font-size: 20px;
      font-weight: 600;
      color: #333;
    }
    .grid-count {
      font-size: 14px;
      color: #666;
    }
    .contacts-table {
      width: 100%;
      border-collapse: collapse;
    }
    .contacts-table th {
      text-align: left;
      padding: 12px;
      background: #f8f9fa;
      color: #555;
      font-weight: 600;
      font-size: 13px;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }
    .contacts-table td {
      padding: 12px;
      border-bottom: 1px solid #e0e0e0;
    }
    .contacts-table tr:hover {
      background: #f8f9fa;
    }
    .contact-name {
      font-weight: 600;
      color: #2563eb;
    }
    .contact-email {
      color: #666;
      font-size: 14px;
    }
    .contact-status {
      display: inline-block;
      padding: 4px 12px;
      border-radius: 12px;
      font-size: 12px;
      font-weight: 600;
    }
    .status-active {
      background: #d1fae5;
      color: #065f46;
    }
    .status-inactive {
      background: #fee2e2;
      color: #991b1b;
    }
  </style>
</head>
<body>
  <div class="grid-container">
    <div class="grid-header">
      <div class="grid-title">Contacts</div>
      <div class="grid-count" id="count"></div>
    </div>
    <table class="contacts-table">
      <thead>
        <tr>
          <th>Name</th>
          <th>Email</th>
          <th>Phone</th>
          <th>Status</th>
        </tr>
      </thead>
      <tbody id="contacts-tbody">
        <!-- Populated by JavaScript -->
      </tbody>
    </table>
  </div>

  <script>
    // Listen for data from MCP
    window.addEventListener('message', (event) => {
      if (event.data?.type === 'mcp-app-init') {
        const data = event.data.data;
        renderContacts(data);
      }
    });

    function renderContacts(data) {
      const { contacts, query } = data;
      
      // Update count
      document.getElementById('count').textContent = 
        `${contacts.length} result${contacts.length !== 1 ? 's' : ''}`;

      // Render table rows
      const tbody = document.getElementById('contacts-tbody');
      tbody.innerHTML = contacts.map(contact => `
        <tr>
          <td class="contact-name">${escapeHtml(contact.name)}</td>
          <td class="contact-email">${escapeHtml(contact.email || 'N/A')}</td>
          <td>${escapeHtml(contact.phone || 'N/A')}</td>
          <td>
            <span class="contact-status status-${contact.status || 'active'}">
              ${escapeHtml(contact.status || 'Active')}
            </span>
          </td>
        </tr>
      `).join('');
    }

    function escapeHtml(text) {
      const div = document.createElement('div');
      div.textContent = text;
      return div.innerHTML;
    }
  </script>
</body>
</html>
```

**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<string, unknown>): Promise<AppToolResult> {
  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<string, any[]>);

  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.
