=== DONE === - MCP Apps UI system added (11 apps with _meta.ui.resourceUri) - 19 new tool modules added - Tool count: 269 → 461 across 38 categories - Upstream changes merged - All tools tagged with _meta labels - Package lock updated === TO-DO === - [ ] Fix 42 failing edge case tests (BLOCKER — Stage 11) - [ ] Live API testing with GHL credentials - [ ] App design approval for Stage 7→8
1046 lines
42 KiB
TypeScript
1046 lines
42 KiB
TypeScript
/**
|
|
* MCP Apps Manager — Universal Renderer Architecture
|
|
*
|
|
* All views route through ONE universal renderer HTML file that takes a JSON UITree.
|
|
* Pre-made templates provide deterministic views for the 11 standard tools.
|
|
* The generate_ghl_view tool uses Claude to create novel views on the fly.
|
|
*/
|
|
|
|
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
import { GHLApiClient } from '../clients/ghl-api-client.js';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import Anthropic from '@anthropic-ai/sdk';
|
|
import { UITree, validateUITree } from './types.js';
|
|
import {
|
|
buildContactGridTree,
|
|
buildPipelineBoardTree,
|
|
buildQuickBookTree,
|
|
buildOpportunityCardTree,
|
|
buildCalendarViewTree,
|
|
buildInvoicePreviewTree,
|
|
buildCampaignStatsTree,
|
|
buildAgentStatsTree,
|
|
buildContactTimelineTree,
|
|
buildWorkflowStatusTree,
|
|
buildDashboardTree,
|
|
} from './templates/index.js';
|
|
|
|
// ─── Catalog System Prompt (source of truth for components) ──
|
|
|
|
const CATALOG_SYSTEM_PROMPT = `You are a UI generator for GoHighLevel (GHL) CRM applications.
|
|
You generate JSON UI trees using the component catalog below. Your output MUST be valid JSON matching the UITree schema.
|
|
|
|
## RULES
|
|
1. Only use components defined in the catalog
|
|
2. Every element must have a unique "key", a "type" (matching a catalog component), and "props"
|
|
3. Parent elements list children by key in their "children" array
|
|
4. **USE THE PROVIDED GHL DATA** — if real data is included below, you MUST use it. Do NOT invent fake data when real data is available.
|
|
5. Keep layouts information-dense and professional
|
|
6. Respond with ONLY the JSON object. No markdown fences, no explanation.
|
|
|
|
## LAYOUT RULES (CRITICAL)
|
|
- Design for a **single viewport** — the view should fit on one screen without scrolling
|
|
- Maximum **15 elements** total in the tree. Fewer is better.
|
|
- Use **SplitLayout** for side-by-side content, not stacked cards that go off-screen
|
|
- Use **StatsGrid** with 3-4 MetricCards max for KPIs — don't list every metric
|
|
- For tables, limit to **10 rows max**. Show most relevant data, not everything.
|
|
- For KanbanBoard, limit to **5 columns** and **4 cards per column** max
|
|
- Prefer compact components: MetricCard, StatusBadge, KeyValueList over verbose layouts
|
|
- ONE PageHeader max. Don't nest sections inside sections.
|
|
- Think **dashboard widget**, not **full report page**
|
|
|
|
## UI TREE FORMAT
|
|
{
|
|
"root": "<key of root element>",
|
|
"elements": {
|
|
"<key>": {
|
|
"key": "<same key>",
|
|
"type": "<ComponentName>",
|
|
"props": { ... },
|
|
"children": ["<child-key-1>", "<child-key-2>"]
|
|
}
|
|
}
|
|
}
|
|
|
|
## COMPONENT CATALOG
|
|
|
|
### PageHeader
|
|
Top-level page header with title, subtitle, status badge, and summary stats.
|
|
Props: title (string, required), subtitle (string?), status (string?), statusVariant ("active"|"complete"|"paused"|"draft"|"error"|"sent"|"paid"|"pending"?), gradient (boolean?), stats (array of {label, value}?)
|
|
Can contain children.
|
|
|
|
### Card
|
|
Container card with optional header and padding.
|
|
Props: title (string?), subtitle (string?), padding ("none"|"sm"|"md"|"lg"?), noBorder (boolean?)
|
|
Can contain children.
|
|
|
|
### StatsGrid
|
|
Grid of metric cards showing key numbers.
|
|
Props: columns (number?)
|
|
Can contain children (typically MetricCard elements).
|
|
|
|
### SplitLayout
|
|
Two-column layout for side-by-side content.
|
|
Props: ratio ("50/50"|"33/67"|"67/33"?), gap ("sm"|"md"|"lg"?)
|
|
Can contain children (exactly 2 children for left/right).
|
|
|
|
### Section
|
|
Titled content section.
|
|
Props: title (string?), description (string?)
|
|
Can contain children.
|
|
|
|
### DataTable
|
|
Sortable data table with column definitions and row actions.
|
|
Props: columns (array of {key, label, sortable?, align?, format?, width?}), rows (array of objects), selectable (boolean?), rowAction (string?), emptyMessage (string?), pageSize (number?)
|
|
Format options: "text"|"email"|"phone"|"date"|"currency"|"tags"|"avatar"|"status"
|
|
|
|
### KanbanBoard
|
|
Kanban-style board with columns and cards. Used for pipeline views.
|
|
Props: columns (array of {id, title, count?, totalValue?, color?, cards: [{id, title, subtitle?, value?, status?, statusVariant?, date?, avatarInitials?}]})
|
|
|
|
### MetricCard
|
|
Single metric display with big number, label, and optional trend.
|
|
Props: label (string), value (string), format ("number"|"currency"|"percent"?), trend ("up"|"down"|"flat"?), trendValue (string?), color ("default"|"green"|"blue"|"purple"|"yellow"|"red"?)
|
|
|
|
### StatusBadge
|
|
Colored badge showing entity status.
|
|
Props: label (string), variant ("active"|"complete"|"paused"|"draft"|"error"|"sent"|"paid"|"pending"|"open"|"won"|"lost")
|
|
|
|
### Timeline
|
|
Chronological event list for activity feeds.
|
|
Props: events (array of {id, title, description?, timestamp, icon?, variant?})
|
|
Icon options: "email"|"phone"|"note"|"meeting"|"task"|"system"
|
|
|
|
### ProgressBar
|
|
Percentage bar with label and value.
|
|
Props: label (string), value (number), max (number?), color ("green"|"blue"|"purple"|"yellow"|"red"?), showPercent (boolean?), benchmark (number?), benchmarkLabel (string?)
|
|
|
|
### DetailHeader
|
|
Header for detail/preview pages with entity name, ID, status.
|
|
Props: title (string), subtitle (string?), entityId (string?), status (string?), statusVariant?
|
|
Can contain children.
|
|
|
|
### KeyValueList
|
|
List of label-value pairs for totals, metadata.
|
|
Props: items (array of {label, value, bold?, variant?, isTotalRow?}), compact (boolean?)
|
|
Variant options: "default"|"highlight"|"muted"|"success"|"danger"
|
|
|
|
### LineItemsTable
|
|
Invoice-style table with quantities and prices.
|
|
Props: items (array of {name, description?, quantity, unitPrice, total}), currency (string?)
|
|
|
|
### InfoBlock
|
|
Labeled block of information (e.g. From/To on invoices).
|
|
Props: label (string), name (string), lines (string[])
|
|
|
|
### SearchBar
|
|
Search input with placeholder.
|
|
Props: placeholder (string?), valuePath (string?)
|
|
|
|
### FilterChips
|
|
Toggleable filter tags.
|
|
Props: chips (array of {label, value, active?}), dataPath (string?)
|
|
|
|
### TabGroup
|
|
Tab navigation for switching views.
|
|
Props: tabs (array of {label, value, count?}), activeTab (string?), dataPath (string?)
|
|
|
|
### ActionButton
|
|
Clickable button with variants.
|
|
Props: label (string), variant ("primary"|"secondary"|"danger"|"ghost"?), size ("sm"|"md"|"lg"?), icon (string?), disabled (boolean?)
|
|
|
|
### ActionBar
|
|
Row of action buttons.
|
|
Props: align ("left"|"center"|"right"?)
|
|
Can contain children (ActionButton elements).
|
|
|
|
### CurrencyDisplay
|
|
Formatted monetary value with currency symbol and locale-aware formatting.
|
|
Props: amount (number, required), currency (string? default "USD"), locale (string? default "en-US"), size ("sm"|"md"|"lg"?), positive (boolean?), negative (boolean?)
|
|
|
|
### TagList
|
|
Visual tag/chip display for arrays of tags rendered as inline colored pills.
|
|
Props: tags (array of {label, color?, variant?} or strings, required), maxVisible (number?), size ("sm"|"md"?)
|
|
|
|
### CardGrid
|
|
Grid of visual cards with image, title, description for browsable catalogs and listings.
|
|
Props: cards (array of {title, description?, imageUrl?, subtitle?, status?, statusVariant?, action?}, required), columns (number? default 3)
|
|
|
|
### AvatarGroup
|
|
Stacked circular avatars for displaying users, followers, or team members.
|
|
Props: avatars (array of {name, imageUrl?, initials?}, required), max (number? default 5), size ("sm"|"md"|"lg"?)
|
|
|
|
### StarRating
|
|
Visual star rating display (1-5).
|
|
Props: rating (number, required), count (number?), maxStars (number? default 5), distribution (array of {stars, count}?), showDistribution (boolean?)
|
|
|
|
### StockIndicator
|
|
Visual stock level indicator showing green/yellow/red status with quantity.
|
|
Props: quantity (number, required), lowThreshold (number?), criticalThreshold (number?), label (string?)
|
|
|
|
### ChatThread
|
|
Conversation message thread with chat bubbles.
|
|
Props: messages (array of {id, content, direction: "inbound"|"outbound", type?, timestamp, senderName?, avatar?}), title (string?)
|
|
|
|
### EmailPreview
|
|
Rendered HTML email with header info in a bordered container.
|
|
Props: from (string), to (string), subject (string), date (string), body (string), cc (string?), attachments (array of {name, size}?)
|
|
|
|
### ContentPreview
|
|
Rich text/HTML content preview (sanitized).
|
|
Props: content (string), format ("html"|"markdown"|"text"?), maxHeight (number?), title (string?)
|
|
|
|
### TranscriptView
|
|
Time-stamped conversation transcript with speaker labels.
|
|
Props: entries (array of {timestamp, speaker, text, speakerRole?}), title (string?), duration (string?)
|
|
|
|
### AudioPlayer
|
|
Visual audio player UI with play button and waveform visualization.
|
|
Props: src (string?), title (string?), duration (string?), type ("recording"|"voicemail"?)
|
|
|
|
### ChecklistView
|
|
Task/checklist with checkboxes, due dates, assignees, and priority indicators.
|
|
Props: items (array of {id, title, completed?, dueDate?, assignee?, priority?}), title (string?), showProgress (boolean?)
|
|
|
|
### CalendarView
|
|
Monthly calendar grid with color-coded event blocks.
|
|
Props: year (number?), month (number?), events (array of {date, title, time?, color?, type?}), highlightToday (boolean?), title (string?)
|
|
|
|
### FlowDiagram
|
|
Linear node→arrow→node flow for triggers, IVR menus, funnel pages.
|
|
Props: nodes (array of {id, label, type?, description?}), edges (array of {from, to, label?}), direction ("horizontal"|"vertical"?), title (string?)
|
|
|
|
### TreeView
|
|
Hierarchical expandable tree.
|
|
Props: nodes (array of {id, label, icon?, children?, expanded?, badge?}), title (string?), expandAll (boolean?)
|
|
|
|
### MediaGallery
|
|
Thumbnail grid for images/files.
|
|
Props: items (array of {url, thumbnailUrl?, title?, fileType?, fileSize?, date?}), columns (number?), title (string?)
|
|
|
|
### DuplicateCompare
|
|
Side-by-side record comparison with field-level diff highlighting.
|
|
Props: records (array of {label, fields: Record<string, string>} — exactly 2), highlightDiffs (boolean?), title (string?)
|
|
|
|
### BarChart
|
|
Vertical or horizontal bar chart.
|
|
Props: bars (array of {label, value, color?}), orientation ("vertical"|"horizontal"?), maxValue (number?), showValues (boolean?), title (string?)
|
|
|
|
### LineChart
|
|
Time-series line chart with optional area fill.
|
|
Props: points (array of {label, value}), color (string?), showPoints (boolean?), showArea (boolean?), title (string?), yAxisLabel (string?)
|
|
|
|
### PieChart
|
|
Pie or donut chart for proportional breakdowns.
|
|
Props: segments (array of {label, value, color?}), donut (boolean?), title (string?), showLegend (boolean?)
|
|
|
|
### FunnelChart
|
|
Horizontal funnel showing stage drop-off.
|
|
Props: stages (array of {label, value, color?}), showDropoff (boolean?), title (string?)
|
|
|
|
### SparklineChart
|
|
Tiny inline chart.
|
|
Props: values (number[]), color (string?), height (number?), width (number?)
|
|
|
|
### ContactPicker
|
|
Searchable contact dropdown.
|
|
Props: searchTool (string, required), placeholder (string?), value (any?)
|
|
|
|
### InvoiceBuilder
|
|
Multi-section invoice form.
|
|
Props: createTool (string?), contactSearchTool (string?), initialContact (any?), initialItems (array?)
|
|
|
|
### OpportunityEditor
|
|
Inline editor for deal/opportunity fields.
|
|
Props: saveTool (string, required), opportunity (object, required), stages (array of {id, name}?)
|
|
|
|
### AppointmentBooker
|
|
Calendar-based appointment booking form.
|
|
Props: calendarTool (string?), bookTool (string?), contactSearchTool (string?), calendarId (string?)
|
|
|
|
### EditableField
|
|
Click-to-edit wrapper for any text value.
|
|
Props: value (string, required), fieldName (string, required), saveTool (string?), saveArgs (object?)
|
|
|
|
### SelectDropdown
|
|
Dropdown select.
|
|
Props: loadTool (string?), loadArgs (object?), options (array of {label, value}?), value (string?), placeholder (string?)
|
|
|
|
### FormGroup
|
|
Group of form fields with labels and validation.
|
|
Props: fields (array of {key, label, type?, value?, required?, options?}, required), submitLabel (string?), submitTool (string?)
|
|
|
|
### AmountInput
|
|
Currency-formatted number input.
|
|
Props: value (number, required), currency (string?)
|
|
|
|
## DATA RULES (CRITICAL — READ CAREFULLY)
|
|
- If real GHL data is provided in the user message, use ONLY that data. Do NOT add, invent, or embellish any records.
|
|
- Pipeline stages MUST come from the provided data. Never invent stage names unless they literally exist in the data.
|
|
- Show exactly the records provided. If there are 2 opportunities, show 2. Don't add fake ones.
|
|
- If no data is provided, THEN you may use sample data, but keep it minimal (3-5 records max).
|
|
- When generating interactive views, use correct tool names for GHL:
|
|
- ContactPicker: searchTool="search_contacts"
|
|
- InvoiceBuilder: createTool="create_invoice", contactSearchTool="search_contacts"
|
|
- OpportunityEditor: saveTool="update_opportunity"
|
|
- KanbanBoard: moveTool="update_opportunity"`;
|
|
|
|
// ─── Types ──────────────────────────────────────────────────
|
|
|
|
export interface AppToolResult {
|
|
content: Array<{ type: 'text'; text: string }>;
|
|
structuredContent?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface AppResourceHandler {
|
|
uri: string;
|
|
mimeType: string;
|
|
getContent: () => string;
|
|
}
|
|
|
|
// ─── UI Build Path Resolver ─────────────────────────────────
|
|
|
|
function getUIBuildPath(): string {
|
|
const fromDist = path.resolve(__dirname, '..', 'app-ui');
|
|
if (fs.existsSync(fromDist)) return fromDist;
|
|
const appUiPath = path.join(process.cwd(), 'dist', 'app-ui');
|
|
if (fs.existsSync(appUiPath)) return appUiPath;
|
|
return fromDist;
|
|
}
|
|
|
|
// ─── MCP Apps Manager ──────────────────────────────────────
|
|
|
|
export class MCPAppsManager {
|
|
private ghlClient: GHLApiClient;
|
|
private resourceHandlers: Map<string, AppResourceHandler> = new Map();
|
|
private uiBuildPath: string;
|
|
private pendingDynamicData: any = null;
|
|
/** Cached universal renderer HTML */
|
|
private rendererHTML: string | null = null;
|
|
|
|
constructor(ghlClient: GHLApiClient) {
|
|
this.ghlClient = ghlClient;
|
|
this.uiBuildPath = getUIBuildPath();
|
|
process.stderr.write(`[MCP Apps] UI build path: ${this.uiBuildPath}\n`);
|
|
this.registerResourceHandlers();
|
|
}
|
|
|
|
// ─── Resource Registration ──────────────────────────────
|
|
|
|
private registerResourceHandlers(): void {
|
|
// Universal renderer is the ONLY real resource
|
|
// All view_* tools inject their UITree into this same renderer
|
|
const universalResource = {
|
|
uri: 'ui://ghl/app',
|
|
mimeType: 'text/html;profile=mcp-app',
|
|
getContent: () => {
|
|
const html = this.getRendererHTML();
|
|
if (this.pendingDynamicData) {
|
|
const data = this.pendingDynamicData;
|
|
this.pendingDynamicData = null;
|
|
process.stderr.write(`[MCP Apps] Injecting UITree into universal renderer\n`);
|
|
return this.injectDataIntoHTML(html, data);
|
|
}
|
|
return html;
|
|
},
|
|
};
|
|
|
|
this.resourceHandlers.set('ui://ghl/app', universalResource);
|
|
|
|
// Keep dynamic-view as an alias for backward compatibility
|
|
this.resourceHandlers.set('ui://ghl/dynamic-view', {
|
|
...universalResource,
|
|
uri: 'ui://ghl/dynamic-view',
|
|
});
|
|
|
|
// Legacy resource URIs — all point to the universal renderer
|
|
const legacyURIs = [
|
|
'ui://ghl/mcp-app',
|
|
'ui://ghl/pipeline-board',
|
|
'ui://ghl/quick-book',
|
|
'ui://ghl/opportunity-card',
|
|
'ui://ghl/contact-grid',
|
|
'ui://ghl/calendar-view',
|
|
'ui://ghl/invoice-preview',
|
|
'ui://ghl/campaign-stats',
|
|
'ui://ghl/agent-stats',
|
|
'ui://ghl/contact-timeline',
|
|
'ui://ghl/workflow-status',
|
|
];
|
|
|
|
for (const uri of legacyURIs) {
|
|
this.resourceHandlers.set(uri, {
|
|
uri,
|
|
mimeType: 'text/html;profile=mcp-app',
|
|
getContent: universalResource.getContent,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load and cache the universal renderer HTML
|
|
*/
|
|
private getRendererHTML(): string {
|
|
if (this.rendererHTML) return this.rendererHTML;
|
|
|
|
// Try universal-renderer first, fall back to dynamic-view
|
|
for (const filename of ['universal-renderer.html', 'dynamic-view.html']) {
|
|
const filePath = path.join(this.uiBuildPath, filename);
|
|
try {
|
|
this.rendererHTML = fs.readFileSync(filePath, 'utf-8');
|
|
process.stderr.write(`[MCP Apps] Loaded universal renderer from ${filename}\n`);
|
|
return this.rendererHTML;
|
|
} catch {
|
|
// Try next
|
|
}
|
|
}
|
|
|
|
process.stderr.write(`[MCP Apps] WARNING: Universal renderer HTML not found, using fallback\n`);
|
|
this.rendererHTML = this.getFallbackHTML();
|
|
return this.rendererHTML;
|
|
}
|
|
|
|
private getFallbackHTML(): string {
|
|
return `<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
<title>GHL View</title>
|
|
<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:20px}
|
|
.fallback{text-align:center;color:#666}</style></head>
|
|
<body><div class="fallback"><p>UI renderer is loading...</p><p>Run <code>npm run build:dynamic-ui</code> to build.</p></div>
|
|
<script>
|
|
window.addEventListener('message',(e)=>{if(e.data?.type==='mcp-app-init'){console.log('MCP App data:',e.data.data)}});
|
|
const d=window.__MCP_APP_DATA__;if(d){document.querySelector('.fallback').innerHTML='<pre>'+JSON.stringify(d,null,2)+'</pre>'}
|
|
</script></body></html>`;
|
|
}
|
|
|
|
// ─── Tool Definitions ───────────────────────────────────
|
|
|
|
getToolDefinitions(): Tool[] {
|
|
// All tools point to the universal renderer resource
|
|
const appUri = 'ui://ghl/app';
|
|
|
|
return [
|
|
{
|
|
name: 'view_contact_grid',
|
|
description: 'Display contact search results in a data grid with sorting and pagination. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
query: { type: 'string', description: 'Search query string' },
|
|
limit: { type: 'number', description: 'Maximum results (default: 25)' },
|
|
},
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_pipeline_board',
|
|
description: 'Display a pipeline as an interactive Kanban board with opportunities. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
pipelineId: { type: 'string', description: 'Pipeline ID to display' },
|
|
},
|
|
required: ['pipelineId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_quick_book',
|
|
description: 'Display a quick booking interface for scheduling appointments. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
calendarId: { type: 'string', description: 'Calendar ID for booking' },
|
|
contactId: { type: 'string', description: 'Optional contact ID to pre-fill' },
|
|
},
|
|
required: ['calendarId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_opportunity_card',
|
|
description: 'Display a single opportunity with details, value, and stage info. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
opportunityId: { type: 'string', description: 'Opportunity ID to display' },
|
|
},
|
|
required: ['opportunityId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_calendar',
|
|
description: 'Display a calendar with events and appointments. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
calendarId: { type: 'string', description: 'Calendar ID to display' },
|
|
startDate: { type: 'string', description: 'Start date (ISO format)' },
|
|
endDate: { type: 'string', description: 'End date (ISO format)' },
|
|
},
|
|
required: ['calendarId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_invoice',
|
|
description: 'Display an invoice preview with line items and payment status. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
invoiceId: { type: 'string', description: 'Invoice ID to display' },
|
|
},
|
|
required: ['invoiceId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_campaign_stats',
|
|
description: 'Display campaign statistics and performance metrics. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
campaignId: { type: 'string', description: 'Campaign ID to display stats for' },
|
|
},
|
|
required: ['campaignId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_agent_stats',
|
|
description: 'Display agent/user performance statistics and metrics. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
userId: { type: 'string', description: 'User/Agent ID to display stats for' },
|
|
dateRange: { type: 'string', description: 'Date range (e.g., "last7days", "last30days")' },
|
|
},
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_contact_timeline',
|
|
description: "Display a contact's activity timeline with all interactions. Returns a visual UI component.",
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
contactId: { type: 'string', description: 'Contact ID to display timeline for' },
|
|
},
|
|
required: ['contactId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_workflow_status',
|
|
description: 'Display workflow execution status and history. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
workflowId: { type: 'string', description: 'Workflow ID to display status for' },
|
|
},
|
|
required: ['workflowId'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'view_dashboard',
|
|
description: 'Display the main GHL dashboard overview. Returns a visual UI component.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {},
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'generate_ghl_view',
|
|
description: 'Generate a rich, AI-powered UI view on the fly from a natural language prompt. Optionally fetches real GHL data to populate the view. Returns a visual UI component rendered in the MCP App.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
prompt: {
|
|
type: 'string',
|
|
description: 'Natural language description of the UI view to generate.',
|
|
},
|
|
dataSource: {
|
|
type: 'string',
|
|
enum: ['contacts', 'opportunities', 'pipelines', 'calendars', 'invoices'],
|
|
description: 'Optional: fetch real GHL data to include in the generated view.',
|
|
},
|
|
},
|
|
required: ['prompt'],
|
|
},
|
|
_meta: { ui: { resourceUri: appUri } },
|
|
},
|
|
{
|
|
name: 'update_opportunity',
|
|
description: 'Update an opportunity (move to stage, change value, status, etc.)',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
opportunityId: { type: 'string', description: 'Opportunity ID to update' },
|
|
pipelineStageId: { type: 'string', description: 'New stage ID (for moving)' },
|
|
name: { type: 'string', description: 'Opportunity name' },
|
|
monetaryValue: { type: 'number', description: 'Monetary value' },
|
|
status: { type: 'string', enum: ['open', 'won', 'lost', 'abandoned'], description: 'Opportunity status' },
|
|
},
|
|
required: ['opportunityId'],
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
// ─── Tool Routing ───────────────────────────────────────
|
|
|
|
getAppToolNames(): string[] {
|
|
return [
|
|
'view_contact_grid', 'view_pipeline_board', 'view_quick_book',
|
|
'view_opportunity_card', 'view_calendar', 'view_invoice',
|
|
'view_campaign_stats', 'view_agent_stats', 'view_contact_timeline',
|
|
'view_workflow_status', 'view_dashboard', 'generate_ghl_view',
|
|
'update_opportunity',
|
|
];
|
|
}
|
|
|
|
isAppTool(toolName: string): boolean {
|
|
return this.getAppToolNames().includes(toolName);
|
|
}
|
|
|
|
async executeTool(toolName: string, args: Record<string, any>): Promise<AppToolResult> {
|
|
process.stderr.write(`[MCP Apps] Executing: ${toolName}\n`);
|
|
|
|
switch (toolName) {
|
|
case 'view_contact_grid':
|
|
return this.viewContactGrid(args.query, args.limit);
|
|
case 'view_pipeline_board':
|
|
return this.viewPipelineBoard(args.pipelineId);
|
|
case 'view_quick_book':
|
|
return this.viewQuickBook(args.calendarId, args.contactId);
|
|
case 'view_opportunity_card':
|
|
return this.viewOpportunityCard(args.opportunityId);
|
|
case 'view_calendar':
|
|
return this.viewCalendar(args.calendarId, args.startDate, args.endDate);
|
|
case 'view_invoice':
|
|
return this.viewInvoice(args.invoiceId);
|
|
case 'view_campaign_stats':
|
|
return this.viewCampaignStats(args.campaignId);
|
|
case 'view_agent_stats':
|
|
return this.viewAgentStats(args.userId, args.dateRange);
|
|
case 'view_contact_timeline':
|
|
return this.viewContactTimeline(args.contactId);
|
|
case 'view_workflow_status':
|
|
return this.viewWorkflowStatus(args.workflowId);
|
|
case 'view_dashboard':
|
|
return this.viewDashboard();
|
|
case 'generate_ghl_view':
|
|
return this.generateDynamicView(args.prompt, args.dataSource);
|
|
case 'update_opportunity':
|
|
return this.updateOpportunity(args as {
|
|
opportunityId: string;
|
|
pipelineStageId?: string;
|
|
name?: string;
|
|
monetaryValue?: number;
|
|
status?: 'open' | 'won' | 'lost' | 'abandoned';
|
|
});
|
|
default:
|
|
throw new Error(`Unknown app tool: ${toolName}`);
|
|
}
|
|
}
|
|
|
|
// ─── View Handlers (fetch data → template → universal renderer) ──
|
|
|
|
private async viewContactGrid(query?: string, limit?: number): Promise<AppToolResult> {
|
|
const response = await this.ghlClient.searchContacts({
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
query, limit: limit || 25,
|
|
});
|
|
if (!response.success) throw new Error(response.error?.message || 'Failed to search contacts');
|
|
|
|
const uiTree = buildContactGridTree({
|
|
contacts: response.data?.contacts || [],
|
|
query,
|
|
});
|
|
|
|
return this.renderUITree(uiTree, `Found ${response.data?.contacts?.length || 0} contacts`);
|
|
}
|
|
|
|
private async viewPipelineBoard(pipelineId: string): Promise<AppToolResult> {
|
|
const [pipelinesResponse, opportunitiesResponse] = await Promise.all([
|
|
this.ghlClient.getPipelines(),
|
|
this.ghlClient.searchOpportunities({
|
|
location_id: this.ghlClient.getConfig().locationId,
|
|
pipeline_id: pipelineId,
|
|
}),
|
|
]);
|
|
if (!pipelinesResponse.success) throw new Error(pipelinesResponse.error?.message || 'Failed to get pipeline');
|
|
|
|
const pipeline = pipelinesResponse.data?.pipelines?.find((p: any) => p.id === pipelineId);
|
|
const opportunities = (opportunitiesResponse.data?.opportunities || []).map((opp: any) => ({
|
|
id: opp.id, name: opp.name || 'Untitled',
|
|
pipelineStageId: opp.pipelineStageId, status: opp.status || 'open',
|
|
monetaryValue: opp.monetaryValue || 0,
|
|
contact: opp.contact ? { name: opp.contact.name || 'Unknown', email: opp.contact.email, phone: opp.contact.phone } : { name: 'Unknown' },
|
|
updatedAt: opp.updatedAt || opp.createdAt, createdAt: opp.createdAt, source: opp.source,
|
|
}));
|
|
|
|
const uiTree = buildPipelineBoardTree({
|
|
pipeline, opportunities, stages: pipeline?.stages || [],
|
|
});
|
|
|
|
return this.renderUITree(uiTree, `Pipeline: ${pipeline?.name || 'Unknown'} (${opportunities.length} opportunities)`);
|
|
}
|
|
|
|
private async viewQuickBook(calendarId: string, contactId?: string): Promise<AppToolResult> {
|
|
const [calendarResponse, contactResponse] = await Promise.all([
|
|
this.ghlClient.getCalendar(calendarId),
|
|
contactId ? this.ghlClient.getContact(contactId) : Promise.resolve({ success: true, data: null }),
|
|
]);
|
|
if (!calendarResponse.success) throw new Error(calendarResponse.error?.message || 'Failed to get calendar');
|
|
|
|
const uiTree = buildQuickBookTree({
|
|
calendar: calendarResponse.data,
|
|
contact: contactResponse.data,
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
});
|
|
|
|
return this.renderUITree(uiTree, `Quick booking for calendar: ${(calendarResponse.data as any)?.name || calendarId}`);
|
|
}
|
|
|
|
private async viewOpportunityCard(opportunityId: string): Promise<AppToolResult> {
|
|
const response = await this.ghlClient.getOpportunity(opportunityId);
|
|
if (!response.success) throw new Error(response.error?.message || 'Failed to get opportunity');
|
|
|
|
const uiTree = buildOpportunityCardTree(response.data);
|
|
return this.renderUITree(uiTree, `Opportunity: ${(response.data as any)?.name || opportunityId}`);
|
|
}
|
|
|
|
private async viewCalendar(calendarId: string, startDate?: string, endDate?: string): Promise<AppToolResult> {
|
|
const now = new Date();
|
|
const start = startDate || new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
|
const end = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString();
|
|
|
|
const [calendarResponse, eventsResponse] = await Promise.all([
|
|
this.ghlClient.getCalendar(calendarId),
|
|
this.ghlClient.getCalendarEvents({
|
|
calendarId, startTime: start, endTime: end,
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
}),
|
|
]);
|
|
if (!calendarResponse.success) throw new Error(calendarResponse.error?.message || 'Failed to get calendar');
|
|
|
|
const calendar = calendarResponse.data as any;
|
|
const events = eventsResponse.data?.events || [];
|
|
|
|
const uiTree = buildCalendarViewTree({ calendar, events, startDate: start, endDate: end });
|
|
return this.renderUITree(uiTree, `Calendar: ${calendar?.name || 'Unknown'} (${events.length} events)`);
|
|
}
|
|
|
|
private async viewInvoice(invoiceId: string): Promise<AppToolResult> {
|
|
const response = await this.ghlClient.getInvoice(invoiceId, {
|
|
altId: this.ghlClient.getConfig().locationId,
|
|
altType: 'location',
|
|
});
|
|
if (!response.success) throw new Error(response.error?.message || 'Failed to get invoice');
|
|
|
|
const invoice = response.data;
|
|
const uiTree = buildInvoicePreviewTree(invoice);
|
|
return this.renderUITree(uiTree, `Invoice #${invoice?.invoiceNumber || invoiceId} - ${invoice?.status || 'Unknown status'}`);
|
|
}
|
|
|
|
private async viewCampaignStats(campaignId: string): Promise<AppToolResult> {
|
|
const response = await this.ghlClient.getEmailCampaigns({});
|
|
const campaigns = response.data?.schedules || [];
|
|
const campaign = campaigns.find((c: any) => c.id === campaignId) || { id: campaignId };
|
|
|
|
const uiTree = buildCampaignStatsTree({
|
|
campaign, campaigns, campaignId,
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
});
|
|
|
|
return this.renderUITree(uiTree, `Campaign stats: ${(campaign as any)?.name || campaignId}`);
|
|
}
|
|
|
|
private async viewAgentStats(userId?: string, dateRange?: string): Promise<AppToolResult> {
|
|
const locationResponse = await this.ghlClient.getLocationById(this.ghlClient.getConfig().locationId);
|
|
|
|
const uiTree = buildAgentStatsTree({
|
|
userId, dateRange: dateRange || 'last30days',
|
|
location: locationResponse.data,
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
});
|
|
|
|
return this.renderUITree(uiTree, userId ? `Agent stats: ${userId}` : 'Agent overview');
|
|
}
|
|
|
|
private async viewContactTimeline(contactId: string): Promise<AppToolResult> {
|
|
const [contactResponse, notesResponse, tasksResponse] = await Promise.all([
|
|
this.ghlClient.getContact(contactId),
|
|
this.ghlClient.getContactNotes(contactId),
|
|
this.ghlClient.getContactTasks(contactId),
|
|
]);
|
|
if (!contactResponse.success) throw new Error(contactResponse.error?.message || 'Failed to get contact');
|
|
|
|
const contact = contactResponse.data as any;
|
|
const uiTree = buildContactTimelineTree({
|
|
contact: contactResponse.data,
|
|
notes: notesResponse.data || [],
|
|
tasks: tasksResponse.data || [],
|
|
});
|
|
|
|
return this.renderUITree(uiTree, `Timeline for ${contact?.firstName || ''} ${contact?.lastName || ''}`);
|
|
}
|
|
|
|
private async viewWorkflowStatus(workflowId: string): Promise<AppToolResult> {
|
|
const response = await this.ghlClient.getWorkflows({
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
});
|
|
const workflows = response.data?.workflows || [];
|
|
const workflow = workflows.find((w: any) => w.id === workflowId) || { id: workflowId };
|
|
|
|
const uiTree = buildWorkflowStatusTree({
|
|
workflow, workflows, workflowId,
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
});
|
|
|
|
return this.renderUITree(uiTree, `Workflow: ${(workflow as any)?.name || workflowId}`);
|
|
}
|
|
|
|
private async viewDashboard(): Promise<AppToolResult> {
|
|
const [contactsResponse, pipelinesResponse, calendarsResponse] = await Promise.all([
|
|
this.ghlClient.searchContacts({ locationId: this.ghlClient.getConfig().locationId, limit: 10 }),
|
|
this.ghlClient.getPipelines(),
|
|
this.ghlClient.getCalendars(),
|
|
]);
|
|
|
|
const uiTree = buildDashboardTree({
|
|
recentContacts: contactsResponse.data?.contacts || [],
|
|
pipelines: pipelinesResponse.data?.pipelines || [],
|
|
calendars: calendarsResponse.data?.calendars || [],
|
|
locationId: this.ghlClient.getConfig().locationId,
|
|
});
|
|
|
|
return this.renderUITree(uiTree, 'GHL Dashboard Overview');
|
|
}
|
|
|
|
// ─── Dynamic View (LLM-powered) ────────────────────────
|
|
|
|
private detectDataSources(prompt: string): string[] {
|
|
const lower = prompt.toLowerCase();
|
|
const sources: string[] = [];
|
|
if (lower.match(/pipeline|kanban|deal|opportunit|stage|funnel|sales/)) sources.push('pipelines');
|
|
if (lower.match(/contact|lead|customer|people|person|client/)) sources.push('contacts');
|
|
if (lower.match(/calendar|appointment|event|schedule|booking/)) sources.push('calendars');
|
|
if (lower.match(/invoice|billing|payment|charge/)) sources.push('invoices');
|
|
if (lower.match(/campaign|email.*market|newsletter|broadcast/)) sources.push('campaigns');
|
|
if (sources.length === 0) sources.push('contacts', 'pipelines');
|
|
return sources;
|
|
}
|
|
|
|
private async generateDynamicView(prompt: string, dataSource?: string): Promise<AppToolResult> {
|
|
process.stderr.write(`[MCP Apps] Generating dynamic view: "${prompt}" (dataSource: ${dataSource || 'auto'})\n`);
|
|
|
|
// Step 1: Fetch real GHL data
|
|
let ghlData: any = {};
|
|
const sources = dataSource ? [dataSource] : this.detectDataSources(prompt);
|
|
|
|
for (const src of sources) {
|
|
try {
|
|
const data = await this.fetchGHLData(src);
|
|
if (data) Object.assign(ghlData, data);
|
|
} catch (err: any) {
|
|
process.stderr.write(`[MCP Apps] Warning: Failed to fetch GHL data for ${src}: ${err.message}\n`);
|
|
}
|
|
}
|
|
if (Object.keys(ghlData).length === 0) ghlData = null;
|
|
|
|
// Step 2: Call Claude API
|
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
if (!apiKey) throw new Error('ANTHROPIC_API_KEY environment variable is required for generate_ghl_view');
|
|
|
|
const anthropic = new Anthropic({ apiKey });
|
|
|
|
let userMessage: string;
|
|
if (ghlData) {
|
|
const dataKeys = Object.keys(ghlData);
|
|
const summary: string[] = [];
|
|
if (ghlData.pipelines) summary.push(`${ghlData.pipelines.length} pipeline(s)`);
|
|
if (ghlData.opportunities) summary.push(`${ghlData.opportunities.length} opportunity/deal(s)`);
|
|
if (ghlData.contacts) summary.push(`${ghlData.contacts.length} contact(s)`);
|
|
if (ghlData.calendars) summary.push(`${ghlData.calendars.length} calendar(s)`);
|
|
if (ghlData.invoices) summary.push(`${ghlData.invoices.length} invoice(s)`);
|
|
if (ghlData.campaigns) summary.push(`${ghlData.campaigns.length} campaign(s)`);
|
|
|
|
userMessage = `${prompt}
|
|
|
|
⛔ STRICT DATA RULES:
|
|
- You have REAL CRM data below: ${summary.join(', ')}
|
|
- Use ONLY this data. Do NOT invent ANY additional records.
|
|
- If pipelines are provided, use ONLY the stage names from pipelines[].stages[].name.
|
|
- Show exactly the records provided.
|
|
- Do NOT add sections for data types not provided (${['tasks', 'workflows', 'notes', 'emails'].filter(k => !dataKeys.includes(k)).join(', ')} were NOT fetched).
|
|
|
|
REAL GHL DATA:
|
|
\`\`\`json
|
|
${JSON.stringify(ghlData, null, 2)}
|
|
\`\`\``;
|
|
} else {
|
|
userMessage = `${prompt}\n\n(No real data available — use minimal sample data, 3-5 records max.)`;
|
|
}
|
|
|
|
let message;
|
|
try {
|
|
message = await anthropic.messages.create({
|
|
model: 'claude-sonnet-4-20250514',
|
|
max_tokens: 8192,
|
|
system: CATALOG_SYSTEM_PROMPT,
|
|
messages: [{ role: 'user', content: userMessage }],
|
|
});
|
|
} catch (aiErr: any) {
|
|
throw new Error(`AI generation failed: ${aiErr.message}`);
|
|
}
|
|
|
|
const text = message.content
|
|
.filter((b): b is Anthropic.TextBlock => b.type === 'text')
|
|
.map(b => b.text)
|
|
.join('');
|
|
|
|
const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
|
|
let uiTree: UITree;
|
|
try {
|
|
uiTree = JSON.parse(cleaned);
|
|
} catch (parseErr: any) {
|
|
throw new Error(`Failed to parse AI response as JSON: ${parseErr.message}`);
|
|
}
|
|
|
|
// Validate the tree
|
|
const errors = validateUITree(uiTree);
|
|
if (errors.length > 0) {
|
|
process.stderr.write(`[MCP Apps] UITree validation warnings: ${JSON.stringify(errors)}\n`);
|
|
// Don't throw — render what we got, the renderer handles unknown types gracefully
|
|
}
|
|
|
|
process.stderr.write(`[MCP Apps] Generated UI tree with ${Object.keys(uiTree.elements).length} elements\n`);
|
|
|
|
return this.renderUITree(uiTree, `Generated dynamic view: ${prompt}`);
|
|
}
|
|
|
|
// ─── Data Fetching ──────────────────────────────────────
|
|
|
|
private async fetchGHLData(dataSource: string): Promise<any> {
|
|
const locationId = this.ghlClient.getConfig().locationId;
|
|
|
|
switch (dataSource) {
|
|
case 'contacts': {
|
|
const resp = await this.ghlClient.searchContacts({ locationId, limit: 20 });
|
|
return { contacts: resp.data?.contacts || [] };
|
|
}
|
|
case 'opportunities': {
|
|
const resp = await this.ghlClient.searchOpportunities({ location_id: locationId });
|
|
return { opportunities: resp.data?.opportunities || [] };
|
|
}
|
|
case 'pipelines': {
|
|
const [pResp, oResp] = await Promise.all([
|
|
this.ghlClient.getPipelines(),
|
|
this.ghlClient.searchOpportunities({ location_id: locationId }),
|
|
]);
|
|
return {
|
|
pipelines: pResp.data?.pipelines || [],
|
|
opportunities: oResp.data?.opportunities || [],
|
|
};
|
|
}
|
|
case 'calendars': {
|
|
const resp = await this.ghlClient.getCalendars();
|
|
return { calendars: resp.data?.calendars || [] };
|
|
}
|
|
case 'invoices': {
|
|
const resp = await this.ghlClient.listInvoices?.({
|
|
altId: locationId, altType: 'location', limit: '10', offset: '0',
|
|
}) || { data: { invoices: [] } };
|
|
return { invoices: resp.data?.invoices || [] };
|
|
}
|
|
case 'campaigns': {
|
|
const resp = await this.ghlClient.getEmailCampaigns({});
|
|
return { campaigns: resp.data?.schedules || [] };
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Action Tools ───────────────────────────────────────
|
|
|
|
private async updateOpportunity(args: {
|
|
opportunityId: string;
|
|
pipelineStageId?: string;
|
|
name?: string;
|
|
monetaryValue?: number;
|
|
status?: 'open' | 'won' | 'lost' | 'abandoned';
|
|
}): Promise<AppToolResult> {
|
|
const { opportunityId, ...updates } = args;
|
|
const updatePayload: any = {};
|
|
if (updates.pipelineStageId) updatePayload.pipelineStageId = updates.pipelineStageId;
|
|
if (updates.name) updatePayload.name = updates.name;
|
|
if (updates.monetaryValue !== undefined) updatePayload.monetaryValue = updates.monetaryValue;
|
|
if (updates.status) updatePayload.status = updates.status;
|
|
|
|
process.stderr.write(`[MCP Apps] Updating opportunity ${opportunityId}: ${JSON.stringify(updatePayload)}\n`);
|
|
const response = await this.ghlClient.updateOpportunity(opportunityId, updatePayload);
|
|
if (!response.success) throw new Error(response.error?.message || 'Failed to update opportunity');
|
|
|
|
const opportunity = response.data;
|
|
return {
|
|
content: [{ type: 'text', text: `Updated opportunity: ${opportunity?.name || opportunityId}` }],
|
|
structuredContent: {
|
|
success: true,
|
|
opportunity: {
|
|
id: opportunity?.id, name: opportunity?.name,
|
|
pipelineStageId: opportunity?.pipelineStageId,
|
|
monetaryValue: opportunity?.monetaryValue, status: opportunity?.status,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── Universal Render Pipeline ──────────────────────────
|
|
|
|
/**
|
|
* Core render method: takes a UITree, injects it into the universal
|
|
* renderer, and returns a structuredContent result.
|
|
*/
|
|
private renderUITree(uiTree: UITree, textSummary: string): AppToolResult {
|
|
// Store UITree for injection when resource is read
|
|
this.pendingDynamicData = { uiTree };
|
|
|
|
return {
|
|
content: [{ type: 'text', text: textSummary }],
|
|
structuredContent: { uiTree } as Record<string, unknown>,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Inject data into HTML as a script tag (for pre-injected __MCP_APP_DATA__)
|
|
*/
|
|
private injectDataIntoHTML(html: string, data: any): string {
|
|
const dataScript = `<script>window.__MCP_APP_DATA__ = ${JSON.stringify(data)};</script>`;
|
|
if (html.includes('</head>')) {
|
|
return html.replace('</head>', `${dataScript}</head>`);
|
|
} else if (html.includes('<body>')) {
|
|
return html.replace('<body>', `<body>${dataScript}`);
|
|
}
|
|
return dataScript + html;
|
|
}
|
|
|
|
// ─── Resource Access ────────────────────────────────────
|
|
|
|
getResourceHandler(uri: string): AppResourceHandler | undefined {
|
|
return this.resourceHandlers.get(uri);
|
|
}
|
|
|
|
getResourceURIs(): string[] {
|
|
return Array.from(this.resourceHandlers.keys());
|
|
}
|
|
}
|