/** * 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": "", "elements": { "": { "key": "", "type": "", "props": { ... }, "children": ["", ""] } } } ## 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} — 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; [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 = 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 ` GHL View

UI renderer is loading...

Run npm run build:dynamic-ui to build.

`; } // ─── 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): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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, }; } /** * Inject data into HTML as a script tag (for pre-injected __MCP_APP_DATA__) */ private injectDataIntoHTML(html: string, data: any): string { const dataScript = ``; if (html.includes('')) { return html.replace('', `${dataScript}`); } else if (html.includes('')) { return html.replace('', `${dataScript}`); } return dataScript + html; } // ─── Resource Access ──────────────────────────────────── getResourceHandler(uri: string): AppResourceHandler | undefined { return this.resourceHandlers.get(uri); } getResourceURIs(): string[] { return Array.from(this.resourceHandlers.keys()); } }