2026-01-30 23:00:51 -05:00

655 lines
27 KiB
JavaScript

/**
* MCP Apps Manager
* Manages rich UI components for GoHighLevel MCP Server
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
/**
* MCP Apps Manager class
* Registers app tools and handles structuredContent responses
*/
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve UI build path - works regardless of working directory
function getUIBuildPath() {
// 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: try process.cwd() based paths
const appUiPath = path.join(process.cwd(), 'dist', 'app-ui');
if (fs.existsSync(appUiPath)) {
return appUiPath;
}
// Default fallback
return fromDist;
}
export class MCPAppsManager {
ghlClient;
resourceHandlers = new Map();
uiBuildPath;
constructor(ghlClient) {
this.ghlClient = ghlClient;
this.uiBuildPath = getUIBuildPath();
process.stderr.write(`[MCP Apps] UI build path: ${this.uiBuildPath}\n`);
this.registerResourceHandlers();
}
/**
* Register all UI resource handlers
*/
registerResourceHandlers() {
const resources = [
// All 11 MCP Apps
{ uri: 'ui://ghl/mcp-app', file: 'mcp-app.html' },
{ uri: 'ui://ghl/pipeline-board', file: 'pipeline-board.html' },
{ uri: 'ui://ghl/quick-book', file: 'quick-book.html' },
{ uri: 'ui://ghl/opportunity-card', file: 'opportunity-card.html' },
{ uri: 'ui://ghl/contact-grid', file: 'contact-grid.html' },
{ uri: 'ui://ghl/calendar-view', file: 'calendar-view.html' },
{ uri: 'ui://ghl/invoice-preview', file: 'invoice-preview.html' },
{ uri: 'ui://ghl/campaign-stats', file: 'campaign-stats.html' },
{ uri: 'ui://ghl/agent-stats', file: 'agent-stats.html' },
{ uri: 'ui://ghl/contact-timeline', file: 'contact-timeline.html' },
{ uri: 'ui://ghl/workflow-status', file: 'workflow-status.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
*/
loadUIResource(filename) {
const filePath = path.join(this.uiBuildPath, filename);
try {
return fs.readFileSync(filePath, 'utf-8');
}
catch (error) {
process.stderr.write(`[MCP Apps] UI resource not found: ${filePath}\n`);
return this.getFallbackHTML(filename);
}
}
/**
* Generate fallback HTML when UI resource is not built
*/
getFallbackHTML(filename) {
const componentName = filename.replace('.html', '');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GHL ${componentName}</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 component "${componentName}" is loading...</p>
<p>Run <code>npm run build:ui</code> to build UI components.</p>
</div>
<script>
window.addEventListener('message', (e) => {
if (e.data?.type === 'mcp-app-init') {
console.log('MCP App data:', e.data.data);
}
});
</script>
</body>
</html>
`.trim();
}
/**
* Get tool definitions for all app tools
*/
getToolDefinitions() {
return [
// 1. Contact Grid - search and display contacts
{
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: 'ui://ghl/contact-grid' }
}
},
// 2. Pipeline Board - Kanban view of opportunities
{
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: 'ui://ghl/pipeline-board' }
}
},
// 3. Quick Book - appointment booking
{
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: 'ui://ghl/quick-book' }
}
},
// 4. Opportunity Card - single opportunity details
{
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: 'ui://ghl/opportunity-card' }
}
},
// 5. Calendar View - calendar with events
{
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: 'ui://ghl/calendar-view' }
}
},
// 6. Invoice Preview - invoice details
{
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: 'ui://ghl/invoice-preview' }
}
},
// 7. Campaign Stats - campaign performance metrics
{
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: 'ui://ghl/campaign-stats' }
}
},
// 8. Agent Stats - agent/user performance
{
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: 'ui://ghl/agent-stats' }
}
},
// 9. Contact Timeline - activity history for a contact
{
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: 'ui://ghl/contact-timeline' }
}
},
// 10. Workflow Status - workflow execution status
{
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: 'ui://ghl/workflow-status' }
}
},
// 11. MCP App - generic/main dashboard
{
name: 'view_dashboard',
description: 'Display the main GHL dashboard overview. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {}
},
_meta: {
ui: { resourceUri: 'ui://ghl/mcp-app' }
}
},
// 12. Update Opportunity - action tool for UI to update opportunities
{
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']
}
}
];
}
/**
* Get app tool names for routing
*/
getAppToolNames() {
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',
'update_opportunity'
];
}
/**
* Check if a tool is an app tool
*/
isAppTool(toolName) {
return this.getAppToolNames().includes(toolName);
}
/**
* Execute an app tool
*/
async executeTool(toolName, args) {
process.stderr.write(`[MCP Apps] Executing app tool: ${toolName}\n`);
switch (toolName) {
case 'view_contact_grid':
return await this.viewContactGrid(args.query, args.limit);
case 'view_pipeline_board':
return await this.viewPipelineBoard(args.pipelineId);
case 'view_quick_book':
return await this.viewQuickBook(args.calendarId, args.contactId);
case 'view_opportunity_card':
return await this.viewOpportunityCard(args.opportunityId);
case 'view_calendar':
return await this.viewCalendar(args.calendarId, args.startDate, args.endDate);
case 'view_invoice':
return await this.viewInvoice(args.invoiceId);
case 'view_campaign_stats':
return await this.viewCampaignStats(args.campaignId);
case 'view_agent_stats':
return await this.viewAgentStats(args.userId, args.dateRange);
case 'view_contact_timeline':
return await this.viewContactTimeline(args.contactId);
case 'view_workflow_status':
return await this.viewWorkflowStatus(args.workflowId);
case 'view_dashboard':
return await this.viewDashboard();
case 'update_opportunity':
return await this.updateOpportunity(args);
default:
throw new Error(`Unknown app tool: ${toolName}`);
}
}
/**
* View contact grid (search results)
*/
async viewContactGrid(query, limit) {
const response = await this.ghlClient.searchContacts({
locationId: this.ghlClient.getConfig().locationId,
query: query,
limit: limit || 25
});
if (!response.success) {
throw new Error(response.error?.message || 'Failed to search contacts');
}
const data = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/contact-grid');
return this.createAppResult(`Found ${data?.contacts?.length || 0} contacts`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View pipeline board (Kanban)
*/
async viewPipelineBoard(pipelineId) {
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) => p.id === pipelineId);
const opportunities = opportunitiesResponse.data?.opportunities || [];
// Simplify opportunity data to only include fields the UI needs (reduces payload size)
const simplifiedOpportunities = opportunities.map((opp) => ({
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 data = {
pipeline,
opportunities: simplifiedOpportunities,
stages: pipeline?.stages || []
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/pipeline-board');
return this.createAppResult(`Pipeline: ${pipeline?.name || 'Unknown'} (${opportunities.length} opportunities)`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View quick book interface
*/
async viewQuickBook(calendarId, contactId) {
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 data = {
calendar: calendarResponse.data,
contact: contactResponse.data,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/quick-book');
return this.createAppResult(`Quick booking for calendar: ${calendarResponse.data?.name || calendarId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View opportunity card
*/
async viewOpportunityCard(opportunityId) {
const response = await this.ghlClient.getOpportunity(opportunityId);
if (!response.success) {
throw new Error(response.error?.message || 'Failed to get opportunity');
}
const opportunity = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/opportunity-card');
return this.createAppResult(`Opportunity: ${opportunity?.name || opportunityId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), opportunity);
}
/**
* View calendar
*/
async viewCalendar(calendarId, startDate, endDate) {
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: 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;
const data = {
calendar: calendarResponse.data,
events: eventsResponse.data?.events || [],
startDate: start,
endDate: end
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/calendar-view');
return this.createAppResult(`Calendar: ${calendar?.name || 'Unknown'} (${data.events.length} events)`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View campaign stats
*/
async viewCampaignStats(campaignId) {
// Get email campaigns
const response = await this.ghlClient.getEmailCampaigns({});
const campaigns = response.data?.schedules || [];
const campaign = campaigns.find((c) => c.id === campaignId) || { id: campaignId };
const data = {
campaign,
campaigns,
campaignId,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/campaign-stats');
return this.createAppResult(`Campaign stats: ${campaign?.name || campaignId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View agent stats
*/
async viewAgentStats(userId, dateRange) {
// Get location info which may include user data
const locationResponse = await this.ghlClient.getLocationById(this.ghlClient.getConfig().locationId);
const data = {
userId,
dateRange: dateRange || 'last30days',
location: locationResponse.data,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/agent-stats');
return this.createAppResult(userId ? `Agent stats: ${userId}` : 'Agent overview', resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View contact timeline
*/
async viewContactTimeline(contactId) {
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;
const data = {
contact: contactResponse.data,
notes: notesResponse.data || [],
tasks: tasksResponse.data || []
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/contact-timeline');
return this.createAppResult(`Timeline for ${contact?.firstName || ''} ${contact?.lastName || ''}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View workflow status
*/
async viewWorkflowStatus(workflowId) {
const response = await this.ghlClient.getWorkflows({
locationId: this.ghlClient.getConfig().locationId
});
const workflows = response.data?.workflows || [];
const workflow = workflows.find((w) => w.id === workflowId) || { id: workflowId };
const data = {
workflow,
workflows,
workflowId,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/workflow-status');
return this.createAppResult(`Workflow: ${workflow?.name || workflowId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View main dashboard
*/
async viewDashboard() {
const [contactsResponse, pipelinesResponse, calendarsResponse] = await Promise.all([
this.ghlClient.searchContacts({ locationId: this.ghlClient.getConfig().locationId, limit: 10 }),
this.ghlClient.getPipelines(),
this.ghlClient.getCalendars()
]);
const data = {
recentContacts: contactsResponse.data?.contacts || [],
pipelines: pipelinesResponse.data?.pipelines || [],
calendars: calendarsResponse.data?.calendars || [],
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/mcp-app');
return this.createAppResult('GHL Dashboard Overview', resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View invoice
*/
async viewInvoice(invoiceId) {
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 resourceHandler = this.resourceHandlers.get('ui://ghl/invoice-preview');
return this.createAppResult(`Invoice #${invoice?.invoiceNumber || invoiceId} - ${invoice?.status || 'Unknown status'}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), invoice);
}
/**
* Update opportunity (action tool for UI)
*/
async updateOpportunity(args) {
const { opportunityId, ...updates } = args;
// Build the update payload
const updatePayload = {};
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
}
}
};
}
/**
* Create app tool result with structuredContent
*/
createAppResult(textSummary, resourceUri, mimeType, htmlContent, data) {
// structuredContent is the data object that gets passed to ontoolresult
// The UI accesses it via result.structuredContent
return {
content: [{ type: 'text', text: textSummary }],
structuredContent: data
};
}
/**
* Inject data into HTML as a script tag
*/
injectDataIntoHTML(html, data) {
const dataScript = `<script>window.__MCP_APP_DATA__ = ${JSON.stringify(data)};</script>`;
// Insert before </head> or at the beginning of <body>
if (html.includes('</head>')) {
return html.replace('</head>', `${dataScript}</head>`);
}
else if (html.includes('<body>')) {
return html.replace('<body>', `<body>${dataScript}`);
}
else {
return dataScript + html;
}
}
/**
* Get resource handler by URI
*/
getResourceHandler(uri) {
return this.resourceHandlers.get(uri);
}
/**
* Get all registered resource URIs
*/
getResourceURIs() {
return Array.from(this.resourceHandlers.keys());
}
}