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.
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/orapp-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:
capabilities: { tools: {}, resources: {} }— Enable resourcesListResourcesRequestSchemahandler — List UI filesReadResourceRequestSchemahandler — Serve UI HTML- 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 dataevent.data.type === 'mcp-app-init'to detect initevent.data.datacontains 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 gridshow_dashboard→ Display dashboardview_opportunity_card→ Display opportunity cardshow_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:
- Create
MCPAppsManagerclass insrc/apps/index.ts - Build HTML UI components in
src/ui/ - Register resource handlers in apps manager
- Add
capabilities: { resources: {} }to server - Implement
ListResourcesRequestSchemaandReadResourceRequestSchema - Return
structuredContentfrom app tool handlers - 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.