20 KiB

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

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

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)

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

{
  "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

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:

npm install --save-dev fs-extra @types/fs-extra

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

npm run build

2. Add to Claude Desktop config

{
  "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

{
  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)' 
      },
    },
  },
}
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.