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

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

773 lines
20 KiB
Markdown

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