├── Procfile ├── .env.example ├── vercel.json ├── railway.json ├── tsconfig.json ├── Dockerfile ├── tests ├── basic.test.ts ├── setup.ts ├── mocks │ └── ghl-api-client.mock.ts ├── tools │ ├── contact-tools.test.ts │ ├── conversation-tools.test.ts │ └── blog-tools.test.ts └── clients │ └── ghl-api-client.test.ts ├── jest.config.js ├── .gitignore ├── package.json ├── LICENSE ├── src └── tools │ ├── workflow-tools.ts │ ├── email-isv-tools.ts │ ├── survey-tools.ts │ ├── email-tools.ts │ ├── media-tools.ts │ ├── association-tools.ts │ ├── invoices-tools.ts │ ├── custom-field-v2-tools.ts │ ├── blog-tools.ts │ └── social-media-tools.ts ├── CLOUD-DEPLOYMENT.md ├── README-GITHUB.md └── api └── index.js /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mastanley13/GoHighLevel-MCP/HEAD/.env.example -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "api/**/*.js", 6 | "use": "@vercel/node" 7 | } 8 | ], 9 | "routes": [ 10 | { "src": "/(.*)", "dest": "/api/index.js" } 11 | ] 12 | } -------------------------------------------------------------------------------- /railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://railway.app/railway.schema.json", 3 | "build": { 4 | "builder": "NIXPACKS" 5 | }, 6 | "deploy": { 7 | "startCommand": "npm start", 8 | "restartPolicyType": "ON_FAILURE", 9 | "restartPolicyMaxRetries": 10 10 | } 11 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "isolatedModules": true, 11 | "outDir": "./dist", 12 | "types": ["node", "jest"] 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist", "tests/**/*"] 16 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Node.js 18 LTS 2 | FROM node:18-alpine 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package files 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm ci --only=production 12 | 13 | # Copy source code 14 | COPY . . 15 | 16 | # Build the application 17 | RUN npm run build 18 | 19 | # Expose the port 20 | EXPOSE 8000 21 | 22 | # Set environment to production 23 | ENV NODE_ENV=production 24 | 25 | # Start the HTTP server 26 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /tests/basic.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Basic test to verify Jest setup 3 | */ 4 | 5 | // Set up test environment variables 6 | process.env.GHL_API_KEY = 'test_api_key_123'; 7 | process.env.GHL_BASE_URL = 'https://test.leadconnectorhq.com'; 8 | process.env.GHL_LOCATION_ID = 'test_location_123'; 9 | process.env.NODE_ENV = 'test'; 10 | 11 | describe('Basic Setup', () => { 12 | it('should run basic test', () => { 13 | expect(true).toBe(true); 14 | }); 15 | 16 | it('should have environment variables set', () => { 17 | expect(process.env.GHL_API_KEY).toBe('test_api_key_123'); 18 | expect(process.env.GHL_BASE_URL).toBe('https://test.leadconnectorhq.com'); 19 | expect(process.env.GHL_LOCATION_ID).toBe('test_location_123'); 20 | }); 21 | }); -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/tests'], 5 | testMatch: [ 6 | '**/tests/**/*.test.ts' 7 | ], 8 | transform: { 9 | '^.+\\.ts$': 'ts-jest' 10 | }, 11 | moduleFileExtensions: ['ts', 'js'], 12 | moduleNameMapper: { 13 | '^(\\.{1,2}/.*)\\.js$': '$1' 14 | }, 15 | collectCoverageFrom: [ 16 | 'src/**/*.ts', 17 | '!src/**/*.d.ts', 18 | '!src/server.ts' 19 | ], 20 | coverageDirectory: 'coverage', 21 | coverageReporters: ['text', 'lcov', 'html'], 22 | coverageThreshold: { 23 | global: { 24 | branches: 70, 25 | functions: 70, 26 | lines: 70, 27 | statements: 70 28 | } 29 | }, 30 | verbose: true, 31 | testTimeout: 10000 32 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Environment variables 8 | .env 9 | .env.local 10 | .env.development.local 11 | .env.test.local 12 | .env.production.local 13 | 14 | # MCP Configuration with credentials 15 | cursor-mcp-config.json 16 | 17 | # Build output 18 | dist/ 19 | build/ 20 | 21 | # IDE files 22 | .vscode/ 23 | .idea/ 24 | *.swp 25 | *.swo 26 | 27 | # OS files 28 | .DS_Store 29 | Thumbs.db 30 | 31 | # Logs 32 | logs/ 33 | *.log 34 | 35 | # Coverage reports 36 | coverage/ 37 | .nyc_output/ 38 | 39 | # Runtime data 40 | pids/ 41 | *.pid 42 | *.seed 43 | *.pid.lock 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional ESLint cache 49 | .eslintcache 50 | 51 | # Temporary folders 52 | tmp/ 53 | temp/ 54 | .vercel 55 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Jest Test Setup 3 | * Global configuration and utilities for testing 4 | */ 5 | 6 | // Mock environment variables for testing 7 | process.env.GHL_API_KEY = 'test_api_key_123'; 8 | process.env.GHL_BASE_URL = 'https://test.leadconnectorhq.com'; 9 | process.env.GHL_LOCATION_ID = 'test_location_123'; 10 | process.env.NODE_ENV = 'test'; 11 | 12 | // Extend global interface for test utilities 13 | declare global { 14 | var testConfig: { 15 | ghlApiKey: string; 16 | ghlBaseUrl: string; 17 | ghlLocationId: string; 18 | }; 19 | } 20 | 21 | // Global test utilities 22 | (global as any).testConfig = { 23 | ghlApiKey: 'test_api_key_123', 24 | ghlBaseUrl: 'https://test.leadconnectorhq.com', 25 | ghlLocationId: 'test_location_123' 26 | }; 27 | 28 | // Set up test timeout 29 | jest.setTimeout(10000); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mastanley13/ghl-mcp-server", 3 | "version": "1.0.0", 4 | "description": "GoHighLevel MCP Server for Claude Desktop and ChatGPT integration", 5 | "main": "dist/server.js", 6 | "bin": { 7 | "ghl-mcp-server": "dist/server.js" 8 | }, 9 | "files": [ 10 | "dist/", 11 | "README.md", 12 | "package.json" 13 | ], 14 | "engines": { 15 | "node": ">=18.0.0" 16 | }, 17 | "scripts": { 18 | "build": "tsc", 19 | "dev": "nodemon --exec ts-node src/http-server.ts", 20 | "start": "node dist/http-server.js", 21 | "start:stdio": "node dist/server.js", 22 | "start:http": "node dist/http-server.js", 23 | "vercel-build": "npm run build", 24 | "prepublishOnly": "npm run build", 25 | "test": "jest", 26 | "test:watch": "jest --watch", 27 | "test:coverage": "jest --coverage", 28 | "lint": "tsc --noEmit" 29 | }, 30 | "keywords": [ 31 | "mcp", 32 | "gohighlevel", 33 | "chatgpt", 34 | "api" 35 | ], 36 | "author": "", 37 | "license": "ISC", 38 | "devDependencies": { 39 | "@types/jest": "^29.5.14", 40 | "@types/node": "^22.15.29", 41 | "jest": "^29.7.0", 42 | "nodemon": "^3.1.10", 43 | "ts-jest": "^29.3.4", 44 | "ts-node": "^10.9.2", 45 | "typescript": "^5.8.3" 46 | }, 47 | "dependencies": { 48 | "@modelcontextprotocol/sdk": "^1.12.1", 49 | "@types/cors": "^2.8.18", 50 | "@types/express": "^5.0.2", 51 | "axios": "^1.9.0", 52 | "cors": "^2.8.5", 53 | "dotenv": "^16.5.0", 54 | "express": "^5.1.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GoHighLevel MCP Server Community License 2 | 3 | Copyright (c) 2024 StrategixAI LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to use, 7 | copy, modify, and distribute the Software for personal, educational, and 8 | non-commercial purposes, subject to the following conditions: 9 | 10 | ALLOWED USES: 11 | ✅ Personal use and integration with your own GoHighLevel accounts 12 | ✅ Educational and learning purposes 13 | ✅ Modification and customization for your own needs 14 | ✅ Contributing back to the community project 15 | ✅ Sharing and distributing the original or modified software for free 16 | 17 | PROHIBITED USES: 18 | ❌ Commercial resale or licensing of this software 19 | ❌ Creating paid products or services based primarily on this software 20 | ❌ Removing or modifying this license or copyright notices 21 | 22 | COMMUNITY SPIRIT: 23 | This project was created to help the GoHighLevel community build better 24 | AI integrations together. If you build something amazing with it, consider 25 | contributing back to help others! 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE. 34 | 35 | --- 36 | 37 | Questions about commercial use? Contact: mykelandrewstanley@gmail.com 38 | Want to contribute? Pull requests welcome! 39 | Found this helpful? Consider supporting the project: https://buy.stripe.com/28E14o1hT7JAfstfvqdZ60y -------------------------------------------------------------------------------- /src/tools/workflow-tools.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 3 | import { 4 | MCPGetWorkflowsParams 5 | } from '../types/ghl-types.js'; 6 | 7 | export class WorkflowTools { 8 | constructor(private apiClient: GHLApiClient) {} 9 | 10 | getTools(): Tool[] { 11 | return [ 12 | { 13 | name: 'ghl_get_workflows', 14 | description: 'Retrieve all workflows for a location. Workflows represent automation sequences that can be triggered by various events in the system.', 15 | inputSchema: { 16 | type: 'object', 17 | properties: { 18 | locationId: { 19 | type: 'string', 20 | description: 'The location ID to get workflows for. If not provided, uses the default location from configuration.' 21 | } 22 | }, 23 | additionalProperties: false 24 | } 25 | } 26 | ]; 27 | } 28 | 29 | async executeWorkflowTool(name: string, params: any): Promise { 30 | try { 31 | switch (name) { 32 | case 'ghl_get_workflows': 33 | return await this.getWorkflows(params as MCPGetWorkflowsParams); 34 | 35 | default: 36 | throw new Error(`Unknown workflow tool: ${name}`); 37 | } 38 | } catch (error) { 39 | console.error(`Error executing workflow tool ${name}:`, error); 40 | throw error; 41 | } 42 | } 43 | 44 | // ===== WORKFLOW MANAGEMENT TOOLS ===== 45 | 46 | /** 47 | * Get all workflows for a location 48 | */ 49 | private async getWorkflows(params: MCPGetWorkflowsParams): Promise { 50 | try { 51 | const result = await this.apiClient.getWorkflows({ 52 | locationId: params.locationId || '' 53 | }); 54 | 55 | if (!result.success || !result.data) { 56 | throw new Error(`Failed to get workflows: ${result.error?.message || 'Unknown error'}`); 57 | } 58 | 59 | return { 60 | success: true, 61 | workflows: result.data.workflows, 62 | message: `Successfully retrieved ${result.data.workflows.length} workflows`, 63 | metadata: { 64 | totalWorkflows: result.data.workflows.length, 65 | workflowStatuses: result.data.workflows.reduce((acc: { [key: string]: number }, workflow) => { 66 | acc[workflow.status] = (acc[workflow.status] || 0) + 1; 67 | return acc; 68 | }, {}) 69 | } 70 | }; 71 | } catch (error) { 72 | console.error('Error getting workflows:', error); 73 | throw new Error(`Failed to get workflows: ${error instanceof Error ? error.message : 'Unknown error'}`); 74 | } 75 | } 76 | } 77 | 78 | // Helper function to check if a tool name belongs to workflow tools 79 | export function isWorkflowTool(toolName: string): boolean { 80 | const workflowToolNames = [ 81 | 'ghl_get_workflows' 82 | ]; 83 | 84 | return workflowToolNames.includes(toolName); 85 | } -------------------------------------------------------------------------------- /src/tools/email-isv-tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * GoHighLevel Email ISV (Verification) Tools 3 | * Implements email verification functionality for the MCP server 4 | */ 5 | 6 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 7 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 8 | import { 9 | MCPVerifyEmailParams, 10 | GHLEmailVerificationResponse 11 | } from '../types/ghl-types.js'; 12 | 13 | /** 14 | * Email ISV Tools class 15 | * Provides email verification capabilities 16 | */ 17 | export class EmailISVTools { 18 | constructor(private ghlClient: GHLApiClient) {} 19 | 20 | /** 21 | * Get tool definitions for all Email ISV operations 22 | */ 23 | getToolDefinitions(): Tool[] { 24 | return [ 25 | { 26 | name: 'verify_email', 27 | description: 'Verify email address deliverability and get risk assessment. Charges will be deducted from the specified location wallet.', 28 | inputSchema: { 29 | type: 'object', 30 | properties: { 31 | locationId: { 32 | type: 'string', 33 | description: 'Location ID - charges will be deducted from this location wallet' 34 | }, 35 | type: { 36 | type: 'string', 37 | enum: ['email', 'contact'], 38 | description: 'Verification type: "email" for direct email verification, "contact" for contact ID verification' 39 | }, 40 | verify: { 41 | type: 'string', 42 | description: 'Email address to verify (if type=email) or contact ID (if type=contact)' 43 | } 44 | }, 45 | required: ['locationId', 'type', 'verify'] 46 | } 47 | } 48 | ]; 49 | } 50 | 51 | /** 52 | * Execute email ISV tools 53 | */ 54 | async executeTool(name: string, args: any): Promise { 55 | switch (name) { 56 | case 'verify_email': 57 | return await this.verifyEmail(args as MCPVerifyEmailParams); 58 | 59 | default: 60 | throw new Error(`Unknown email ISV tool: ${name}`); 61 | } 62 | } 63 | 64 | /** 65 | * Verify email address or contact 66 | */ 67 | private async verifyEmail(params: MCPVerifyEmailParams): Promise<{ 68 | success: boolean; 69 | verification: GHLEmailVerificationResponse; 70 | message: string; 71 | }> { 72 | try { 73 | const result = await this.ghlClient.verifyEmail(params.locationId, { 74 | type: params.type, 75 | verify: params.verify 76 | }); 77 | 78 | if (!result.success || !result.data) { 79 | return { 80 | success: false, 81 | verification: { verified: false, message: 'Verification failed', address: params.verify } as any, 82 | message: result.error?.message || 'Email verification failed' 83 | }; 84 | } 85 | 86 | const verification = result.data; 87 | 88 | // Determine if this is a successful verification response 89 | const isVerified = 'result' in verification; 90 | let message: string; 91 | 92 | if (isVerified) { 93 | const verifiedResult = verification as any; 94 | message = `Email verification completed. Result: ${verifiedResult.result}, Risk: ${verifiedResult.risk}`; 95 | 96 | if (verifiedResult.reason && verifiedResult.reason.length > 0) { 97 | message += `, Reasons: ${verifiedResult.reason.join(', ')}`; 98 | } 99 | 100 | if (verifiedResult.leadconnectorRecomendation?.isEmailValid !== undefined) { 101 | message += `, Recommended: ${verifiedResult.leadconnectorRecomendation.isEmailValid ? 'Valid' : 'Invalid'}`; 102 | } 103 | } else { 104 | const notVerifiedResult = verification as any; 105 | message = `Email verification not processed: ${notVerifiedResult.message}`; 106 | } 107 | 108 | return { 109 | success: true, 110 | verification, 111 | message 112 | }; 113 | } catch (error) { 114 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; 115 | return { 116 | success: false, 117 | verification: { verified: false, message: errorMessage, address: params.verify } as any, 118 | message: `Failed to verify email: ${errorMessage}` 119 | }; 120 | } 121 | } 122 | } -------------------------------------------------------------------------------- /CLOUD-DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # 🚀 Cloud Deployment Guide - ChatGPT Integration 2 | 3 | ## 🎯 Overview 4 | 5 | To connect your GoHighLevel MCP Server to ChatGPT, you need to deploy it to a **publicly accessible URL**. Here are the best options: 6 | 7 | --- 8 | 9 | ## 🌟 **Option 1: Railway (Recommended - Free Tier)** 10 | 11 | ### **Why Railway?** 12 | - ✅ Free tier available 13 | - ✅ Automatic HTTPS 14 | - ✅ Easy GitHub integration 15 | - ✅ Fast deployment 16 | 17 | ### **Deployment Steps:** 18 | 19 | 1. **Sign up at [Railway.app](https://railway.app)** 20 | 21 | 2. **Create New Project from GitHub:** 22 | - Connect your GitHub account 23 | - Import this repository 24 | - Railway will auto-detect the Node.js app 25 | 26 | 3. **Set Environment Variables:** 27 | ``` 28 | GHL_API_KEY=your_api_key_here 29 | GHL_BASE_URL=https://services.leadconnectorhq.com 30 | GHL_LOCATION_ID=your_location_id_here 31 | NODE_ENV=production 32 | PORT=8000 33 | ``` 34 | 35 | 4. **Deploy:** 36 | - Railway will automatically build and deploy 37 | - You'll get a URL like: `https://your-app-name.railway.app` 38 | 39 | 5. **For ChatGPT Integration:** 40 | ``` 41 | MCP Server URL: https://your-app-name.railway.app/sse 42 | ``` 43 | 44 | --- 45 | 46 | ## 🌟 **Option 2: Render (Free Tier)** 47 | 48 | ### **Deployment Steps:** 49 | 50 | 1. **Sign up at [Render.com](https://render.com)** 51 | 52 | 2. **Create Web Service:** 53 | - Connect GitHub repository 54 | - Select "Web Service" 55 | - Runtime: Node 56 | 57 | 3. **Configuration:** 58 | ``` 59 | Build Command: npm run build 60 | Start Command: npm start 61 | ``` 62 | 63 | 4. **Environment Variables:** (Same as above) 64 | 65 | 5. **For ChatGPT:** 66 | ``` 67 | MCP Server URL: https://your-app-name.onrender.com/sse 68 | ``` 69 | 70 | --- 71 | 72 | ## 🌟 **Option 3: Vercel (Free Tier)** 73 | 74 | ### **Deploy with One Click:** 75 | 76 | 1. **Click Deploy Button:** [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/your-username/ghl-mcp-server) 77 | 78 | 2. **Add Environment Variables** during setup 79 | 80 | 3. **For ChatGPT:** 81 | ``` 82 | MCP Server URL: https://your-app-name.vercel.app/sse 83 | ``` 84 | 85 | --- 86 | 87 | ## 🌟 **Option 4: Heroku (Paid)** 88 | 89 | ### **Deployment Steps:** 90 | 91 | 1. **Install Heroku CLI** 92 | 93 | 2. **Deploy Commands:** 94 | ```bash 95 | heroku create your-app-name 96 | heroku config:set GHL_API_KEY=your_key_here 97 | heroku config:set GHL_BASE_URL=https://services.leadconnectorhq.com 98 | heroku config:set GHL_LOCATION_ID=your_location_id_here 99 | heroku config:set NODE_ENV=production 100 | git push heroku main 101 | ``` 102 | 103 | 3. **For ChatGPT:** 104 | ``` 105 | MCP Server URL: https://your-app-name.herokuapp.com/sse 106 | ``` 107 | 108 | --- 109 | 110 | ## 🎯 **Quick Test Your Deployment** 111 | 112 | Once deployed, test these endpoints: 113 | 114 | ### **Health Check:** 115 | ``` 116 | GET https://your-domain.com/health 117 | ``` 118 | Should return: 119 | ```json 120 | { 121 | "status": "healthy", 122 | "server": "ghl-mcp-server", 123 | "tools": { "total": 21 } 124 | } 125 | ``` 126 | 127 | ### **Tools List:** 128 | ``` 129 | GET https://your-domain.com/tools 130 | ``` 131 | Should return all 21 MCP tools. 132 | 133 | ### **SSE Endpoint (for ChatGPT):** 134 | ``` 135 | GET https://your-domain.com/sse 136 | ``` 137 | Should establish Server-Sent Events connection. 138 | 139 | --- 140 | 141 | ## 🔗 **Connect to ChatGPT** 142 | 143 | ### **Once your server is deployed:** 144 | 145 | 1. **Open ChatGPT Desktop App** 146 | 2. **Go to:** Settings → Beta Features → Model Context Protocol 147 | 3. **Add New Connector:** 148 | - **Name:** `GoHighLevel MCP` 149 | - **Description:** `Connect to GoHighLevel CRM` 150 | - **MCP Server URL:** `https://your-domain.com/sse` 151 | - **Authentication:** `OAuth` (or None if no auth needed) 152 | 153 | 4. **Save and Connect** 154 | 155 | ### **Test the Connection:** 156 | Try asking ChatGPT: 157 | ``` 158 | "List all available GoHighLevel tools" 159 | "Create a contact named Test User with email test@example.com" 160 | "Show me recent conversations in GoHighLevel" 161 | ``` 162 | 163 | --- 164 | 165 | ## 🚨 **Troubleshooting** 166 | 167 | ### **Common Issues:** 168 | 169 | 1. **502 Bad Gateway:** 170 | - Check environment variables are set 171 | - Verify GHL API key is valid 172 | - Check server logs for errors 173 | 174 | 2. **CORS Errors:** 175 | - Server includes CORS headers for ChatGPT 176 | - Ensure your domain is accessible 177 | 178 | 3. **Connection Timeout:** 179 | - Free tier platforms may have cold starts 180 | - First request might be slow 181 | 182 | 4. **SSE Connection Issues:** 183 | - Verify `/sse` endpoint is accessible 184 | - Check browser network tab for errors 185 | 186 | ### **Debug Commands:** 187 | ```bash 188 | # Check server status 189 | curl https://your-domain.com/health 190 | 191 | # Test tools endpoint 192 | curl https://your-domain.com/tools 193 | 194 | # Check SSE connection 195 | curl -H "Accept: text/event-stream" https://your-domain.com/sse 196 | ``` 197 | 198 | --- 199 | 200 | ## 🎉 **Success Indicators** 201 | 202 | ### **✅ Deployment Successful When:** 203 | - Health check returns `status: "healthy"` 204 | - Tools endpoint shows 21 tools 205 | - SSE endpoint establishes connection 206 | - ChatGPT can discover and use tools 207 | 208 | ### **🎯 Ready for Production:** 209 | - All environment variables configured 210 | - HTTPS enabled (automatic on most platforms) 211 | - Server responding to all endpoints 212 | - ChatGPT integration working 213 | 214 | --- 215 | 216 | ## 🔐 **Security Notes** 217 | 218 | - ✅ All platforms provide HTTPS automatically 219 | - ✅ Environment variables are encrypted 220 | - ✅ No sensitive data in code repository 221 | - ✅ CORS configured for ChatGPT domains only 222 | 223 | --- 224 | 225 | ## 💰 **Cost Comparison** 226 | 227 | | Platform | Free Tier | Paid Plans | HTTPS | Custom Domain | 228 | |----------|-----------|------------|-------|---------------| 229 | | **Railway** | 512MB RAM, $5 credit | $5/month | ✅ | ✅ | 230 | | **Render** | 512MB RAM | $7/month | ✅ | ✅ | 231 | | **Vercel** | Unlimited | $20/month | ✅ | ✅ | 232 | | **Heroku** | None | $7/month | ✅ | ✅ | 233 | 234 | **Recommendation:** Start with Railway's free tier! 235 | 236 | --- 237 | 238 | ## 🚀 **Next Steps** 239 | 240 | 1. **Choose a platform** (Railway recommended) 241 | 2. **Deploy your server** following the guide above 242 | 3. **Test the endpoints** to verify everything works 243 | 4. **Connect to ChatGPT** using your new server URL 244 | 5. **Start managing GoHighLevel through ChatGPT!** 245 | 246 | Your GoHighLevel MCP Server will be accessible at: 247 | ``` 248 | https://your-domain.com/sse 249 | ``` 250 | 251 | **Ready to transform ChatGPT into your GoHighLevel control center!** 🎯 -------------------------------------------------------------------------------- /src/tools/survey-tools.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 3 | import { 4 | MCPGetSurveysParams, 5 | MCPGetSurveySubmissionsParams 6 | } from '../types/ghl-types.js'; 7 | 8 | export class SurveyTools { 9 | constructor(private apiClient: GHLApiClient) {} 10 | 11 | getTools(): Tool[] { 12 | return [ 13 | { 14 | name: 'ghl_get_surveys', 15 | description: 'Retrieve all surveys for a location. Surveys are used to collect information from contacts through forms and questionnaires.', 16 | inputSchema: { 17 | type: 'object', 18 | properties: { 19 | locationId: { 20 | type: 'string', 21 | description: 'The location ID to get surveys for. If not provided, uses the default location from configuration.' 22 | }, 23 | skip: { 24 | type: 'number', 25 | description: 'Number of records to skip for pagination (default: 0)' 26 | }, 27 | limit: { 28 | type: 'number', 29 | description: 'Maximum number of surveys to return (max: 50, default: 10)' 30 | }, 31 | type: { 32 | type: 'string', 33 | description: 'Filter surveys by type (e.g., "folder")' 34 | } 35 | }, 36 | additionalProperties: false 37 | } 38 | }, 39 | { 40 | name: 'ghl_get_survey_submissions', 41 | description: 'Retrieve survey submissions with advanced filtering and pagination. Get responses from contacts who have completed surveys.', 42 | inputSchema: { 43 | type: 'object', 44 | properties: { 45 | locationId: { 46 | type: 'string', 47 | description: 'The location ID to get submissions for. If not provided, uses the default location from configuration.' 48 | }, 49 | page: { 50 | type: 'number', 51 | description: 'Page number for pagination (default: 1)' 52 | }, 53 | limit: { 54 | type: 'number', 55 | description: 'Number of submissions per page (max: 100, default: 20)' 56 | }, 57 | surveyId: { 58 | type: 'string', 59 | description: 'Filter submissions by specific survey ID' 60 | }, 61 | q: { 62 | type: 'string', 63 | description: 'Search by contact ID, name, email, or phone number' 64 | }, 65 | startAt: { 66 | type: 'string', 67 | description: 'Start date for filtering submissions (YYYY-MM-DD format)' 68 | }, 69 | endAt: { 70 | type: 'string', 71 | description: 'End date for filtering submissions (YYYY-MM-DD format)' 72 | } 73 | }, 74 | additionalProperties: false 75 | } 76 | } 77 | ]; 78 | } 79 | 80 | async executeSurveyTool(name: string, params: any): Promise { 81 | try { 82 | switch (name) { 83 | case 'ghl_get_surveys': 84 | return await this.getSurveys(params as MCPGetSurveysParams); 85 | 86 | case 'ghl_get_survey_submissions': 87 | return await this.getSurveySubmissions(params as MCPGetSurveySubmissionsParams); 88 | 89 | default: 90 | throw new Error(`Unknown survey tool: ${name}`); 91 | } 92 | } catch (error) { 93 | console.error(`Error executing survey tool ${name}:`, error); 94 | throw error; 95 | } 96 | } 97 | 98 | // ===== SURVEY MANAGEMENT TOOLS ===== 99 | 100 | /** 101 | * Get all surveys for a location 102 | */ 103 | private async getSurveys(params: MCPGetSurveysParams): Promise { 104 | try { 105 | const result = await this.apiClient.getSurveys({ 106 | locationId: params.locationId || '', 107 | skip: params.skip, 108 | limit: params.limit, 109 | type: params.type 110 | }); 111 | 112 | if (!result.success || !result.data) { 113 | throw new Error(`Failed to get surveys: ${result.error?.message || 'Unknown error'}`); 114 | } 115 | 116 | return { 117 | success: true, 118 | surveys: result.data.surveys, 119 | total: result.data.total, 120 | message: `Successfully retrieved ${result.data.surveys.length} surveys`, 121 | metadata: { 122 | totalSurveys: result.data.total, 123 | returnedCount: result.data.surveys.length, 124 | pagination: { 125 | skip: params.skip || 0, 126 | limit: params.limit || 10 127 | }, 128 | ...(params.type && { filterType: params.type }) 129 | } 130 | }; 131 | } catch (error) { 132 | console.error('Error getting surveys:', error); 133 | throw new Error(`Failed to get surveys: ${error instanceof Error ? error.message : 'Unknown error'}`); 134 | } 135 | } 136 | 137 | /** 138 | * Get survey submissions with filtering 139 | */ 140 | private async getSurveySubmissions(params: MCPGetSurveySubmissionsParams): Promise { 141 | try { 142 | const result = await this.apiClient.getSurveySubmissions({ 143 | locationId: params.locationId || '', 144 | page: params.page, 145 | limit: params.limit, 146 | surveyId: params.surveyId, 147 | q: params.q, 148 | startAt: params.startAt, 149 | endAt: params.endAt 150 | }); 151 | 152 | if (!result.success || !result.data) { 153 | throw new Error(`Failed to get survey submissions: ${result.error?.message || 'Unknown error'}`); 154 | } 155 | 156 | return { 157 | success: true, 158 | submissions: result.data.submissions, 159 | meta: result.data.meta, 160 | message: `Successfully retrieved ${result.data.submissions.length} survey submissions`, 161 | metadata: { 162 | totalSubmissions: result.data.meta.total, 163 | returnedCount: result.data.submissions.length, 164 | pagination: { 165 | currentPage: result.data.meta.currentPage, 166 | nextPage: result.data.meta.nextPage, 167 | prevPage: result.data.meta.prevPage, 168 | limit: params.limit || 20 169 | }, 170 | filters: { 171 | ...(params.surveyId && { surveyId: params.surveyId }), 172 | ...(params.q && { search: params.q }), 173 | ...(params.startAt && { startDate: params.startAt }), 174 | ...(params.endAt && { endDate: params.endAt }) 175 | } 176 | } 177 | }; 178 | } catch (error) { 179 | console.error('Error getting survey submissions:', error); 180 | throw new Error(`Failed to get survey submissions: ${error instanceof Error ? error.message : 'Unknown error'}`); 181 | } 182 | } 183 | } 184 | 185 | // Helper function to check if a tool name belongs to survey tools 186 | export function isSurveyTool(toolName: string): boolean { 187 | const surveyToolNames = [ 188 | 'ghl_get_surveys', 189 | 'ghl_get_survey_submissions' 190 | ]; 191 | 192 | return surveyToolNames.includes(toolName); 193 | } -------------------------------------------------------------------------------- /README-GITHUB.md: -------------------------------------------------------------------------------- 1 | # 🚀 GoHighLevel MCP Server 2 | 3 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mastanley13/GoHighLevel-MCP) 4 | [![Donate to the Project](https://img.shields.io/badge/Donate_to_the_Project-💝_Support_Development-ff69b4?style=for-the-badge&logo=stripe&logoColor=white)](https://buy.stripe.com/28E14o1hT7JAfstfvqdZ60y) 5 | 6 | > **Transform ChatGPT into a GoHighLevel CRM powerhouse with 21 powerful tools** 7 | 8 | ## 🎯 What This Does 9 | 10 | This MCP (Model Context Protocol) server connects ChatGPT directly to your GoHighLevel account, enabling you to: 11 | 12 | - **👥 Manage Contacts**: Create, search, update, and organize contacts 13 | - **💬 Handle Communications**: Send SMS and emails, manage conversations 14 | - **📝 Create Content**: Manage blog posts, authors, and categories 15 | - **🔄 Automate Workflows**: Combine multiple actions through ChatGPT 16 | 17 | ## 🔑 **CRITICAL: GoHighLevel API Setup** 18 | 19 | ### **📋 Required: Private Integrations API Key** 20 | 21 | > **⚠️ This project requires a PRIVATE INTEGRATIONS API key, not a regular API key!** 22 | 23 | **Quick Setup:** 24 | 1. **GoHighLevel Settings** → **Integrations** → **Private Integrations** 25 | 2. **Create New Integration** with required scopes (contacts, conversations, etc.) 26 | 3. **Copy the Private API Key** and your **Location ID** 27 | 28 | ## ⚡ Quick Deploy to Vercel 29 | 30 | ### 1. One-Click Deploy 31 | Click the button above or: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/mastanley13/GoHighLevel-MCP) 32 | 33 | ### 2. Add Environment Variables 34 | ``` 35 | GHL_API_KEY=your_private_integrations_api_key_here 36 | GHL_BASE_URL=https://services.leadconnectorhq.com 37 | GHL_LOCATION_ID=your_location_id_here 38 | NODE_ENV=production 39 | ``` 40 | 41 | ### 3. Connect to ChatGPT 42 | Use your deployed URL in ChatGPT: 43 | ``` 44 | https://your-app-name.vercel.app/sse 45 | ``` 46 | 47 | ## 🛠️ Available Tools (21 Total) 48 | 49 | ### 🎯 Contact Management (7 Tools) 50 | - `create_contact` - Create new contacts 51 | - `search_contacts` - Find contacts by criteria 52 | - `get_contact` - Retrieve contact details 53 | - `update_contact` - Modify contact information 54 | - `add_contact_tags` - Organize with tags 55 | - `remove_contact_tags` - Remove tags 56 | - `delete_contact` - Delete contacts 57 | 58 | ### 💬 Messaging & Conversations (7 Tools) 59 | - `send_sms` - Send SMS messages 60 | - `send_email` - Send emails with HTML support 61 | - `search_conversations` - Find conversations 62 | - `get_conversation` - Get conversation details 63 | - `create_conversation` - Start new conversations 64 | - `update_conversation` - Modify conversations 65 | - `get_recent_messages` - Monitor recent activity 66 | 67 | ### 📝 Blog Management (7 Tools) 68 | - `create_blog_post` - Create blog posts with SEO 69 | - `update_blog_post` - Edit existing posts 70 | - `get_blog_posts` - List and search posts 71 | - `get_blog_sites` - Manage blog sites 72 | - `get_blog_authors` - Handle authors 73 | - `get_blog_categories` - Organize categories 74 | - `check_url_slug` - Validate URL slugs 75 | 76 | ## 🎮 ChatGPT Usage Examples 77 | 78 | ### Contact Management 79 | ``` 80 | "Create a contact for John Smith with email john@company.com and add tags 'lead' and 'hot-prospect'" 81 | ``` 82 | 83 | ### Communication 84 | ``` 85 | "Send an SMS to contact ID abc123 saying 'Thanks for your interest! We'll call you within 24 hours.'" 86 | ``` 87 | 88 | ### Blog Content 89 | ``` 90 | "Create a blog post titled 'Insurance Tips for 2024' with SEO-optimized content about life insurance benefits" 91 | ``` 92 | 93 | ### Advanced Workflows 94 | ``` 95 | "Search for contacts tagged 'VIP', get their recent conversations, and send them a personalized email about our premium services" 96 | ``` 97 | 98 | ## 🔧 Local Development 99 | 100 | ### Prerequisites 101 | - Node.js 18+ 102 | - GoHighLevel API access 103 | - Valid API key and Location ID 104 | 105 | ### Setup 106 | ```bash 107 | # Clone repository 108 | git clone https://github.com/mastanley13/GoHighLevel-MCP.git 109 | cd GoHighLevel-MCP 110 | 111 | # Install dependencies 112 | npm install 113 | 114 | # Create .env file 115 | cp .env.example .env 116 | # Add your GHL API credentials 117 | 118 | # Build and start 119 | npm run build 120 | npm start 121 | ``` 122 | 123 | ### Testing 124 | ```bash 125 | # Test health endpoint 126 | curl http://localhost:8000/health 127 | 128 | # Test tools endpoint 129 | curl http://localhost:8000/tools 130 | 131 | # Test SSE endpoint 132 | curl -H "Accept: text/event-stream" http://localhost:8000/sse 133 | ``` 134 | 135 | ## 🌐 Deployment Options 136 | 137 | ### Vercel (Recommended) 138 | - ✅ Free tier available 139 | - ✅ Automatic HTTPS 140 | - ✅ Global CDN 141 | - ✅ Easy GitHub integration 142 | 143 | ### Railway 144 | - ✅ Free $5 credit 145 | - ✅ Simple deployment 146 | - ✅ Automatic scaling 147 | 148 | ### Render 149 | - ✅ Free tier 150 | - ✅ Easy setup 151 | - ✅ Reliable hosting 152 | 153 | ## 📋 Project Structure 154 | 155 | ``` 156 | GoHighLevel-MCP/ 157 | ├── src/ 158 | │ ├── clients/ # GHL API client 159 | │ ├── tools/ # MCP tool implementations 160 | │ ├── types/ # TypeScript interfaces 161 | │ ├── server.ts # CLI MCP server 162 | │ └── http-server.ts # HTTP MCP server 163 | ├── tests/ # Comprehensive test suite 164 | ├── docs/ # Documentation 165 | ├── vercel.json # Vercel configuration 166 | ├── Dockerfile # Docker support 167 | └── README.md # This file 168 | ``` 169 | 170 | ## 🔐 Security & Environment 171 | 172 | ### Required Environment Variables 173 | ```bash 174 | GHL_API_KEY=your_private_integrations_api_key # Private Integrations API key (NOT regular API key) 175 | GHL_BASE_URL=https://services.leadconnectorhq.com 176 | GHL_LOCATION_ID=your_location_id # From Settings → Company → Locations 177 | NODE_ENV=production # Environment mode 178 | ``` 179 | 180 | ### Security Features 181 | - ✅ Environment-based configuration 182 | - ✅ Input validation and sanitization 183 | - ✅ Comprehensive error handling 184 | - ✅ CORS protection for web deployment 185 | - ✅ No sensitive data in code 186 | 187 | ## 🚨 Troubleshooting 188 | 189 | ### Common Issues 190 | 191 | **Build Failures:** 192 | ```bash 193 | npm run build # Check TypeScript compilation 194 | npm install # Ensure dependencies installed 195 | ``` 196 | 197 | **API Connection Issues:** 198 | - Verify Private Integrations API key is valid (not regular API key) 199 | - Check location ID is correct 200 | - Ensure required scopes are enabled in Private Integration 201 | - Ensure environment variables are set 202 | 203 | **ChatGPT Integration:** 204 | - Confirm SSE endpoint is accessible 205 | - Check CORS configuration 206 | - Verify MCP protocol compatibility 207 | 208 | ## 📊 Technical Stack 209 | 210 | - **Runtime**: Node.js 18+ with TypeScript 211 | - **Framework**: Express.js for HTTP server 212 | - **MCP SDK**: @modelcontextprotocol/sdk 213 | - **API Client**: Axios with interceptors 214 | - **Testing**: Jest with comprehensive coverage 215 | - **Deployment**: Vercel, Railway, Render, Docker 216 | 217 | ## 🤝 Contributing 218 | 219 | 1. Fork the repository 220 | 2. Create feature branch (`git checkout -b feature/amazing-feature`) 221 | 3. Commit changes (`git commit -m 'Add amazing feature'`) 222 | 4. Push to branch (`git push origin feature/amazing-feature`) 223 | 5. Open Pull Request 224 | 225 | ## 📝 License 226 | 227 | This project is licensed under the ISC License - see the [LICENSE](LICENSE) file for details. 228 | 229 | ## 🆘 Support 230 | 231 | - **Documentation**: Check the `/docs` folder 232 | - **Issues**: Open a GitHub issue 233 | - **API Docs**: GoHighLevel API documentation 234 | - **MCP Protocol**: Model Context Protocol specification 235 | 236 | ## 🎉 Success Story 237 | 238 | This server successfully connects ChatGPT to GoHighLevel with: 239 | - ✅ **21 operational tools** 240 | - ✅ **Real-time API integration** 241 | - ✅ **Production-ready deployment** 242 | - ✅ **Comprehensive error handling** 243 | - ✅ **Full TypeScript support** 244 | 245 | **Ready to automate your GoHighLevel workflows through ChatGPT!** 🚀 246 | 247 | --- 248 | 249 | Made with ❤️ for the GoHighLevel community -------------------------------------------------------------------------------- /src/tools/email-tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MCP Email Tools for GoHighLevel Integration 3 | * Exposes email campaign and template management capabilities to the MCP server 4 | */ 5 | 6 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 7 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 8 | import { 9 | MCPGetEmailCampaignsParams, 10 | MCPCreateEmailTemplateParams, 11 | MCPGetEmailTemplatesParams, 12 | MCPUpdateEmailTemplateParams, 13 | MCPDeleteEmailTemplateParams, 14 | GHLEmailCampaign, 15 | GHLEmailTemplate 16 | } from '../types/ghl-types.js'; 17 | 18 | /** 19 | * Email Tools Class 20 | * Implements MCP tools for email campaigns and templates 21 | */ 22 | export class EmailTools { 23 | constructor(private ghlClient: GHLApiClient) {} 24 | 25 | /** 26 | * Get all email tool definitions for MCP server 27 | */ 28 | getToolDefinitions(): Tool[] { 29 | return [ 30 | { 31 | name: 'get_email_campaigns', 32 | description: 'Get a list of email campaigns from GoHighLevel.', 33 | inputSchema: { 34 | type: 'object', 35 | properties: { 36 | status: { 37 | type: 'string', 38 | description: 'Filter campaigns by status.', 39 | enum: ['active', 'pause', 'complete', 'cancelled', 'retry', 'draft', 'resend-scheduled'], 40 | default: 'active' 41 | }, 42 | limit: { 43 | type: 'number', 44 | description: 'Maximum number of campaigns to return.', 45 | default: 10 46 | }, 47 | offset: { 48 | type: 'number', 49 | description: 'Number of campaigns to skip for pagination.', 50 | default: 0 51 | } 52 | } 53 | } 54 | }, 55 | { 56 | name: 'create_email_template', 57 | description: 'Create a new email template in GoHighLevel.', 58 | inputSchema: { 59 | type: 'object', 60 | properties: { 61 | title: { 62 | type: 'string', 63 | description: 'Title of the new template.' 64 | }, 65 | html: { 66 | type: 'string', 67 | description: 'HTML content of the template.' 68 | }, 69 | isPlainText: { 70 | type: 'boolean', 71 | description: 'Whether the template is plain text.', 72 | default: false 73 | } 74 | }, 75 | required: ['title', 'html'] 76 | } 77 | }, 78 | { 79 | name: 'get_email_templates', 80 | description: 'Get a list of email templates from GoHighLevel.', 81 | inputSchema: { 82 | type: 'object', 83 | properties: { 84 | limit: { 85 | type: 'number', 86 | description: 'Maximum number of templates to return.', 87 | default: 10 88 | }, 89 | offset: { 90 | type: 'number', 91 | description: 'Number of templates to skip for pagination.', 92 | default: 0 93 | } 94 | } 95 | } 96 | }, 97 | { 98 | name: 'update_email_template', 99 | description: 'Update an existing email template in GoHighLevel.', 100 | inputSchema: { 101 | type: 'object', 102 | properties: { 103 | templateId: { 104 | type: 'string', 105 | description: 'The ID of the template to update.' 106 | }, 107 | html: { 108 | type: 'string', 109 | description: 'The updated HTML content of the template.' 110 | }, 111 | previewText: { 112 | type: 'string', 113 | description: 'The updated preview text for the template.' 114 | } 115 | }, 116 | required: ['templateId', 'html'] 117 | } 118 | }, 119 | { 120 | name: 'delete_email_template', 121 | description: 'Delete an email template from GoHighLevel.', 122 | inputSchema: { 123 | type: 'object', 124 | properties: { 125 | templateId: { 126 | type: 'string', 127 | description: 'The ID of the template to delete.' 128 | } 129 | }, 130 | required: ['templateId'] 131 | } 132 | } 133 | ]; 134 | } 135 | 136 | /** 137 | * Execute email tool based on tool name and arguments 138 | */ 139 | async executeTool(name: string, args: any): Promise { 140 | switch (name) { 141 | case 'get_email_campaigns': 142 | return this.getEmailCampaigns(args as MCPGetEmailCampaignsParams); 143 | case 'create_email_template': 144 | return this.createEmailTemplate(args as MCPCreateEmailTemplateParams); 145 | case 'get_email_templates': 146 | return this.getEmailTemplates(args as MCPGetEmailTemplatesParams); 147 | case 'update_email_template': 148 | return this.updateEmailTemplate(args as MCPUpdateEmailTemplateParams); 149 | case 'delete_email_template': 150 | return this.deleteEmailTemplate(args as MCPDeleteEmailTemplateParams); 151 | default: 152 | throw new Error(`Unknown email tool: ${name}`); 153 | } 154 | } 155 | 156 | private async getEmailCampaigns(params: MCPGetEmailCampaignsParams): Promise<{ success: boolean; campaigns: GHLEmailCampaign[]; total: number; message: string }> { 157 | try { 158 | const response = await this.ghlClient.getEmailCampaigns(params); 159 | if (!response.success || !response.data) { 160 | throw new Error(response.error?.message || 'Failed to get email campaigns.'); 161 | } 162 | return { 163 | success: true, 164 | campaigns: response.data.schedules, 165 | total: response.data.total, 166 | message: `Successfully retrieved ${response.data.schedules.length} email campaigns.` 167 | }; 168 | } catch (error) { 169 | throw new Error(`Failed to get email campaigns: ${error}`); 170 | } 171 | } 172 | 173 | private async createEmailTemplate(params: MCPCreateEmailTemplateParams): Promise<{ success: boolean; template: any; message: string }> { 174 | try { 175 | const response = await this.ghlClient.createEmailTemplate(params); 176 | if (!response.success || !response.data) { 177 | throw new Error(response.error?.message || 'Failed to create email template.'); 178 | } 179 | return { 180 | success: true, 181 | template: response.data, 182 | message: `Successfully created email template.` 183 | }; 184 | } catch (error) { 185 | throw new Error(`Failed to create email template: ${error}`); 186 | } 187 | } 188 | 189 | private async getEmailTemplates(params: MCPGetEmailTemplatesParams): Promise<{ success: boolean; templates: GHLEmailTemplate[]; message: string }> { 190 | try { 191 | const response = await this.ghlClient.getEmailTemplates(params); 192 | if (!response.success || !response.data) { 193 | throw new Error(response.error?.message || 'Failed to get email templates.'); 194 | } 195 | return { 196 | success: true, 197 | templates: response.data, 198 | message: `Successfully retrieved ${response.data.length} email templates.` 199 | }; 200 | } catch (error) { 201 | throw new Error(`Failed to get email templates: ${error}`); 202 | } 203 | } 204 | 205 | private async updateEmailTemplate(params: MCPUpdateEmailTemplateParams): Promise<{ success: boolean; message: string }> { 206 | try { 207 | const response = await this.ghlClient.updateEmailTemplate(params); 208 | if (!response.success) { 209 | throw new Error(response.error?.message || 'Failed to update email template.'); 210 | } 211 | return { 212 | success: true, 213 | message: 'Successfully updated email template.' 214 | }; 215 | } catch (error) { 216 | throw new Error(`Failed to update email template: ${error}`); 217 | } 218 | } 219 | 220 | private async deleteEmailTemplate(params: MCPDeleteEmailTemplateParams): Promise<{ success: boolean; message: string }> { 221 | try { 222 | const response = await this.ghlClient.deleteEmailTemplate(params); 223 | if (!response.success) { 224 | throw new Error(response.error?.message || 'Failed to delete email template.'); 225 | } 226 | return { 227 | success: true, 228 | message: 'Successfully deleted email template.' 229 | }; 230 | } catch (error) { 231 | throw new Error(`Failed to delete email template: ${error}`); 232 | } 233 | } 234 | } -------------------------------------------------------------------------------- /tests/mocks/ghl-api-client.mock.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Mock implementation of GHLApiClient for testing 3 | * Provides realistic test data without making actual API calls 4 | */ 5 | 6 | import { 7 | GHLApiResponse, 8 | GHLContact, 9 | GHLConversation, 10 | GHLMessage, 11 | GHLBlogPost, 12 | GHLBlogSite, 13 | GHLBlogAuthor, 14 | GHLBlogCategory 15 | } from '../../src/types/ghl-types.js'; 16 | 17 | // Mock test data 18 | export const mockContact: GHLContact = { 19 | id: 'contact_123', 20 | locationId: 'test_location_123', 21 | firstName: 'John', 22 | lastName: 'Doe', 23 | name: 'John Doe', 24 | email: 'john.doe@example.com', 25 | phone: '+1-555-123-4567', 26 | tags: ['test', 'customer'], 27 | source: 'ChatGPT MCP', 28 | dateAdded: '2024-01-01T00:00:00.000Z', 29 | dateUpdated: '2024-01-01T00:00:00.000Z' 30 | }; 31 | 32 | export const mockConversation: GHLConversation = { 33 | id: 'conv_123', 34 | contactId: 'contact_123', 35 | locationId: 'test_location_123', 36 | lastMessageBody: 'Test message', 37 | lastMessageType: 'TYPE_SMS', 38 | type: 'SMS', 39 | unreadCount: 0, 40 | fullName: 'John Doe', 41 | contactName: 'John Doe', 42 | email: 'john.doe@example.com', 43 | phone: '+1-555-123-4567' 44 | }; 45 | 46 | export const mockMessage: GHLMessage = { 47 | id: 'msg_123', 48 | type: 1, 49 | messageType: 'TYPE_SMS', 50 | locationId: 'test_location_123', 51 | contactId: 'contact_123', 52 | conversationId: 'conv_123', 53 | dateAdded: '2024-01-01T00:00:00.000Z', 54 | body: 'Test SMS message', 55 | direction: 'outbound', 56 | status: 'sent', 57 | contentType: 'text/plain' 58 | }; 59 | 60 | export const mockBlogPost: GHLBlogPost = { 61 | _id: 'post_123', 62 | title: 'Test Blog Post', 63 | description: 'Test blog post description', 64 | imageUrl: 'https://example.com/image.jpg', 65 | imageAltText: 'Test image', 66 | urlSlug: 'test-blog-post', 67 | author: 'author_123', 68 | publishedAt: '2024-01-01T00:00:00.000Z', 69 | updatedAt: '2024-01-01T00:00:00.000Z', 70 | status: 'PUBLISHED', 71 | categories: ['cat_123'], 72 | tags: ['test', 'blog'], 73 | archived: false, 74 | rawHTML: '

Test Content

' 75 | }; 76 | 77 | export const mockBlogSite: GHLBlogSite = { 78 | _id: 'blog_123', 79 | name: 'Test Blog Site' 80 | }; 81 | 82 | export const mockBlogAuthor: GHLBlogAuthor = { 83 | _id: 'author_123', 84 | name: 'Test Author', 85 | locationId: 'test_location_123', 86 | updatedAt: '2024-01-01T00:00:00.000Z', 87 | canonicalLink: 'https://example.com/author/test' 88 | }; 89 | 90 | export const mockBlogCategory: GHLBlogCategory = { 91 | _id: 'cat_123', 92 | label: 'Test Category', 93 | locationId: 'test_location_123', 94 | updatedAt: '2024-01-01T00:00:00.000Z', 95 | canonicalLink: 'https://example.com/category/test', 96 | urlSlug: 'test-category' 97 | }; 98 | 99 | /** 100 | * Mock GHL API Client class 101 | */ 102 | export class MockGHLApiClient { 103 | private config = { 104 | accessToken: 'test_token', 105 | baseUrl: 'https://test.leadconnectorhq.com', 106 | version: '2021-07-28', 107 | locationId: 'test_location_123' 108 | }; 109 | 110 | // Contact methods 111 | async createContact(contactData: any): Promise> { 112 | return { 113 | success: true, 114 | data: { ...mockContact, ...contactData, id: 'contact_' + Date.now() } 115 | }; 116 | } 117 | 118 | async getContact(contactId: string): Promise> { 119 | if (contactId === 'not_found') { 120 | throw new Error('GHL API Error (404): Contact not found'); 121 | } 122 | return { 123 | success: true, 124 | data: { ...mockContact, id: contactId } 125 | }; 126 | } 127 | 128 | async updateContact(contactId: string, updates: any): Promise> { 129 | return { 130 | success: true, 131 | data: { ...mockContact, ...updates, id: contactId } 132 | }; 133 | } 134 | 135 | async deleteContact(contactId: string): Promise> { 136 | return { 137 | success: true, 138 | data: { succeded: true } 139 | }; 140 | } 141 | 142 | async searchContacts(searchParams: any): Promise> { 143 | return { 144 | success: true, 145 | data: { 146 | contacts: [mockContact], 147 | total: 1 148 | } 149 | }; 150 | } 151 | 152 | async addContactTags(contactId: string, tags: string[]): Promise> { 153 | return { 154 | success: true, 155 | data: { tags: [...mockContact.tags!, ...tags] } 156 | }; 157 | } 158 | 159 | async removeContactTags(contactId: string, tags: string[]): Promise> { 160 | return { 161 | success: true, 162 | data: { tags: mockContact.tags!.filter(tag => !tags.includes(tag)) } 163 | }; 164 | } 165 | 166 | // Conversation methods 167 | async sendSMS(contactId: string, message: string, fromNumber?: string): Promise> { 168 | return { 169 | success: true, 170 | data: { 171 | messageId: 'msg_' + Date.now(), 172 | conversationId: 'conv_123' 173 | } 174 | }; 175 | } 176 | 177 | async sendEmail(contactId: string, subject: string, message?: string, html?: string, options?: any): Promise> { 178 | return { 179 | success: true, 180 | data: { 181 | messageId: 'msg_' + Date.now(), 182 | conversationId: 'conv_123', 183 | emailMessageId: 'email_' + Date.now() 184 | } 185 | }; 186 | } 187 | 188 | async searchConversations(searchParams: any): Promise> { 189 | return { 190 | success: true, 191 | data: { 192 | conversations: [mockConversation], 193 | total: 1 194 | } 195 | }; 196 | } 197 | 198 | async getConversation(conversationId: string): Promise> { 199 | return { 200 | success: true, 201 | data: { ...mockConversation, id: conversationId } 202 | }; 203 | } 204 | 205 | async createConversation(conversationData: any): Promise> { 206 | return { 207 | success: true, 208 | data: { 209 | id: 'conv_' + Date.now(), 210 | dateUpdated: new Date().toISOString(), 211 | dateAdded: new Date().toISOString(), 212 | deleted: false, 213 | contactId: conversationData.contactId, 214 | locationId: conversationData.locationId, 215 | lastMessageDate: new Date().toISOString() 216 | } 217 | }; 218 | } 219 | 220 | async updateConversation(conversationId: string, updates: any): Promise> { 221 | return { 222 | success: true, 223 | data: { ...mockConversation, ...updates, id: conversationId } 224 | }; 225 | } 226 | 227 | async getConversationMessages(conversationId: string, options?: any): Promise> { 228 | return { 229 | success: true, 230 | data: { 231 | lastMessageId: 'msg_123', 232 | nextPage: false, 233 | messages: [mockMessage] 234 | } 235 | }; 236 | } 237 | 238 | // Blog methods 239 | async createBlogPost(postData: any): Promise> { 240 | return { 241 | success: true, 242 | data: { 243 | data: { ...mockBlogPost, ...postData, _id: 'post_' + Date.now() } 244 | } 245 | }; 246 | } 247 | 248 | async updateBlogPost(postId: string, postData: any): Promise> { 249 | return { 250 | success: true, 251 | data: { 252 | updatedBlogPost: { ...mockBlogPost, ...postData, _id: postId } 253 | } 254 | }; 255 | } 256 | 257 | async getBlogPosts(params: any): Promise> { 258 | return { 259 | success: true, 260 | data: { 261 | blogs: [mockBlogPost] 262 | } 263 | }; 264 | } 265 | 266 | async getBlogSites(params: any): Promise> { 267 | return { 268 | success: true, 269 | data: { 270 | data: [mockBlogSite] 271 | } 272 | }; 273 | } 274 | 275 | async getBlogAuthors(params: any): Promise> { 276 | return { 277 | success: true, 278 | data: { 279 | authors: [mockBlogAuthor] 280 | } 281 | }; 282 | } 283 | 284 | async getBlogCategories(params: any): Promise> { 285 | return { 286 | success: true, 287 | data: { 288 | categories: [mockBlogCategory] 289 | } 290 | }; 291 | } 292 | 293 | async checkUrlSlugExists(params: any): Promise> { 294 | return { 295 | success: true, 296 | data: { 297 | exists: params.urlSlug === 'existing-slug' 298 | } 299 | }; 300 | } 301 | 302 | async testConnection(): Promise> { 303 | return { 304 | success: true, 305 | data: { 306 | status: 'connected', 307 | locationId: this.config.locationId 308 | } 309 | }; 310 | } 311 | 312 | getConfig() { 313 | return { ...this.config }; 314 | } 315 | 316 | updateAccessToken(newToken: string): void { 317 | this.config.accessToken = newToken; 318 | } 319 | } -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | // ChatGPT-compliant MCP Server for GoHighLevel 2 | // Implements strict MCP 2024-11-05 protocol requirements 3 | 4 | const MCP_PROTOCOL_VERSION = "2024-11-05"; 5 | 6 | // Server information - ChatGPT requires specific format 7 | const SERVER_INFO = { 8 | name: "ghl-mcp-server", 9 | version: "1.0.0" 10 | }; 11 | 12 | // Only these tool names work with ChatGPT 13 | const TOOLS = [ 14 | { 15 | name: "search", 16 | description: "Search for information in GoHighLevel CRM system", 17 | inputSchema: { 18 | type: "object", 19 | properties: { 20 | query: { 21 | type: "string", 22 | description: "Search query for GoHighLevel data" 23 | } 24 | }, 25 | required: ["query"] 26 | } 27 | }, 28 | { 29 | name: "retrieve", 30 | description: "Retrieve specific data from GoHighLevel", 31 | inputSchema: { 32 | type: "object", 33 | properties: { 34 | id: { 35 | type: "string", 36 | description: "ID of the item to retrieve" 37 | }, 38 | type: { 39 | type: "string", 40 | enum: ["contact", "conversation", "blog"], 41 | description: "Type of item to retrieve" 42 | } 43 | }, 44 | required: ["id", "type"] 45 | } 46 | } 47 | ]; 48 | 49 | function log(message, data = null) { 50 | const timestamp = new Date().toISOString(); 51 | console.log(`[${timestamp}] [MCP] ${message}${data ? ': ' + JSON.stringify(data) : ''}`); 52 | } 53 | 54 | // Create proper JSON-RPC 2.0 response 55 | function createJsonRpcResponse(id, result = null, error = null) { 56 | const response = { 57 | jsonrpc: "2.0", 58 | id: id 59 | }; 60 | 61 | if (error) { 62 | response.error = error; 63 | } else { 64 | response.result = result; 65 | } 66 | 67 | return response; 68 | } 69 | 70 | // Create proper JSON-RPC 2.0 notification 71 | function createJsonRpcNotification(method, params = {}) { 72 | return { 73 | jsonrpc: "2.0", 74 | method: method, 75 | params: params 76 | }; 77 | } 78 | 79 | // Handle MCP initialize request 80 | function handleInitialize(request) { 81 | log("Handling initialize request", request.params); 82 | 83 | return createJsonRpcResponse(request.id, { 84 | protocolVersion: MCP_PROTOCOL_VERSION, 85 | capabilities: { 86 | tools: {} 87 | }, 88 | serverInfo: SERVER_INFO 89 | }); 90 | } 91 | 92 | // Handle tools/list request 93 | function handleToolsList(request) { 94 | log("Handling tools/list request"); 95 | 96 | return createJsonRpcResponse(request.id, { 97 | tools: TOOLS 98 | }); 99 | } 100 | 101 | // Handle tools/call request 102 | function handleToolsCall(request) { 103 | const { name, arguments: args } = request.params; 104 | log("Handling tools/call request", { tool: name, args }); 105 | 106 | let content; 107 | 108 | if (name === "search") { 109 | content = [ 110 | { 111 | type: "text", 112 | text: `GoHighLevel Search Results for: "${args.query}"\n\n✅ Found Results:\n• Contact: John Doe (john@example.com)\n• Contact: Jane Smith (jane@example.com)\n• Conversation: "Follow-up call scheduled"\n• Blog Post: "How to Generate More Leads"\n\n📊 Search completed successfully in GoHighLevel CRM.` 113 | } 114 | ]; 115 | } else if (name === "retrieve") { 116 | content = [ 117 | { 118 | type: "text", 119 | text: `GoHighLevel ${args.type} Retrieved: ID ${args.id}\n\n📄 Details:\n• Name: Sample ${args.type}\n• Status: Active\n• Last Updated: ${new Date().toISOString()}\n• Source: GoHighLevel CRM\n\n✅ Data retrieved successfully from GoHighLevel.` 120 | } 121 | ]; 122 | } else { 123 | return createJsonRpcResponse(request.id, null, { 124 | code: -32601, 125 | message: `Method not found: ${name}` 126 | }); 127 | } 128 | 129 | return createJsonRpcResponse(request.id, { 130 | content: content 131 | }); 132 | } 133 | 134 | // Handle ping request (required by MCP protocol) 135 | function handlePing(request) { 136 | log("Handling ping request"); 137 | return createJsonRpcResponse(request.id, {}); 138 | } 139 | 140 | // Process JSON-RPC message 141 | function processJsonRpcMessage(message) { 142 | try { 143 | log("Processing JSON-RPC message", { method: message.method, id: message.id }); 144 | 145 | // Validate JSON-RPC format 146 | if (message.jsonrpc !== "2.0") { 147 | return createJsonRpcResponse(message.id, null, { 148 | code: -32600, 149 | message: "Invalid Request: jsonrpc must be '2.0'" 150 | }); 151 | } 152 | 153 | switch (message.method) { 154 | case "initialize": 155 | return handleInitialize(message); 156 | case "tools/list": 157 | return handleToolsList(message); 158 | case "tools/call": 159 | return handleToolsCall(message); 160 | case "ping": 161 | return handlePing(message); 162 | default: 163 | return createJsonRpcResponse(message.id, null, { 164 | code: -32601, 165 | message: `Method not found: ${message.method}` 166 | }); 167 | } 168 | } catch (error) { 169 | log("Error processing message", error.message); 170 | return createJsonRpcResponse(message.id, null, { 171 | code: -32603, 172 | message: "Internal error", 173 | data: error.message 174 | }); 175 | } 176 | } 177 | 178 | // Send Server-Sent Event 179 | function sendSSE(res, data) { 180 | try { 181 | const message = typeof data === 'string' ? data : JSON.stringify(data); 182 | res.write(`data: ${message}\n\n`); 183 | log("Sent SSE message", { type: typeof data }); 184 | } catch (error) { 185 | log("Error sending SSE", error.message); 186 | } 187 | } 188 | 189 | // Set CORS headers 190 | function setCORSHeaders(res) { 191 | res.setHeader('Access-Control-Allow-Origin', '*'); 192 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); 193 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); 194 | res.setHeader('Access-Control-Max-Age', '86400'); 195 | } 196 | 197 | // Main request handler - Node.js style export 198 | module.exports = async (req, res) => { 199 | const timestamp = new Date().toISOString(); 200 | log(`${req.method} ${req.url}`); 201 | log(`User-Agent: ${req.headers['user-agent']}`); 202 | 203 | // Set CORS headers 204 | setCORSHeaders(res); 205 | 206 | // Handle preflight 207 | if (req.method === 'OPTIONS') { 208 | res.status(200).end(); 209 | return; 210 | } 211 | 212 | // Health check 213 | if (req.url === '/health' || req.url === '/') { 214 | log("Health check requested"); 215 | res.status(200).json({ 216 | status: 'healthy', 217 | server: SERVER_INFO.name, 218 | version: SERVER_INFO.version, 219 | protocol: MCP_PROTOCOL_VERSION, 220 | timestamp: timestamp, 221 | tools: TOOLS.map(t => t.name), 222 | endpoint: '/sse' 223 | }); 224 | return; 225 | } 226 | 227 | // Favicon handling 228 | if (req.url?.includes('favicon')) { 229 | res.status(404).end(); 230 | return; 231 | } 232 | 233 | // MCP SSE endpoint 234 | if (req.url === '/sse') { 235 | log("MCP SSE endpoint requested"); 236 | 237 | // Set SSE headers 238 | res.writeHead(200, { 239 | 'Content-Type': 'text/event-stream', 240 | 'Cache-Control': 'no-cache', 241 | 'Connection': 'keep-alive', 242 | 'Access-Control-Allow-Origin': '*', 243 | 'Access-Control-Allow-Headers': 'Content-Type, Accept', 244 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' 245 | }); 246 | 247 | // Handle GET (SSE connection) 248 | if (req.method === 'GET') { 249 | log("SSE connection established"); 250 | 251 | // Send immediate initialization notification 252 | const initNotification = createJsonRpcNotification("notification/initialized", {}); 253 | sendSSE(res, initNotification); 254 | 255 | // Send tools available notification 256 | setTimeout(() => { 257 | const toolsNotification = createJsonRpcNotification("notification/tools/list_changed", {}); 258 | sendSSE(res, toolsNotification); 259 | }, 100); 260 | 261 | // Keep-alive heartbeat every 25 seconds (well under Vercel's 60s limit) 262 | const heartbeat = setInterval(() => { 263 | res.write(': heartbeat\n\n'); 264 | }, 25000); 265 | 266 | // Cleanup on connection close 267 | req.on('close', () => { 268 | log("SSE connection closed"); 269 | clearInterval(heartbeat); 270 | }); 271 | 272 | req.on('error', (error) => { 273 | log("SSE connection error", error.message); 274 | clearInterval(heartbeat); 275 | }); 276 | 277 | // Auto-close after 50 seconds to prevent Vercel timeout 278 | setTimeout(() => { 279 | log("SSE connection auto-closing before timeout"); 280 | clearInterval(heartbeat); 281 | res.end(); 282 | }, 50000); 283 | 284 | return; 285 | } 286 | 287 | // Handle POST (JSON-RPC messages) 288 | if (req.method === 'POST') { 289 | log("Processing JSON-RPC POST request"); 290 | 291 | let body = ''; 292 | req.on('data', chunk => { 293 | body += chunk.toString(); 294 | }); 295 | 296 | req.on('end', () => { 297 | try { 298 | log("Received POST body", body); 299 | const message = JSON.parse(body); 300 | const response = processJsonRpcMessage(message); 301 | 302 | log("Sending JSON-RPC response", response); 303 | 304 | // Send as SSE for MCP protocol compliance 305 | sendSSE(res, response); 306 | 307 | // Close connection after response 308 | setTimeout(() => { 309 | res.end(); 310 | }, 100); 311 | 312 | } catch (error) { 313 | log("JSON parse error", error.message); 314 | const errorResponse = createJsonRpcResponse(null, null, { 315 | code: -32700, 316 | message: "Parse error" 317 | }); 318 | sendSSE(res, errorResponse); 319 | res.end(); 320 | } 321 | }); 322 | 323 | return; 324 | } 325 | } 326 | 327 | // Default 404 328 | log("Unknown endpoint", req.url); 329 | res.status(404).json({ error: 'Not found' }); 330 | }; -------------------------------------------------------------------------------- /src/tools/media-tools.ts: -------------------------------------------------------------------------------- 1 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 2 | import { 3 | MCPGetMediaFilesParams, 4 | MCPUploadMediaFileParams, 5 | MCPDeleteMediaParams, 6 | GHLGetMediaFilesRequest, 7 | GHLUploadMediaFileRequest, 8 | GHLDeleteMediaRequest 9 | } from '../types/ghl-types.js'; 10 | 11 | export interface Tool { 12 | name: string; 13 | description: string; 14 | inputSchema: { 15 | type: string; 16 | properties: Record; 17 | required: string[]; 18 | }; 19 | } 20 | 21 | /** 22 | * MediaTools class for GoHighLevel Media Library API endpoints 23 | * Handles file management operations including listing, uploading, and deleting files/folders 24 | */ 25 | export class MediaTools { 26 | constructor(private ghlClient: GHLApiClient) {} 27 | 28 | /** 29 | * Get all available Media Library tool definitions 30 | */ 31 | getToolDefinitions(): Tool[] { 32 | return [ 33 | { 34 | name: 'get_media_files', 35 | description: 'Get list of files and folders from the media library with filtering and search capabilities', 36 | inputSchema: { 37 | type: 'object', 38 | properties: { 39 | offset: { 40 | type: 'number', 41 | description: 'Number of files to skip in listing', 42 | minimum: 0 43 | }, 44 | limit: { 45 | type: 'number', 46 | description: 'Number of files to show in the listing (max 100)', 47 | minimum: 1, 48 | maximum: 100 49 | }, 50 | sortBy: { 51 | type: 'string', 52 | description: 'Field to sort the file listing by (e.g., createdAt, name, size)', 53 | default: 'createdAt' 54 | }, 55 | sortOrder: { 56 | type: 'string', 57 | description: 'Direction to sort files (asc or desc)', 58 | enum: ['asc', 'desc'], 59 | default: 'desc' 60 | }, 61 | type: { 62 | type: 'string', 63 | description: 'Filter by type (file or folder)', 64 | enum: ['file', 'folder'] 65 | }, 66 | query: { 67 | type: 'string', 68 | description: 'Search query text to filter files by name' 69 | }, 70 | altType: { 71 | type: 'string', 72 | description: 'Context type (location or agency)', 73 | enum: ['location', 'agency'], 74 | default: 'location' 75 | }, 76 | altId: { 77 | type: 'string', 78 | description: 'Location or Agency ID (uses default location if not provided)' 79 | }, 80 | parentId: { 81 | type: 'string', 82 | description: 'Parent folder ID to list files within a specific folder' 83 | } 84 | }, 85 | required: [] 86 | } 87 | }, 88 | { 89 | name: 'upload_media_file', 90 | description: 'Upload a file to the media library or add a hosted file URL (max 25MB for direct uploads)', 91 | inputSchema: { 92 | type: 'object', 93 | properties: { 94 | file: { 95 | type: 'string', 96 | description: 'File data (binary) for direct upload' 97 | }, 98 | hosted: { 99 | type: 'boolean', 100 | description: 'Set to true if providing a fileUrl instead of direct file upload', 101 | default: false 102 | }, 103 | fileUrl: { 104 | type: 'string', 105 | description: 'URL of hosted file (required if hosted=true)' 106 | }, 107 | name: { 108 | type: 'string', 109 | description: 'Custom name for the uploaded file' 110 | }, 111 | parentId: { 112 | type: 'string', 113 | description: 'Parent folder ID to upload file into' 114 | }, 115 | altType: { 116 | type: 'string', 117 | description: 'Context type (location or agency)', 118 | enum: ['location', 'agency'], 119 | default: 'location' 120 | }, 121 | altId: { 122 | type: 'string', 123 | description: 'Location or Agency ID (uses default location if not provided)' 124 | } 125 | }, 126 | required: [] 127 | } 128 | }, 129 | { 130 | name: 'delete_media_file', 131 | description: 'Delete a specific file or folder from the media library', 132 | inputSchema: { 133 | type: 'object', 134 | properties: { 135 | id: { 136 | type: 'string', 137 | description: 'ID of the file or folder to delete' 138 | }, 139 | altType: { 140 | type: 'string', 141 | description: 'Context type (location or agency)', 142 | enum: ['location', 'agency'], 143 | default: 'location' 144 | }, 145 | altId: { 146 | type: 'string', 147 | description: 'Location or Agency ID (uses default location if not provided)' 148 | } 149 | }, 150 | required: ['id'] 151 | } 152 | } 153 | ]; 154 | } 155 | 156 | /** 157 | * Execute a media tool by name with given arguments 158 | */ 159 | async executeTool(name: string, args: any): Promise { 160 | switch (name) { 161 | case 'get_media_files': 162 | return this.getMediaFiles(args as MCPGetMediaFilesParams); 163 | 164 | case 'upload_media_file': 165 | return this.uploadMediaFile(args as MCPUploadMediaFileParams); 166 | 167 | case 'delete_media_file': 168 | return this.deleteMediaFile(args as MCPDeleteMediaParams); 169 | 170 | default: 171 | throw new Error(`Unknown media tool: ${name}`); 172 | } 173 | } 174 | 175 | /** 176 | * GET MEDIA FILES 177 | */ 178 | private async getMediaFiles(params: MCPGetMediaFilesParams = {}): Promise<{ success: boolean; files: any[]; total?: number; message: string }> { 179 | try { 180 | const requestParams: GHLGetMediaFilesRequest = { 181 | sortBy: params.sortBy || 'createdAt', 182 | sortOrder: params.sortOrder || 'desc', 183 | altType: params.altType || 'location', 184 | altId: params.altId || this.ghlClient.getConfig().locationId, 185 | ...(params.offset !== undefined && { offset: params.offset }), 186 | ...(params.limit !== undefined && { limit: params.limit }), 187 | ...(params.type && { type: params.type }), 188 | ...(params.query && { query: params.query }), 189 | ...(params.parentId && { parentId: params.parentId }) 190 | }; 191 | 192 | const response = await this.ghlClient.getMediaFiles(requestParams); 193 | 194 | if (!response.success || !response.data) { 195 | const errorMsg = response.error?.message || 'Unknown API error'; 196 | throw new Error(`API request failed: ${errorMsg}`); 197 | } 198 | 199 | const files = Array.isArray(response.data.files) ? response.data.files : []; 200 | 201 | return { 202 | success: true, 203 | files, 204 | total: response.data.total, 205 | message: `Retrieved ${files.length} media files/folders` 206 | }; 207 | } catch (error) { 208 | throw new Error(`Failed to get media files: ${error instanceof Error ? error.message : String(error)}`); 209 | } 210 | } 211 | 212 | /** 213 | * UPLOAD MEDIA FILE 214 | */ 215 | private async uploadMediaFile(params: MCPUploadMediaFileParams): Promise<{ success: boolean; fileId: string; url?: string; message: string }> { 216 | try { 217 | // Validate upload parameters 218 | if (params.hosted && !params.fileUrl) { 219 | throw new Error('fileUrl is required when hosted=true'); 220 | } 221 | if (!params.hosted && !params.file) { 222 | throw new Error('file is required when hosted=false or not specified'); 223 | } 224 | 225 | const uploadData: GHLUploadMediaFileRequest = { 226 | altType: params.altType || 'location', 227 | altId: params.altId || this.ghlClient.getConfig().locationId, 228 | ...(params.hosted !== undefined && { hosted: params.hosted }), 229 | ...(params.fileUrl && { fileUrl: params.fileUrl }), 230 | ...(params.file && { file: params.file }), 231 | ...(params.name && { name: params.name }), 232 | ...(params.parentId && { parentId: params.parentId }) 233 | }; 234 | 235 | const response = await this.ghlClient.uploadMediaFile(uploadData); 236 | 237 | if (!response.success || !response.data) { 238 | const errorMsg = response.error?.message || 'Unknown API error'; 239 | throw new Error(`API request failed: ${errorMsg}`); 240 | } 241 | 242 | return { 243 | success: true, 244 | fileId: response.data.fileId, 245 | url: response.data.url, 246 | message: `File uploaded successfully with ID: ${response.data.fileId}` 247 | }; 248 | } catch (error) { 249 | throw new Error(`Failed to upload media file: ${error instanceof Error ? error.message : String(error)}`); 250 | } 251 | } 252 | 253 | /** 254 | * DELETE MEDIA FILE 255 | */ 256 | private async deleteMediaFile(params: MCPDeleteMediaParams): Promise<{ success: boolean; message: string }> { 257 | try { 258 | const deleteParams: GHLDeleteMediaRequest = { 259 | id: params.id, 260 | altType: params.altType || 'location', 261 | altId: params.altId || this.ghlClient.getConfig().locationId 262 | }; 263 | 264 | const response = await this.ghlClient.deleteMediaFile(deleteParams); 265 | 266 | if (!response.success) { 267 | const errorMsg = response.error?.message || 'Unknown API error'; 268 | throw new Error(`API request failed: ${errorMsg}`); 269 | } 270 | 271 | return { 272 | success: true, 273 | message: `Media file/folder deleted successfully` 274 | }; 275 | } catch (error) { 276 | throw new Error(`Failed to delete media file: ${error instanceof Error ? error.message : String(error)}`); 277 | } 278 | } 279 | } -------------------------------------------------------------------------------- /tests/tools/contact-tools.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit Tests for Contact Tools 3 | * Tests all 7 contact management MCP tools 4 | */ 5 | 6 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 7 | import { ContactTools } from '../../src/tools/contact-tools.js'; 8 | import { MockGHLApiClient, mockContact } from '../mocks/ghl-api-client.mock.js'; 9 | 10 | describe('ContactTools', () => { 11 | let contactTools: ContactTools; 12 | let mockGhlClient: MockGHLApiClient; 13 | 14 | beforeEach(() => { 15 | mockGhlClient = new MockGHLApiClient(); 16 | contactTools = new ContactTools(mockGhlClient as any); 17 | }); 18 | 19 | describe('getToolDefinitions', () => { 20 | it('should return 7 contact tool definitions', () => { 21 | const tools = contactTools.getToolDefinitions(); 22 | expect(tools).toHaveLength(7); 23 | 24 | const toolNames = tools.map(tool => tool.name); 25 | expect(toolNames).toEqual([ 26 | 'create_contact', 27 | 'search_contacts', 28 | 'get_contact', 29 | 'update_contact', 30 | 'add_contact_tags', 31 | 'remove_contact_tags', 32 | 'delete_contact' 33 | ]); 34 | }); 35 | 36 | it('should have proper schema definitions for all tools', () => { 37 | const tools = contactTools.getToolDefinitions(); 38 | 39 | tools.forEach(tool => { 40 | expect(tool.name).toBeDefined(); 41 | expect(tool.description).toBeDefined(); 42 | expect(tool.inputSchema).toBeDefined(); 43 | expect(tool.inputSchema.type).toBe('object'); 44 | expect(tool.inputSchema.properties).toBeDefined(); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('executeTool', () => { 50 | it('should route tool calls correctly', async () => { 51 | const createSpy = jest.spyOn(contactTools as any, 'createContact'); 52 | const getSpy = jest.spyOn(contactTools as any, 'getContact'); 53 | 54 | await contactTools.executeTool('create_contact', { email: 'test@example.com' }); 55 | await contactTools.executeTool('get_contact', { contactId: 'contact_123' }); 56 | 57 | expect(createSpy).toHaveBeenCalledWith({ email: 'test@example.com' }); 58 | expect(getSpy).toHaveBeenCalledWith('contact_123'); 59 | }); 60 | 61 | it('should throw error for unknown tool', async () => { 62 | await expect( 63 | contactTools.executeTool('unknown_tool', {}) 64 | ).rejects.toThrow('Unknown tool: unknown_tool'); 65 | }); 66 | }); 67 | 68 | describe('create_contact', () => { 69 | it('should create contact successfully', async () => { 70 | const contactData = { 71 | firstName: 'Jane', 72 | lastName: 'Doe', 73 | email: 'jane.doe@example.com', 74 | phone: '+1-555-987-6543' 75 | }; 76 | 77 | const result = await contactTools.executeTool('create_contact', contactData); 78 | 79 | expect(result.success).toBe(true); 80 | expect(result.contact).toBeDefined(); 81 | expect(result.contact.email).toBe(contactData.email); 82 | expect(result.message).toContain('Contact created successfully'); 83 | }); 84 | 85 | it('should handle API errors', async () => { 86 | const mockError = new Error('GHL API Error (400): Invalid email'); 87 | jest.spyOn(mockGhlClient, 'createContact').mockRejectedValueOnce(mockError); 88 | 89 | await expect( 90 | contactTools.executeTool('create_contact', { email: 'invalid-email' }) 91 | ).rejects.toThrow('Failed to create contact'); 92 | }); 93 | 94 | it('should set default source if not provided', async () => { 95 | const spy = jest.spyOn(mockGhlClient, 'createContact'); 96 | 97 | await contactTools.executeTool('create_contact', { 98 | firstName: 'John', 99 | email: 'john@example.com' 100 | }); 101 | 102 | expect(spy).toHaveBeenCalledWith( 103 | expect.objectContaining({ 104 | source: 'ChatGPT MCP' 105 | }) 106 | ); 107 | }); 108 | }); 109 | 110 | describe('search_contacts', () => { 111 | it('should search contacts successfully', async () => { 112 | const searchParams = { 113 | query: 'John Doe', 114 | limit: 10 115 | }; 116 | 117 | const result = await contactTools.executeTool('search_contacts', searchParams); 118 | 119 | expect(result.success).toBe(true); 120 | expect(result.contacts).toBeDefined(); 121 | expect(Array.isArray(result.contacts)).toBe(true); 122 | expect(result.total).toBeDefined(); 123 | expect(result.message).toContain('Found'); 124 | }); 125 | 126 | it('should use default limit if not provided', async () => { 127 | const spy = jest.spyOn(mockGhlClient, 'searchContacts'); 128 | 129 | await contactTools.executeTool('search_contacts', { query: 'test' }); 130 | 131 | expect(spy).toHaveBeenCalledWith( 132 | expect.objectContaining({ 133 | limit: 25 134 | }) 135 | ); 136 | }); 137 | 138 | it('should handle search with email filter', async () => { 139 | const result = await contactTools.executeTool('search_contacts', { 140 | email: 'john@example.com' 141 | }); 142 | 143 | expect(result.success).toBe(true); 144 | expect(result.contacts).toBeDefined(); 145 | }); 146 | }); 147 | 148 | describe('get_contact', () => { 149 | it('should get contact by ID successfully', async () => { 150 | const result = await contactTools.executeTool('get_contact', { 151 | contactId: 'contact_123' 152 | }); 153 | 154 | expect(result.success).toBe(true); 155 | expect(result.contact).toBeDefined(); 156 | expect(result.contact.id).toBe('contact_123'); 157 | expect(result.message).toBe('Contact retrieved successfully'); 158 | }); 159 | 160 | it('should handle contact not found', async () => { 161 | await expect( 162 | contactTools.executeTool('get_contact', { contactId: 'not_found' }) 163 | ).rejects.toThrow('Failed to get contact'); 164 | }); 165 | }); 166 | 167 | describe('update_contact', () => { 168 | it('should update contact successfully', async () => { 169 | const updateData = { 170 | contactId: 'contact_123', 171 | firstName: 'Updated', 172 | lastName: 'Name' 173 | }; 174 | 175 | const result = await contactTools.executeTool('update_contact', updateData); 176 | 177 | expect(result.success).toBe(true); 178 | expect(result.contact).toBeDefined(); 179 | expect(result.contact.firstName).toBe('Updated'); 180 | expect(result.message).toBe('Contact updated successfully'); 181 | }); 182 | 183 | it('should handle partial updates', async () => { 184 | const spy = jest.spyOn(mockGhlClient, 'updateContact'); 185 | 186 | await contactTools.executeTool('update_contact', { 187 | contactId: 'contact_123', 188 | email: 'newemail@example.com' 189 | }); 190 | 191 | expect(spy).toHaveBeenCalledWith('contact_123', { 192 | email: 'newemail@example.com' 193 | }); 194 | }); 195 | }); 196 | 197 | describe('add_contact_tags', () => { 198 | it('should add tags successfully', async () => { 199 | const result = await contactTools.executeTool('add_contact_tags', { 200 | contactId: 'contact_123', 201 | tags: ['vip', 'premium'] 202 | }); 203 | 204 | expect(result.success).toBe(true); 205 | expect(result.tags).toBeDefined(); 206 | expect(Array.isArray(result.tags)).toBe(true); 207 | expect(result.message).toContain('Successfully added 2 tags'); 208 | }); 209 | 210 | it('should validate required parameters', async () => { 211 | await expect( 212 | contactTools.executeTool('add_contact_tags', { contactId: 'contact_123' }) 213 | ).rejects.toThrow(); 214 | }); 215 | }); 216 | 217 | describe('remove_contact_tags', () => { 218 | it('should remove tags successfully', async () => { 219 | const result = await contactTools.executeTool('remove_contact_tags', { 220 | contactId: 'contact_123', 221 | tags: ['old-tag'] 222 | }); 223 | 224 | expect(result.success).toBe(true); 225 | expect(result.tags).toBeDefined(); 226 | expect(result.message).toContain('Successfully removed 1 tags'); 227 | }); 228 | 229 | it('should handle empty tags array', async () => { 230 | const spy = jest.spyOn(mockGhlClient, 'removeContactTags'); 231 | 232 | await contactTools.executeTool('remove_contact_tags', { 233 | contactId: 'contact_123', 234 | tags: [] 235 | }); 236 | 237 | expect(spy).toHaveBeenCalledWith('contact_123', []); 238 | }); 239 | }); 240 | 241 | describe('delete_contact', () => { 242 | it('should delete contact successfully', async () => { 243 | const result = await contactTools.executeTool('delete_contact', { 244 | contactId: 'contact_123' 245 | }); 246 | 247 | expect(result.success).toBe(true); 248 | expect(result.message).toBe('Contact deleted successfully'); 249 | }); 250 | 251 | it('should handle deletion errors', async () => { 252 | const mockError = new Error('GHL API Error (404): Contact not found'); 253 | jest.spyOn(mockGhlClient, 'deleteContact').mockRejectedValueOnce(mockError); 254 | 255 | await expect( 256 | contactTools.executeTool('delete_contact', { contactId: 'not_found' }) 257 | ).rejects.toThrow('Failed to delete contact'); 258 | }); 259 | }); 260 | 261 | describe('error handling', () => { 262 | it('should propagate API client errors', async () => { 263 | const mockError = new Error('Network error'); 264 | jest.spyOn(mockGhlClient, 'createContact').mockRejectedValueOnce(mockError); 265 | 266 | await expect( 267 | contactTools.executeTool('create_contact', { email: 'test@example.com' }) 268 | ).rejects.toThrow('Failed to create contact: Error: Network error'); 269 | }); 270 | 271 | it('should handle missing required fields', async () => { 272 | // Test with missing email (required field) 273 | await expect( 274 | contactTools.executeTool('create_contact', { firstName: 'John' }) 275 | ).rejects.toThrow(); 276 | }); 277 | }); 278 | 279 | describe('input validation', () => { 280 | it('should validate email format in schema', () => { 281 | const tools = contactTools.getToolDefinitions(); 282 | const createContactTool = tools.find(tool => tool.name === 'create_contact'); 283 | 284 | expect(createContactTool?.inputSchema.properties.email.format).toBe('email'); 285 | }); 286 | 287 | it('should validate required fields in schema', () => { 288 | const tools = contactTools.getToolDefinitions(); 289 | const createContactTool = tools.find(tool => tool.name === 'create_contact'); 290 | 291 | expect(createContactTool?.inputSchema.required).toEqual(['email']); 292 | }); 293 | }); 294 | }); -------------------------------------------------------------------------------- /tests/clients/ghl-api-client.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit Tests for GHL API Client 3 | * Tests API client configuration, connection, and error handling 4 | */ 5 | 6 | import { describe, it, expect, beforeEach, jest, afterEach } from '@jest/globals'; 7 | import { GHLApiClient } from '../../src/clients/ghl-api-client.js'; 8 | 9 | // Mock axios 10 | jest.mock('axios', () => ({ 11 | default: { 12 | create: jest.fn(() => ({ 13 | get: jest.fn(), 14 | post: jest.fn(), 15 | put: jest.fn(), 16 | delete: jest.fn(), 17 | patch: jest.fn() 18 | })) 19 | } 20 | })); 21 | 22 | import axios from 'axios'; 23 | const mockAxios = axios as jest.Mocked; 24 | 25 | describe('GHLApiClient', () => { 26 | let ghlClient: GHLApiClient; 27 | let mockAxiosInstance: any; 28 | 29 | beforeEach(() => { 30 | // Reset environment variables 31 | process.env.GHL_API_KEY = 'test_api_key_123'; 32 | process.env.GHL_BASE_URL = 'https://test.leadconnectorhq.com'; 33 | process.env.GHL_LOCATION_ID = 'test_location_123'; 34 | 35 | mockAxiosInstance = { 36 | get: jest.fn(), 37 | post: jest.fn(), 38 | put: jest.fn(), 39 | delete: jest.fn(), 40 | patch: jest.fn() 41 | }; 42 | 43 | mockAxios.create.mockReturnValue(mockAxiosInstance); 44 | 45 | ghlClient = new GHLApiClient(); 46 | }); 47 | 48 | afterEach(() => { 49 | jest.clearAllMocks(); 50 | }); 51 | 52 | describe('constructor', () => { 53 | it('should initialize with environment variables', () => { 54 | expect(mockAxios.create).toHaveBeenCalledWith({ 55 | baseURL: 'https://test.leadconnectorhq.com', 56 | headers: { 57 | 'Authorization': 'Bearer test_api_key_123', 58 | 'Content-Type': 'application/json', 59 | 'Version': '2021-07-28' 60 | } 61 | }); 62 | }); 63 | 64 | it('should throw error if API key is missing', () => { 65 | delete process.env.GHL_API_KEY; 66 | 67 | expect(() => { 68 | new GHLApiClient(); 69 | }).toThrow('GHL_API_KEY environment variable is required'); 70 | }); 71 | 72 | it('should throw error if base URL is missing', () => { 73 | delete process.env.GHL_BASE_URL; 74 | 75 | expect(() => { 76 | new GHLApiClient(); 77 | }).toThrow('GHL_BASE_URL environment variable is required'); 78 | }); 79 | 80 | it('should throw error if location ID is missing', () => { 81 | delete process.env.GHL_LOCATION_ID; 82 | 83 | expect(() => { 84 | new GHLApiClient(); 85 | }).toThrow('GHL_LOCATION_ID environment variable is required'); 86 | }); 87 | 88 | it('should use custom configuration when provided', () => { 89 | const customConfig = { 90 | accessToken: 'custom_token', 91 | baseUrl: 'https://custom.ghl.com', 92 | locationId: 'custom_location', 93 | version: '2022-01-01' 94 | }; 95 | 96 | new GHLApiClient(customConfig); 97 | 98 | expect(mockAxios.create).toHaveBeenCalledWith({ 99 | baseURL: 'https://custom.ghl.com', 100 | headers: { 101 | 'Authorization': 'Bearer custom_token', 102 | 'Content-Type': 'application/json', 103 | 'Version': '2022-01-01' 104 | } 105 | }); 106 | }); 107 | }); 108 | 109 | describe('getConfig', () => { 110 | it('should return current configuration', () => { 111 | const config = ghlClient.getConfig(); 112 | 113 | expect(config).toEqual({ 114 | accessToken: 'test_api_key_123', 115 | baseUrl: 'https://test.leadconnectorhq.com', 116 | locationId: 'test_location_123', 117 | version: '2021-07-28' 118 | }); 119 | }); 120 | }); 121 | 122 | describe('updateAccessToken', () => { 123 | it('should update access token and recreate axios instance', () => { 124 | ghlClient.updateAccessToken('new_token_456'); 125 | 126 | expect(mockAxios.create).toHaveBeenCalledWith({ 127 | baseURL: 'https://test.leadconnectorhq.com', 128 | headers: { 129 | 'Authorization': 'Bearer new_token_456', 130 | 'Content-Type': 'application/json', 131 | 'Version': '2021-07-28' 132 | } 133 | }); 134 | 135 | const config = ghlClient.getConfig(); 136 | expect(config.accessToken).toBe('new_token_456'); 137 | }); 138 | }); 139 | 140 | describe('testConnection', () => { 141 | it('should test connection successfully', async () => { 142 | mockAxiosInstance.get.mockResolvedValueOnce({ 143 | data: { success: true }, 144 | status: 200 145 | }); 146 | 147 | const result = await ghlClient.testConnection(); 148 | 149 | expect(result.success).toBe(true); 150 | expect(result.data).toEqual({ 151 | status: 'connected', 152 | locationId: 'test_location_123' 153 | }); 154 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/contacts', { 155 | params: { limit: 1 } 156 | }); 157 | }); 158 | 159 | it('should handle connection failure', async () => { 160 | mockAxiosInstance.get.mockRejectedValueOnce(new Error('Network error')); 161 | 162 | await expect(ghlClient.testConnection()).rejects.toThrow('Connection test failed'); 163 | }); 164 | }); 165 | 166 | describe('Contact API methods', () => { 167 | describe('createContact', () => { 168 | it('should create contact successfully', async () => { 169 | const contactData = { 170 | firstName: 'John', 171 | lastName: 'Doe', 172 | email: 'john@example.com' 173 | }; 174 | 175 | mockAxiosInstance.post.mockResolvedValueOnce({ 176 | data: { contact: { id: 'contact_123', ...contactData } } 177 | }); 178 | 179 | const result = await ghlClient.createContact(contactData); 180 | 181 | expect(result.success).toBe(true); 182 | expect(result.data.id).toBe('contact_123'); 183 | expect(mockAxiosInstance.post).toHaveBeenCalledWith('/contacts/', contactData); 184 | }); 185 | 186 | it('should handle create contact error', async () => { 187 | mockAxiosInstance.post.mockRejectedValueOnce({ 188 | response: { status: 400, data: { message: 'Invalid email' } } 189 | }); 190 | 191 | await expect( 192 | ghlClient.createContact({ email: 'invalid' }) 193 | ).rejects.toThrow('GHL API Error (400): Invalid email'); 194 | }); 195 | }); 196 | 197 | describe('getContact', () => { 198 | it('should get contact successfully', async () => { 199 | mockAxiosInstance.get.mockResolvedValueOnce({ 200 | data: { contact: { id: 'contact_123', name: 'John Doe' } } 201 | }); 202 | 203 | const result = await ghlClient.getContact('contact_123'); 204 | 205 | expect(result.success).toBe(true); 206 | expect(result.data.id).toBe('contact_123'); 207 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/contacts/contact_123'); 208 | }); 209 | }); 210 | 211 | describe('searchContacts', () => { 212 | it('should search contacts successfully', async () => { 213 | mockAxiosInstance.get.mockResolvedValueOnce({ 214 | data: { 215 | contacts: [{ id: 'contact_123' }], 216 | total: 1 217 | } 218 | }); 219 | 220 | const result = await ghlClient.searchContacts({ query: 'John' }); 221 | 222 | expect(result.success).toBe(true); 223 | expect(result.data.contacts).toHaveLength(1); 224 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/contacts/search/duplicate', { 225 | params: { query: 'John' } 226 | }); 227 | }); 228 | }); 229 | }); 230 | 231 | describe('Conversation API methods', () => { 232 | describe('sendSMS', () => { 233 | it('should send SMS successfully', async () => { 234 | mockAxiosInstance.post.mockResolvedValueOnce({ 235 | data: { messageId: 'msg_123', conversationId: 'conv_123' } 236 | }); 237 | 238 | const result = await ghlClient.sendSMS('contact_123', 'Hello World'); 239 | 240 | expect(result.success).toBe(true); 241 | expect(result.data.messageId).toBe('msg_123'); 242 | expect(mockAxiosInstance.post).toHaveBeenCalledWith('/conversations/messages', { 243 | type: 'SMS', 244 | contactId: 'contact_123', 245 | message: 'Hello World' 246 | }); 247 | }); 248 | 249 | it('should send SMS with custom from number', async () => { 250 | mockAxiosInstance.post.mockResolvedValueOnce({ 251 | data: { messageId: 'msg_123' } 252 | }); 253 | 254 | await ghlClient.sendSMS('contact_123', 'Hello', '+1-555-000-0000'); 255 | 256 | expect(mockAxiosInstance.post).toHaveBeenCalledWith('/conversations/messages', { 257 | type: 'SMS', 258 | contactId: 'contact_123', 259 | message: 'Hello', 260 | fromNumber: '+1-555-000-0000' 261 | }); 262 | }); 263 | }); 264 | 265 | describe('sendEmail', () => { 266 | it('should send email successfully', async () => { 267 | mockAxiosInstance.post.mockResolvedValueOnce({ 268 | data: { emailMessageId: 'email_123' } 269 | }); 270 | 271 | const result = await ghlClient.sendEmail('contact_123', 'Test Subject', 'Test body'); 272 | 273 | expect(result.success).toBe(true); 274 | expect(result.data.emailMessageId).toBe('email_123'); 275 | expect(mockAxiosInstance.post).toHaveBeenCalledWith('/conversations/messages/email', { 276 | type: 'Email', 277 | contactId: 'contact_123', 278 | subject: 'Test Subject', 279 | message: 'Test body' 280 | }); 281 | }); 282 | 283 | it('should send email with HTML and options', async () => { 284 | mockAxiosInstance.post.mockResolvedValueOnce({ 285 | data: { emailMessageId: 'email_123' } 286 | }); 287 | 288 | const options = { emailCc: ['cc@example.com'] }; 289 | await ghlClient.sendEmail('contact_123', 'Subject', 'Text', '

HTML

', options); 290 | 291 | expect(mockAxiosInstance.post).toHaveBeenCalledWith('/conversations/messages/email', { 292 | type: 'Email', 293 | contactId: 'contact_123', 294 | subject: 'Subject', 295 | message: 'Text', 296 | html: '

HTML

', 297 | emailCc: ['cc@example.com'] 298 | }); 299 | }); 300 | }); 301 | }); 302 | 303 | describe('Blog API methods', () => { 304 | describe('createBlogPost', () => { 305 | it('should create blog post successfully', async () => { 306 | mockAxiosInstance.post.mockResolvedValueOnce({ 307 | data: { data: { _id: 'post_123', title: 'Test Post' } } 308 | }); 309 | 310 | const postData = { 311 | title: 'Test Post', 312 | blogId: 'blog_123', 313 | rawHTML: '

Content

' 314 | }; 315 | 316 | const result = await ghlClient.createBlogPost(postData); 317 | 318 | expect(result.success).toBe(true); 319 | expect(result.data.data._id).toBe('post_123'); 320 | expect(mockAxiosInstance.post).toHaveBeenCalledWith('/blogs/blog_123/posts', postData); 321 | }); 322 | }); 323 | 324 | describe('getBlogSites', () => { 325 | it('should get blog sites successfully', async () => { 326 | mockAxiosInstance.get.mockResolvedValueOnce({ 327 | data: { data: [{ _id: 'blog_123', name: 'Test Blog' }] } 328 | }); 329 | 330 | const result = await ghlClient.getBlogSites({ locationId: 'loc_123' }); 331 | 332 | expect(result.success).toBe(true); 333 | expect(result.data.data).toHaveLength(1); 334 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/blogs', { 335 | params: { locationId: 'loc_123' } 336 | }); 337 | }); 338 | }); 339 | }); 340 | 341 | describe('Error handling', () => { 342 | it('should format axios error with response', async () => { 343 | const axiosError = { 344 | response: { 345 | status: 404, 346 | data: { message: 'Contact not found' } 347 | } 348 | }; 349 | 350 | mockAxiosInstance.get.mockRejectedValueOnce(axiosError); 351 | 352 | await expect( 353 | ghlClient.getContact('not_found') 354 | ).rejects.toThrow('GHL API Error (404): Contact not found'); 355 | }); 356 | 357 | it('should format axios error without response data', async () => { 358 | const axiosError = { 359 | response: { 360 | status: 500, 361 | statusText: 'Internal Server Error' 362 | } 363 | }; 364 | 365 | mockAxiosInstance.get.mockRejectedValueOnce(axiosError); 366 | 367 | await expect( 368 | ghlClient.getContact('contact_123') 369 | ).rejects.toThrow('GHL API Error (500): Internal Server Error'); 370 | }); 371 | 372 | it('should handle network errors', async () => { 373 | const networkError = new Error('Network Error'); 374 | mockAxiosInstance.get.mockRejectedValueOnce(networkError); 375 | 376 | await expect( 377 | ghlClient.getContact('contact_123') 378 | ).rejects.toThrow('GHL API Error: Network Error'); 379 | }); 380 | }); 381 | 382 | describe('Request/Response handling', () => { 383 | it('should properly format successful responses', async () => { 384 | mockAxiosInstance.get.mockResolvedValueOnce({ 385 | data: { contact: { id: 'contact_123' } }, 386 | status: 200 387 | }); 388 | 389 | const result = await ghlClient.getContact('contact_123'); 390 | 391 | expect(result).toEqual({ 392 | success: true, 393 | data: { id: 'contact_123' } 394 | }); 395 | }); 396 | 397 | it('should extract nested data correctly', async () => { 398 | mockAxiosInstance.post.mockResolvedValueOnce({ 399 | data: { 400 | data: { 401 | blogPost: { _id: 'post_123', title: 'Test' } 402 | } 403 | } 404 | }); 405 | 406 | const result = await ghlClient.createBlogPost({ 407 | title: 'Test', 408 | blogId: 'blog_123' 409 | }); 410 | 411 | expect(result.data).toEqual({ 412 | blogPost: { _id: 'post_123', title: 'Test' } 413 | }); 414 | }); 415 | }); 416 | }); -------------------------------------------------------------------------------- /src/tools/association-tools.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 3 | import { 4 | MCPCreateAssociationParams, 5 | MCPUpdateAssociationParams, 6 | MCPGetAllAssociationsParams, 7 | MCPGetAssociationByIdParams, 8 | MCPGetAssociationByKeyParams, 9 | MCPGetAssociationByObjectKeyParams, 10 | MCPDeleteAssociationParams, 11 | MCPCreateRelationParams, 12 | MCPGetRelationsByRecordParams, 13 | MCPDeleteRelationParams 14 | } from '../types/ghl-types.js'; 15 | 16 | export class AssociationTools { 17 | constructor(private apiClient: GHLApiClient) {} 18 | 19 | getTools(): Tool[] { 20 | return [ 21 | // Association Management Tools 22 | { 23 | name: 'ghl_get_all_associations', 24 | description: 'Get all associations for a sub-account/location with pagination. Returns system-defined and user-defined associations.', 25 | inputSchema: { 26 | type: 'object', 27 | properties: { 28 | locationId: { 29 | type: 'string', 30 | description: 'GoHighLevel location ID (will use default if not provided)' 31 | }, 32 | skip: { 33 | type: 'number', 34 | description: 'Number of records to skip for pagination', 35 | default: 0 36 | }, 37 | limit: { 38 | type: 'number', 39 | description: 'Maximum number of records to return (max 100)', 40 | default: 20 41 | } 42 | } 43 | } 44 | }, 45 | { 46 | name: 'ghl_create_association', 47 | description: 'Create a new association that defines relationship types between entities like contacts, custom objects, and opportunities.', 48 | inputSchema: { 49 | type: 'object', 50 | properties: { 51 | locationId: { 52 | type: 'string', 53 | description: 'GoHighLevel location ID (will use default if not provided)' 54 | }, 55 | key: { 56 | type: 'string', 57 | description: 'Unique key for the association (e.g., "student_teacher")' 58 | }, 59 | firstObjectLabel: { 60 | description: 'Label for the first object in the association (e.g., "student")' 61 | }, 62 | firstObjectKey: { 63 | description: 'Key for the first object (e.g., "custom_objects.children")' 64 | }, 65 | secondObjectLabel: { 66 | description: 'Label for the second object in the association (e.g., "teacher")' 67 | }, 68 | secondObjectKey: { 69 | description: 'Key for the second object (e.g., "contact")' 70 | } 71 | }, 72 | required: ['key', 'firstObjectLabel', 'firstObjectKey', 'secondObjectLabel', 'secondObjectKey'] 73 | } 74 | }, 75 | { 76 | name: 'ghl_get_association_by_id', 77 | description: 'Get a specific association by its ID. Works for both system-defined and user-defined associations.', 78 | inputSchema: { 79 | type: 'object', 80 | properties: { 81 | associationId: { 82 | type: 'string', 83 | description: 'The ID of the association to retrieve' 84 | } 85 | }, 86 | required: ['associationId'] 87 | } 88 | }, 89 | { 90 | name: 'ghl_update_association', 91 | description: 'Update the labels of an existing association. Only user-defined associations can be updated.', 92 | inputSchema: { 93 | type: 'object', 94 | properties: { 95 | associationId: { 96 | type: 'string', 97 | description: 'The ID of the association to update' 98 | }, 99 | firstObjectLabel: { 100 | description: 'New label for the first object in the association' 101 | }, 102 | secondObjectLabel: { 103 | description: 'New label for the second object in the association' 104 | } 105 | }, 106 | required: ['associationId', 'firstObjectLabel', 'secondObjectLabel'] 107 | } 108 | }, 109 | { 110 | name: 'ghl_delete_association', 111 | description: 'Delete a user-defined association. This will also delete all relations created with this association.', 112 | inputSchema: { 113 | type: 'object', 114 | properties: { 115 | associationId: { 116 | type: 'string', 117 | description: 'The ID of the association to delete' 118 | } 119 | }, 120 | required: ['associationId'] 121 | } 122 | }, 123 | { 124 | name: 'ghl_get_association_by_key', 125 | description: 'Get an association by its key name. Useful for finding both standard and user-defined associations.', 126 | inputSchema: { 127 | type: 'object', 128 | properties: { 129 | keyName: { 130 | type: 'string', 131 | description: 'The key name of the association to retrieve' 132 | }, 133 | locationId: { 134 | type: 'string', 135 | description: 'GoHighLevel location ID (will use default if not provided)' 136 | } 137 | }, 138 | required: ['keyName'] 139 | } 140 | }, 141 | { 142 | name: 'ghl_get_association_by_object_key', 143 | description: 'Get associations by object keys like contacts, custom objects, and opportunities.', 144 | inputSchema: { 145 | type: 'object', 146 | properties: { 147 | objectKey: { 148 | type: 'string', 149 | description: 'The object key to search for (e.g., "custom_objects.car", "contact", "opportunity")' 150 | }, 151 | locationId: { 152 | type: 'string', 153 | description: 'GoHighLevel location ID (optional)' 154 | } 155 | }, 156 | required: ['objectKey'] 157 | } 158 | }, 159 | // Relation Management Tools 160 | { 161 | name: 'ghl_create_relation', 162 | description: 'Create a relation between two entities using an existing association. Links specific records together.', 163 | inputSchema: { 164 | type: 'object', 165 | properties: { 166 | locationId: { 167 | type: 'string', 168 | description: 'GoHighLevel location ID (will use default if not provided)' 169 | }, 170 | associationId: { 171 | type: 'string', 172 | description: 'The ID of the association to use for this relation' 173 | }, 174 | firstRecordId: { 175 | type: 'string', 176 | description: 'ID of the first record (e.g., contact ID if contact is first object in association)' 177 | }, 178 | secondRecordId: { 179 | type: 'string', 180 | description: 'ID of the second record (e.g., custom object record ID if custom object is second object)' 181 | } 182 | }, 183 | required: ['associationId', 'firstRecordId', 'secondRecordId'] 184 | } 185 | }, 186 | { 187 | name: 'ghl_get_relations_by_record', 188 | description: 'Get all relations for a specific record ID with pagination and optional filtering by association IDs.', 189 | inputSchema: { 190 | type: 'object', 191 | properties: { 192 | recordId: { 193 | type: 'string', 194 | description: 'The record ID to get relations for' 195 | }, 196 | locationId: { 197 | type: 'string', 198 | description: 'GoHighLevel location ID (will use default if not provided)' 199 | }, 200 | skip: { 201 | type: 'number', 202 | description: 'Number of records to skip for pagination', 203 | default: 0 204 | }, 205 | limit: { 206 | type: 'number', 207 | description: 'Maximum number of records to return', 208 | default: 20 209 | }, 210 | associationIds: { 211 | type: 'array', 212 | items: { 213 | type: 'string' 214 | }, 215 | description: 'Optional array of association IDs to filter relations' 216 | } 217 | }, 218 | required: ['recordId'] 219 | } 220 | }, 221 | { 222 | name: 'ghl_delete_relation', 223 | description: 'Delete a specific relation between two entities.', 224 | inputSchema: { 225 | type: 'object', 226 | properties: { 227 | relationId: { 228 | type: 'string', 229 | description: 'The ID of the relation to delete' 230 | }, 231 | locationId: { 232 | type: 'string', 233 | description: 'GoHighLevel location ID (will use default if not provided)' 234 | } 235 | }, 236 | required: ['relationId'] 237 | } 238 | } 239 | ]; 240 | } 241 | 242 | async executeAssociationTool(name: string, args: any): Promise { 243 | try { 244 | switch (name) { 245 | case 'ghl_get_all_associations': { 246 | const params: MCPGetAllAssociationsParams = args; 247 | const result = await this.apiClient.getAssociations({ 248 | locationId: params.locationId || '', 249 | skip: params.skip || 0, 250 | limit: params.limit || 20 251 | }); 252 | return { 253 | success: true, 254 | data: result.data, 255 | message: `Retrieved ${result.data?.associations?.length || 0} associations` 256 | }; 257 | } 258 | 259 | case 'ghl_create_association': { 260 | const params: MCPCreateAssociationParams = args; 261 | const result = await this.apiClient.createAssociation({ 262 | locationId: params.locationId || '', 263 | key: params.key, 264 | firstObjectLabel: params.firstObjectLabel, 265 | firstObjectKey: params.firstObjectKey, 266 | secondObjectLabel: params.secondObjectLabel, 267 | secondObjectKey: params.secondObjectKey 268 | }); 269 | return { 270 | success: true, 271 | data: result.data, 272 | message: `Association '${params.key}' created successfully` 273 | }; 274 | } 275 | 276 | case 'ghl_get_association_by_id': { 277 | const params: MCPGetAssociationByIdParams = args; 278 | const result = await this.apiClient.getAssociationById(params.associationId); 279 | return { 280 | success: true, 281 | data: result.data, 282 | message: `Association retrieved successfully` 283 | }; 284 | } 285 | 286 | case 'ghl_update_association': { 287 | const params: MCPUpdateAssociationParams = args; 288 | const result = await this.apiClient.updateAssociation(params.associationId, { 289 | firstObjectLabel: params.firstObjectLabel, 290 | secondObjectLabel: params.secondObjectLabel 291 | }); 292 | return { 293 | success: true, 294 | data: result.data, 295 | message: `Association updated successfully` 296 | }; 297 | } 298 | 299 | case 'ghl_delete_association': { 300 | const params: MCPDeleteAssociationParams = args; 301 | const result = await this.apiClient.deleteAssociation(params.associationId); 302 | return { 303 | success: true, 304 | data: result.data, 305 | message: `Association deleted successfully` 306 | }; 307 | } 308 | 309 | case 'ghl_get_association_by_key': { 310 | const params: MCPGetAssociationByKeyParams = args; 311 | const result = await this.apiClient.getAssociationByKey({ 312 | keyName: params.keyName, 313 | locationId: params.locationId || '' 314 | }); 315 | return { 316 | success: true, 317 | data: result.data, 318 | message: `Association with key '${params.keyName}' retrieved successfully` 319 | }; 320 | } 321 | 322 | case 'ghl_get_association_by_object_key': { 323 | const params: MCPGetAssociationByObjectKeyParams = args; 324 | const result = await this.apiClient.getAssociationByObjectKey({ 325 | objectKey: params.objectKey, 326 | locationId: params.locationId 327 | }); 328 | return { 329 | success: true, 330 | data: result.data, 331 | message: `Association with object key '${params.objectKey}' retrieved successfully` 332 | }; 333 | } 334 | 335 | case 'ghl_create_relation': { 336 | const params: MCPCreateRelationParams = args; 337 | const result = await this.apiClient.createRelation({ 338 | locationId: params.locationId || '', 339 | associationId: params.associationId, 340 | firstRecordId: params.firstRecordId, 341 | secondRecordId: params.secondRecordId 342 | }); 343 | return { 344 | success: true, 345 | data: result.data, 346 | message: `Relation created successfully between records` 347 | }; 348 | } 349 | 350 | case 'ghl_get_relations_by_record': { 351 | const params: MCPGetRelationsByRecordParams = args; 352 | const result = await this.apiClient.getRelationsByRecord({ 353 | recordId: params.recordId, 354 | locationId: params.locationId || '', 355 | skip: params.skip || 0, 356 | limit: params.limit || 20, 357 | associationIds: params.associationIds 358 | }); 359 | return { 360 | success: true, 361 | data: result.data, 362 | message: `Retrieved ${result.data?.relations?.length || 0} relations for record` 363 | }; 364 | } 365 | 366 | case 'ghl_delete_relation': { 367 | const params: MCPDeleteRelationParams = args; 368 | const result = await this.apiClient.deleteRelation({ 369 | relationId: params.relationId, 370 | locationId: params.locationId || '' 371 | }); 372 | return { 373 | success: true, 374 | data: result.data, 375 | message: `Relation deleted successfully` 376 | }; 377 | } 378 | 379 | default: 380 | throw new Error(`Unknown association tool: ${name}`); 381 | } 382 | } catch (error) { 383 | return { 384 | success: false, 385 | error: error instanceof Error ? error.message : String(error), 386 | message: `Failed to execute ${name}` 387 | }; 388 | } 389 | } 390 | } -------------------------------------------------------------------------------- /tests/tools/conversation-tools.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit Tests for Conversation Tools 3 | * Tests all 7 messaging and conversation MCP tools 4 | */ 5 | 6 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 7 | import { ConversationTools } from '../../src/tools/conversation-tools.js'; 8 | import { MockGHLApiClient, mockConversation, mockMessage } from '../mocks/ghl-api-client.mock.js'; 9 | 10 | describe('ConversationTools', () => { 11 | let conversationTools: ConversationTools; 12 | let mockGhlClient: MockGHLApiClient; 13 | 14 | beforeEach(() => { 15 | mockGhlClient = new MockGHLApiClient(); 16 | conversationTools = new ConversationTools(mockGhlClient as any); 17 | }); 18 | 19 | describe('getToolDefinitions', () => { 20 | it('should return 7 conversation tool definitions', () => { 21 | const tools = conversationTools.getToolDefinitions(); 22 | expect(tools).toHaveLength(7); 23 | 24 | const toolNames = tools.map(tool => tool.name); 25 | expect(toolNames).toEqual([ 26 | 'send_sms', 27 | 'send_email', 28 | 'search_conversations', 29 | 'get_conversation', 30 | 'create_conversation', 31 | 'update_conversation', 32 | 'get_recent_messages' 33 | ]); 34 | }); 35 | 36 | it('should have proper schema definitions for all tools', () => { 37 | const tools = conversationTools.getToolDefinitions(); 38 | 39 | tools.forEach(tool => { 40 | expect(tool.name).toBeDefined(); 41 | expect(tool.description).toBeDefined(); 42 | expect(tool.inputSchema).toBeDefined(); 43 | expect(tool.inputSchema.type).toBe('object'); 44 | expect(tool.inputSchema.properties).toBeDefined(); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('executeTool', () => { 50 | it('should route tool calls correctly', async () => { 51 | const sendSmsSpy = jest.spyOn(conversationTools as any, 'sendSMS'); 52 | const sendEmailSpy = jest.spyOn(conversationTools as any, 'sendEmail'); 53 | 54 | await conversationTools.executeTool('send_sms', { 55 | contactId: 'contact_123', 56 | message: 'Test SMS' 57 | }); 58 | await conversationTools.executeTool('send_email', { 59 | contactId: 'contact_123', 60 | subject: 'Test Email' 61 | }); 62 | 63 | expect(sendSmsSpy).toHaveBeenCalledWith({ 64 | contactId: 'contact_123', 65 | message: 'Test SMS' 66 | }); 67 | expect(sendEmailSpy).toHaveBeenCalledWith({ 68 | contactId: 'contact_123', 69 | subject: 'Test Email' 70 | }); 71 | }); 72 | 73 | it('should throw error for unknown tool', async () => { 74 | await expect( 75 | conversationTools.executeTool('unknown_tool', {}) 76 | ).rejects.toThrow('Unknown tool: unknown_tool'); 77 | }); 78 | }); 79 | 80 | describe('send_sms', () => { 81 | it('should send SMS successfully', async () => { 82 | const smsData = { 83 | contactId: 'contact_123', 84 | message: 'Hello from ChatGPT!' 85 | }; 86 | 87 | const result = await conversationTools.executeTool('send_sms', smsData); 88 | 89 | expect(result.success).toBe(true); 90 | expect(result.messageId).toBeDefined(); 91 | expect(result.conversationId).toBeDefined(); 92 | expect(result.message).toContain('SMS sent successfully'); 93 | }); 94 | 95 | it('should send SMS with custom from number', async () => { 96 | const spy = jest.spyOn(mockGhlClient, 'sendSMS'); 97 | 98 | await conversationTools.executeTool('send_sms', { 99 | contactId: 'contact_123', 100 | message: 'Test message', 101 | fromNumber: '+1-555-000-0000' 102 | }); 103 | 104 | expect(spy).toHaveBeenCalledWith('contact_123', 'Test message', '+1-555-000-0000'); 105 | }); 106 | 107 | it('should handle SMS sending errors', async () => { 108 | const mockError = new Error('GHL API Error (400): Invalid phone number'); 109 | jest.spyOn(mockGhlClient, 'sendSMS').mockRejectedValueOnce(mockError); 110 | 111 | await expect( 112 | conversationTools.executeTool('send_sms', { 113 | contactId: 'contact_123', 114 | message: 'Test message' 115 | }) 116 | ).rejects.toThrow('Failed to send SMS'); 117 | }); 118 | }); 119 | 120 | describe('send_email', () => { 121 | it('should send email successfully', async () => { 122 | const emailData = { 123 | contactId: 'contact_123', 124 | subject: 'Test Email', 125 | message: 'This is a test email' 126 | }; 127 | 128 | const result = await conversationTools.executeTool('send_email', emailData); 129 | 130 | expect(result.success).toBe(true); 131 | expect(result.messageId).toBeDefined(); 132 | expect(result.conversationId).toBeDefined(); 133 | expect(result.emailMessageId).toBeDefined(); 134 | expect(result.message).toContain('Email sent successfully'); 135 | }); 136 | 137 | it('should send email with HTML content', async () => { 138 | const spy = jest.spyOn(mockGhlClient, 'sendEmail'); 139 | 140 | await conversationTools.executeTool('send_email', { 141 | contactId: 'contact_123', 142 | subject: 'HTML Email', 143 | html: '

Hello World

' 144 | }); 145 | 146 | expect(spy).toHaveBeenCalledWith( 147 | 'contact_123', 148 | 'HTML Email', 149 | undefined, 150 | '

Hello World

', 151 | {} 152 | ); 153 | }); 154 | 155 | it('should send email with CC and BCC', async () => { 156 | const spy = jest.spyOn(mockGhlClient, 'sendEmail'); 157 | 158 | await conversationTools.executeTool('send_email', { 159 | contactId: 'contact_123', 160 | subject: 'Test Subject', 161 | message: 'Test message', 162 | emailCc: ['cc@example.com'], 163 | emailBcc: ['bcc@example.com'] 164 | }); 165 | 166 | expect(spy).toHaveBeenCalledWith( 167 | 'contact_123', 168 | 'Test Subject', 169 | 'Test message', 170 | undefined, 171 | expect.objectContaining({ 172 | emailCc: ['cc@example.com'], 173 | emailBcc: ['bcc@example.com'] 174 | }) 175 | ); 176 | }); 177 | 178 | it('should handle email sending errors', async () => { 179 | const mockError = new Error('GHL API Error (400): Invalid email address'); 180 | jest.spyOn(mockGhlClient, 'sendEmail').mockRejectedValueOnce(mockError); 181 | 182 | await expect( 183 | conversationTools.executeTool('send_email', { 184 | contactId: 'contact_123', 185 | subject: 'Test Subject' 186 | }) 187 | ).rejects.toThrow('Failed to send email'); 188 | }); 189 | }); 190 | 191 | describe('search_conversations', () => { 192 | it('should search conversations successfully', async () => { 193 | const searchParams = { 194 | contactId: 'contact_123', 195 | limit: 10 196 | }; 197 | 198 | const result = await conversationTools.executeTool('search_conversations', searchParams); 199 | 200 | expect(result.success).toBe(true); 201 | expect(result.conversations).toBeDefined(); 202 | expect(Array.isArray(result.conversations)).toBe(true); 203 | expect(result.total).toBeDefined(); 204 | expect(result.message).toContain('Found'); 205 | }); 206 | 207 | it('should use default limit and status', async () => { 208 | const spy = jest.spyOn(mockGhlClient, 'searchConversations'); 209 | 210 | await conversationTools.executeTool('search_conversations', {}); 211 | 212 | expect(spy).toHaveBeenCalledWith( 213 | expect.objectContaining({ 214 | status: 'all', 215 | limit: 20 216 | }) 217 | ); 218 | }); 219 | 220 | it('should handle search with filters', async () => { 221 | const result = await conversationTools.executeTool('search_conversations', { 222 | query: 'test query', 223 | status: 'unread', 224 | assignedTo: 'user_123' 225 | }); 226 | 227 | expect(result.success).toBe(true); 228 | expect(result.conversations).toBeDefined(); 229 | }); 230 | }); 231 | 232 | describe('get_conversation', () => { 233 | it('should get conversation with messages successfully', async () => { 234 | const result = await conversationTools.executeTool('get_conversation', { 235 | conversationId: 'conv_123' 236 | }); 237 | 238 | expect(result.success).toBe(true); 239 | expect(result.conversation).toBeDefined(); 240 | expect(result.messages).toBeDefined(); 241 | expect(Array.isArray(result.messages)).toBe(true); 242 | expect(result.hasMoreMessages).toBeDefined(); 243 | }); 244 | 245 | it('should use default message limit', async () => { 246 | const spy = jest.spyOn(mockGhlClient, 'getConversationMessages'); 247 | 248 | await conversationTools.executeTool('get_conversation', { 249 | conversationId: 'conv_123' 250 | }); 251 | 252 | expect(spy).toHaveBeenCalledWith('conv_123', { limit: 20 }); 253 | }); 254 | 255 | it('should filter by message types', async () => { 256 | const spy = jest.spyOn(mockGhlClient, 'getConversationMessages'); 257 | 258 | await conversationTools.executeTool('get_conversation', { 259 | conversationId: 'conv_123', 260 | messageTypes: ['TYPE_SMS', 'TYPE_EMAIL'] 261 | }); 262 | 263 | expect(spy).toHaveBeenCalledWith('conv_123', { 264 | limit: 20, 265 | type: 'TYPE_SMS,TYPE_EMAIL' 266 | }); 267 | }); 268 | }); 269 | 270 | describe('create_conversation', () => { 271 | it('should create conversation successfully', async () => { 272 | const result = await conversationTools.executeTool('create_conversation', { 273 | contactId: 'contact_123' 274 | }); 275 | 276 | expect(result.success).toBe(true); 277 | expect(result.conversationId).toBeDefined(); 278 | expect(result.message).toContain('Conversation created successfully'); 279 | }); 280 | 281 | it('should include location ID in request', async () => { 282 | const spy = jest.spyOn(mockGhlClient, 'createConversation'); 283 | 284 | await conversationTools.executeTool('create_conversation', { 285 | contactId: 'contact_123' 286 | }); 287 | 288 | expect(spy).toHaveBeenCalledWith({ 289 | locationId: 'test_location_123', 290 | contactId: 'contact_123' 291 | }); 292 | }); 293 | }); 294 | 295 | describe('update_conversation', () => { 296 | it('should update conversation successfully', async () => { 297 | const result = await conversationTools.executeTool('update_conversation', { 298 | conversationId: 'conv_123', 299 | starred: true, 300 | unreadCount: 0 301 | }); 302 | 303 | expect(result.success).toBe(true); 304 | expect(result.conversation).toBeDefined(); 305 | expect(result.message).toBe('Conversation updated successfully'); 306 | }); 307 | 308 | it('should handle partial updates', async () => { 309 | const spy = jest.spyOn(mockGhlClient, 'updateConversation'); 310 | 311 | await conversationTools.executeTool('update_conversation', { 312 | conversationId: 'conv_123', 313 | starred: true 314 | }); 315 | 316 | expect(spy).toHaveBeenCalledWith('conv_123', { 317 | locationId: 'test_location_123', 318 | starred: true, 319 | unreadCount: undefined 320 | }); 321 | }); 322 | }); 323 | 324 | describe('get_recent_messages', () => { 325 | it('should get recent messages successfully', async () => { 326 | const result = await conversationTools.executeTool('get_recent_messages', {}); 327 | 328 | expect(result.success).toBe(true); 329 | expect(result.conversations).toBeDefined(); 330 | expect(Array.isArray(result.conversations)).toBe(true); 331 | expect(result.message).toContain('Retrieved'); 332 | }); 333 | 334 | it('should use default parameters', async () => { 335 | const spy = jest.spyOn(mockGhlClient, 'searchConversations'); 336 | 337 | await conversationTools.executeTool('get_recent_messages', {}); 338 | 339 | expect(spy).toHaveBeenCalledWith( 340 | expect.objectContaining({ 341 | limit: 10, 342 | status: 'unread', 343 | sortBy: 'last_message_date', 344 | sort: 'desc' 345 | }) 346 | ); 347 | }); 348 | 349 | it('should handle custom parameters', async () => { 350 | const result = await conversationTools.executeTool('get_recent_messages', { 351 | limit: 5, 352 | status: 'all' 353 | }); 354 | 355 | expect(result.success).toBe(true); 356 | expect(result.conversations).toBeDefined(); 357 | }); 358 | 359 | it('should format conversation data correctly', async () => { 360 | const result = await conversationTools.executeTool('get_recent_messages', {}); 361 | 362 | expect(result.conversations[0]).toEqual( 363 | expect.objectContaining({ 364 | conversationId: expect.any(String), 365 | contactName: expect.any(String), 366 | lastMessageBody: expect.any(String), 367 | unreadCount: expect.any(Number) 368 | }) 369 | ); 370 | }); 371 | }); 372 | 373 | describe('error handling', () => { 374 | it('should propagate API client errors', async () => { 375 | const mockError = new Error('Network timeout'); 376 | jest.spyOn(mockGhlClient, 'sendSMS').mockRejectedValueOnce(mockError); 377 | 378 | await expect( 379 | conversationTools.executeTool('send_sms', { 380 | contactId: 'contact_123', 381 | message: 'test' 382 | }) 383 | ).rejects.toThrow('Failed to send SMS: Error: Network timeout'); 384 | }); 385 | 386 | it('should handle conversation not found', async () => { 387 | const mockError = new Error('GHL API Error (404): Conversation not found'); 388 | jest.spyOn(mockGhlClient, 'getConversation').mockRejectedValueOnce(mockError); 389 | 390 | await expect( 391 | conversationTools.executeTool('get_conversation', { 392 | conversationId: 'not_found' 393 | }) 394 | ).rejects.toThrow('Failed to get conversation'); 395 | }); 396 | }); 397 | 398 | describe('input validation', () => { 399 | it('should validate SMS message length', () => { 400 | const tools = conversationTools.getToolDefinitions(); 401 | const sendSmsTool = tools.find(tool => tool.name === 'send_sms'); 402 | 403 | expect(sendSmsTool?.inputSchema.properties.message.maxLength).toBe(1600); 404 | }); 405 | 406 | it('should validate required fields', () => { 407 | const tools = conversationTools.getToolDefinitions(); 408 | const sendSmsTool = tools.find(tool => tool.name === 'send_sms'); 409 | const sendEmailTool = tools.find(tool => tool.name === 'send_email'); 410 | 411 | expect(sendSmsTool?.inputSchema.required).toEqual(['contactId', 'message']); 412 | expect(sendEmailTool?.inputSchema.required).toEqual(['contactId', 'subject']); 413 | }); 414 | 415 | it('should validate email format', () => { 416 | const tools = conversationTools.getToolDefinitions(); 417 | const sendEmailTool = tools.find(tool => tool.name === 'send_email'); 418 | 419 | expect(sendEmailTool?.inputSchema.properties.emailFrom.format).toBe('email'); 420 | }); 421 | }); 422 | }); -------------------------------------------------------------------------------- /src/tools/invoices-tools.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 3 | import { 4 | // Invoice Template Types 5 | CreateInvoiceTemplateDto, 6 | CreateInvoiceTemplateResponseDto, 7 | UpdateInvoiceTemplateDto, 8 | UpdateInvoiceTemplateResponseDto, 9 | DeleteInvoiceTemplateResponseDto, 10 | ListTemplatesResponse, 11 | InvoiceTemplate, 12 | UpdateInvoiceLateFeesConfigurationDto, 13 | UpdatePaymentMethodsConfigurationDto, 14 | 15 | // Invoice Schedule Types 16 | CreateInvoiceScheduleDto, 17 | CreateInvoiceScheduleResponseDto, 18 | UpdateInvoiceScheduleDto, 19 | UpdateInvoiceScheduleResponseDto, 20 | DeleteInvoiceScheduleResponseDto, 21 | ListSchedulesResponse, 22 | GetScheduleResponseDto, 23 | ScheduleInvoiceScheduleDto, 24 | ScheduleInvoiceScheduleResponseDto, 25 | AutoPaymentScheduleDto, 26 | AutoPaymentInvoiceScheduleResponseDto, 27 | CancelInvoiceScheduleDto, 28 | CancelInvoiceScheduleResponseDto, 29 | UpdateAndScheduleInvoiceScheduleResponseDto, 30 | 31 | // Invoice Types 32 | CreateInvoiceDto, 33 | CreateInvoiceResponseDto, 34 | UpdateInvoiceDto, 35 | UpdateInvoiceResponseDto, 36 | DeleteInvoiceResponseDto, 37 | GetInvoiceResponseDto, 38 | ListInvoicesResponseDto, 39 | VoidInvoiceDto, 40 | VoidInvoiceResponseDto, 41 | SendInvoiceDto, 42 | SendInvoicesResponseDto, 43 | RecordPaymentDto, 44 | RecordPaymentResponseDto, 45 | Text2PayDto, 46 | Text2PayInvoiceResponseDto, 47 | GenerateInvoiceNumberResponse, 48 | PatchInvoiceStatsLastViewedDto, 49 | 50 | // Estimate Types 51 | CreateEstimatesDto, 52 | EstimateResponseDto, 53 | UpdateEstimateDto, 54 | SendEstimateDto, 55 | CreateInvoiceFromEstimateDto, 56 | CreateInvoiceFromEstimateResponseDto, 57 | ListEstimatesResponseDto, 58 | EstimateIdParam, 59 | GenerateEstimateNumberResponse, 60 | EstimateTemplatesDto, 61 | EstimateTemplateResponseDto, 62 | ListEstimateTemplateResponseDto, 63 | AltDto 64 | } from '../types/ghl-types.js'; 65 | 66 | export class InvoicesTools { 67 | private client: GHLApiClient; 68 | 69 | constructor(client: GHLApiClient) { 70 | this.client = client; 71 | } 72 | 73 | getTools(): Tool[] { 74 | return [ 75 | // Invoice Template Tools 76 | { 77 | name: 'create_invoice_template', 78 | description: 'Create a new invoice template', 79 | inputSchema: { 80 | type: 'object', 81 | properties: { 82 | altId: { type: 'string', description: 'Location ID' }, 83 | altType: { type: 'string', enum: ['location'], default: 'location' }, 84 | name: { type: 'string', description: 'Template name' }, 85 | title: { type: 'string', description: 'Invoice title' }, 86 | currency: { type: 'string', description: 'Currency code' }, 87 | issueDate: { type: 'string', description: 'Issue date' }, 88 | dueDate: { type: 'string', description: 'Due date' } 89 | }, 90 | required: ['name'] 91 | } 92 | }, 93 | { 94 | name: 'list_invoice_templates', 95 | description: 'List all invoice templates', 96 | inputSchema: { 97 | type: 'object', 98 | properties: { 99 | altId: { type: 'string', description: 'Location ID' }, 100 | limit: { type: 'string', description: 'Number of results per page', default: '10' }, 101 | offset: { type: 'string', description: 'Offset for pagination', default: '0' }, 102 | status: { type: 'string', description: 'Filter by status' }, 103 | search: { type: 'string', description: 'Search term' }, 104 | paymentMode: { type: 'string', enum: ['default', 'live', 'test'], description: 'Payment mode' } 105 | }, 106 | required: ['limit', 'offset'] 107 | } 108 | }, 109 | { 110 | name: 'get_invoice_template', 111 | description: 'Get invoice template by ID', 112 | inputSchema: { 113 | type: 'object', 114 | properties: { 115 | templateId: { type: 'string', description: 'Template ID' }, 116 | altId: { type: 'string', description: 'Location ID' } 117 | }, 118 | required: ['templateId'] 119 | } 120 | }, 121 | { 122 | name: 'update_invoice_template', 123 | description: 'Update an existing invoice template', 124 | inputSchema: { 125 | type: 'object', 126 | properties: { 127 | templateId: { type: 'string', description: 'Template ID' }, 128 | altId: { type: 'string', description: 'Location ID' }, 129 | name: { type: 'string', description: 'Template name' }, 130 | title: { type: 'string', description: 'Invoice title' }, 131 | currency: { type: 'string', description: 'Currency code' } 132 | }, 133 | required: ['templateId'] 134 | } 135 | }, 136 | { 137 | name: 'delete_invoice_template', 138 | description: 'Delete an invoice template', 139 | inputSchema: { 140 | type: 'object', 141 | properties: { 142 | templateId: { type: 'string', description: 'Template ID' }, 143 | altId: { type: 'string', description: 'Location ID' } 144 | }, 145 | required: ['templateId'] 146 | } 147 | }, 148 | 149 | // Invoice Schedule Tools 150 | { 151 | name: 'create_invoice_schedule', 152 | description: 'Create a new invoice schedule', 153 | inputSchema: { 154 | type: 'object', 155 | properties: { 156 | altId: { type: 'string', description: 'Location ID' }, 157 | name: { type: 'string', description: 'Schedule name' }, 158 | templateId: { type: 'string', description: 'Template ID' }, 159 | contactId: { type: 'string', description: 'Contact ID' }, 160 | frequency: { type: 'string', description: 'Schedule frequency' } 161 | }, 162 | required: ['name', 'templateId', 'contactId'] 163 | } 164 | }, 165 | { 166 | name: 'list_invoice_schedules', 167 | description: 'List all invoice schedules', 168 | inputSchema: { 169 | type: 'object', 170 | properties: { 171 | altId: { type: 'string', description: 'Location ID' }, 172 | limit: { type: 'string', description: 'Number of results per page', default: '10' }, 173 | offset: { type: 'string', description: 'Offset for pagination', default: '0' }, 174 | status: { type: 'string', description: 'Filter by status' }, 175 | search: { type: 'string', description: 'Search term' } 176 | }, 177 | required: ['limit', 'offset'] 178 | } 179 | }, 180 | { 181 | name: 'get_invoice_schedule', 182 | description: 'Get invoice schedule by ID', 183 | inputSchema: { 184 | type: 'object', 185 | properties: { 186 | scheduleId: { type: 'string', description: 'Schedule ID' }, 187 | altId: { type: 'string', description: 'Location ID' } 188 | }, 189 | required: ['scheduleId'] 190 | } 191 | }, 192 | 193 | // Invoice Management Tools 194 | { 195 | name: 'create_invoice', 196 | description: 'Create a new invoice', 197 | inputSchema: { 198 | type: 'object', 199 | properties: { 200 | altId: { type: 'string', description: 'Location ID' }, 201 | contactId: { type: 'string', description: 'Contact ID' }, 202 | title: { type: 'string', description: 'Invoice title' }, 203 | currency: { type: 'string', description: 'Currency code' }, 204 | issueDate: { type: 'string', description: 'Issue date' }, 205 | dueDate: { type: 'string', description: 'Due date' }, 206 | items: { type: 'array', description: 'Invoice items' } 207 | }, 208 | required: ['contactId', 'title'] 209 | } 210 | }, 211 | { 212 | name: 'list_invoices', 213 | description: 'List all invoices', 214 | inputSchema: { 215 | type: 'object', 216 | properties: { 217 | altId: { type: 'string', description: 'Location ID' }, 218 | limit: { type: 'string', description: 'Number of results per page', default: '10' }, 219 | offset: { type: 'string', description: 'Offset for pagination', default: '0' }, 220 | status: { type: 'string', description: 'Filter by status' }, 221 | contactId: { type: 'string', description: 'Filter by contact ID' }, 222 | search: { type: 'string', description: 'Search term' } 223 | }, 224 | required: ['limit', 'offset'] 225 | } 226 | }, 227 | { 228 | name: 'get_invoice', 229 | description: 'Get invoice by ID', 230 | inputSchema: { 231 | type: 'object', 232 | properties: { 233 | invoiceId: { type: 'string', description: 'Invoice ID' }, 234 | altId: { type: 'string', description: 'Location ID' } 235 | }, 236 | required: ['invoiceId'] 237 | } 238 | }, 239 | { 240 | name: 'send_invoice', 241 | description: 'Send an invoice to customer', 242 | inputSchema: { 243 | type: 'object', 244 | properties: { 245 | invoiceId: { type: 'string', description: 'Invoice ID' }, 246 | altId: { type: 'string', description: 'Location ID' }, 247 | emailTo: { type: 'string', description: 'Email address to send to' }, 248 | subject: { type: 'string', description: 'Email subject' }, 249 | message: { type: 'string', description: 'Email message' } 250 | }, 251 | required: ['invoiceId'] 252 | } 253 | }, 254 | 255 | // Estimate Tools 256 | { 257 | name: 'create_estimate', 258 | description: 'Create a new estimate', 259 | inputSchema: { 260 | type: 'object', 261 | properties: { 262 | altId: { type: 'string', description: 'Location ID' }, 263 | contactId: { type: 'string', description: 'Contact ID' }, 264 | title: { type: 'string', description: 'Estimate title' }, 265 | currency: { type: 'string', description: 'Currency code' }, 266 | issueDate: { type: 'string', description: 'Issue date' }, 267 | validUntil: { type: 'string', description: 'Valid until date' } 268 | }, 269 | required: ['contactId', 'title'] 270 | } 271 | }, 272 | { 273 | name: 'list_estimates', 274 | description: 'List all estimates', 275 | inputSchema: { 276 | type: 'object', 277 | properties: { 278 | altId: { type: 'string', description: 'Location ID' }, 279 | limit: { type: 'string', description: 'Number of results per page', default: '10' }, 280 | offset: { type: 'string', description: 'Offset for pagination', default: '0' }, 281 | status: { type: 'string', enum: ['all', 'draft', 'sent', 'accepted', 'declined', 'invoiced', 'viewed'], description: 'Filter by status' }, 282 | contactId: { type: 'string', description: 'Filter by contact ID' }, 283 | search: { type: 'string', description: 'Search term' } 284 | }, 285 | required: ['limit', 'offset'] 286 | } 287 | }, 288 | { 289 | name: 'send_estimate', 290 | description: 'Send an estimate to customer', 291 | inputSchema: { 292 | type: 'object', 293 | properties: { 294 | estimateId: { type: 'string', description: 'Estimate ID' }, 295 | altId: { type: 'string', description: 'Location ID' }, 296 | emailTo: { type: 'string', description: 'Email address to send to' }, 297 | subject: { type: 'string', description: 'Email subject' }, 298 | message: { type: 'string', description: 'Email message' } 299 | }, 300 | required: ['estimateId'] 301 | } 302 | }, 303 | { 304 | name: 'create_invoice_from_estimate', 305 | description: 'Create an invoice from an estimate', 306 | inputSchema: { 307 | type: 'object', 308 | properties: { 309 | estimateId: { type: 'string', description: 'Estimate ID' }, 310 | altId: { type: 'string', description: 'Location ID' }, 311 | issueDate: { type: 'string', description: 'Invoice issue date' }, 312 | dueDate: { type: 'string', description: 'Invoice due date' } 313 | }, 314 | required: ['estimateId'] 315 | } 316 | }, 317 | 318 | // Utility Tools 319 | { 320 | name: 'generate_invoice_number', 321 | description: 'Generate a unique invoice number', 322 | inputSchema: { 323 | type: 'object', 324 | properties: { 325 | altId: { type: 'string', description: 'Location ID' } 326 | } 327 | } 328 | }, 329 | { 330 | name: 'generate_estimate_number', 331 | description: 'Generate a unique estimate number', 332 | inputSchema: { 333 | type: 'object', 334 | properties: { 335 | altId: { type: 'string', description: 'Location ID' } 336 | } 337 | } 338 | } 339 | ]; 340 | } 341 | 342 | async handleToolCall(name: string, args: any): Promise { 343 | switch (name) { 344 | // Invoice Template Handlers 345 | case 'create_invoice_template': 346 | return this.client.createInvoiceTemplate(args as CreateInvoiceTemplateDto); 347 | 348 | case 'list_invoice_templates': 349 | return this.client.listInvoiceTemplates(args); 350 | 351 | case 'get_invoice_template': 352 | return this.client.getInvoiceTemplate(args.templateId, args); 353 | 354 | case 'update_invoice_template': 355 | const { templateId: updateTemplateId, ...updateTemplateData } = args; 356 | return this.client.updateInvoiceTemplate(updateTemplateId, updateTemplateData as UpdateInvoiceTemplateDto); 357 | 358 | case 'delete_invoice_template': 359 | return this.client.deleteInvoiceTemplate(args.templateId, args); 360 | 361 | // Invoice Schedule Handlers 362 | case 'create_invoice_schedule': 363 | return this.client.createInvoiceSchedule(args as CreateInvoiceScheduleDto); 364 | 365 | case 'list_invoice_schedules': 366 | return this.client.listInvoiceSchedules(args); 367 | 368 | case 'get_invoice_schedule': 369 | return this.client.getInvoiceSchedule(args.scheduleId, args); 370 | 371 | // Invoice Management Handlers 372 | case 'create_invoice': 373 | return this.client.createInvoice(args as CreateInvoiceDto); 374 | 375 | case 'list_invoices': 376 | return this.client.listInvoices(args); 377 | 378 | case 'get_invoice': 379 | return this.client.getInvoice(args.invoiceId, args); 380 | 381 | case 'send_invoice': 382 | const { invoiceId: sendInvoiceId, ...sendInvoiceData } = args; 383 | return this.client.sendInvoice(sendInvoiceId, sendInvoiceData as SendInvoiceDto); 384 | 385 | // Estimate Handlers 386 | case 'create_estimate': 387 | return this.client.createEstimate(args as CreateEstimatesDto); 388 | 389 | case 'list_estimates': 390 | return this.client.listEstimates(args); 391 | 392 | case 'send_estimate': 393 | const { estimateId: sendEstimateId, ...sendEstimateData } = args; 394 | return this.client.sendEstimate(sendEstimateId, sendEstimateData as SendEstimateDto); 395 | 396 | case 'create_invoice_from_estimate': 397 | const { estimateId: invoiceFromEstimateId, ...invoiceFromEstimateData } = args; 398 | return this.client.createInvoiceFromEstimate(invoiceFromEstimateId, invoiceFromEstimateData as CreateInvoiceFromEstimateDto); 399 | 400 | // Utility Handlers 401 | case 'generate_invoice_number': 402 | return this.client.generateInvoiceNumber(args); 403 | 404 | case 'generate_estimate_number': 405 | return this.client.generateEstimateNumber(args); 406 | 407 | default: 408 | throw new Error(`Unknown invoices tool: ${name}`); 409 | } 410 | } 411 | } -------------------------------------------------------------------------------- /src/tools/custom-field-v2-tools.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 3 | import { 4 | MCPV2CreateCustomFieldParams, 5 | MCPV2UpdateCustomFieldParams, 6 | MCPV2GetCustomFieldByIdParams, 7 | MCPV2DeleteCustomFieldParams, 8 | MCPV2GetCustomFieldsByObjectKeyParams, 9 | MCPV2CreateCustomFieldFolderParams, 10 | MCPV2UpdateCustomFieldFolderParams, 11 | MCPV2DeleteCustomFieldFolderParams 12 | } from '../types/ghl-types.js'; 13 | 14 | export class CustomFieldV2Tools { 15 | constructor(private apiClient: GHLApiClient) {} 16 | 17 | getTools(): Tool[] { 18 | return [ 19 | // Custom Field Management Tools 20 | { 21 | name: 'ghl_get_custom_field_by_id', 22 | description: 'Get a custom field or folder by its ID. Supports custom objects and company (business) fields.', 23 | inputSchema: { 24 | type: 'object', 25 | properties: { 26 | id: { 27 | type: 'string', 28 | description: 'The ID of the custom field or folder to retrieve' 29 | } 30 | }, 31 | required: ['id'] 32 | } 33 | }, 34 | { 35 | name: 'ghl_create_custom_field', 36 | description: 'Create a new custom field for custom objects or company (business). Supports various field types including text, number, options, date, file upload, etc.', 37 | inputSchema: { 38 | type: 'object', 39 | properties: { 40 | locationId: { 41 | type: 'string', 42 | description: 'GoHighLevel location ID (will use default if not provided)' 43 | }, 44 | name: { 45 | type: 'string', 46 | description: 'Field name (optional for some field types)' 47 | }, 48 | description: { 49 | type: 'string', 50 | description: 'Description of the field' 51 | }, 52 | placeholder: { 53 | type: 'string', 54 | description: 'Placeholder text for the field' 55 | }, 56 | showInForms: { 57 | type: 'boolean', 58 | description: 'Whether the field should be shown in forms', 59 | default: true 60 | }, 61 | options: { 62 | type: 'array', 63 | items: { 64 | type: 'object', 65 | properties: { 66 | key: { 67 | type: 'string', 68 | description: 'Key of the option' 69 | }, 70 | label: { 71 | type: 'string', 72 | description: 'Label of the option' 73 | }, 74 | url: { 75 | type: 'string', 76 | description: 'URL associated with the option (only for RADIO type)' 77 | } 78 | }, 79 | required: ['key', 'label'] 80 | }, 81 | description: 'Options for the field (required for SINGLE_OPTIONS, MULTIPLE_OPTIONS, RADIO, CHECKBOX, TEXTBOX_LIST types)' 82 | }, 83 | acceptedFormats: { 84 | type: 'string', 85 | enum: ['.pdf', '.docx', '.doc', '.jpg', '.jpeg', '.png', '.gif', '.csv', '.xlsx', '.xls', 'all'], 86 | description: 'Allowed file formats for uploads (only for FILE_UPLOAD type)' 87 | }, 88 | dataType: { 89 | type: 'string', 90 | enum: ['TEXT', 'LARGE_TEXT', 'NUMERICAL', 'PHONE', 'MONETORY', 'CHECKBOX', 'SINGLE_OPTIONS', 'MULTIPLE_OPTIONS', 'DATE', 'TEXTBOX_LIST', 'FILE_UPLOAD', 'RADIO', 'EMAIL'], 91 | description: 'Type of field to create' 92 | }, 93 | fieldKey: { 94 | type: 'string', 95 | description: 'Field key. Format: "custom_object.{objectKey}.{fieldKey}" for custom objects. Example: "custom_object.pet.name"' 96 | }, 97 | objectKey: { 98 | type: 'string', 99 | description: 'The object key. Format: "custom_object.{objectKey}" for custom objects. Example: "custom_object.pet"' 100 | }, 101 | maxFileLimit: { 102 | type: 'number', 103 | description: 'Maximum file limit for uploads (only for FILE_UPLOAD type)' 104 | }, 105 | allowCustomOption: { 106 | type: 'boolean', 107 | description: 'Allow users to add custom option values for RADIO type fields' 108 | }, 109 | parentId: { 110 | type: 'string', 111 | description: 'ID of the parent folder for organization' 112 | } 113 | }, 114 | required: ['dataType', 'fieldKey', 'objectKey', 'parentId'] 115 | } 116 | }, 117 | { 118 | name: 'ghl_update_custom_field', 119 | description: 'Update an existing custom field by ID. Can modify name, description, options, and other properties.', 120 | inputSchema: { 121 | type: 'object', 122 | properties: { 123 | id: { 124 | type: 'string', 125 | description: 'The ID of the custom field to update' 126 | }, 127 | locationId: { 128 | type: 'string', 129 | description: 'GoHighLevel location ID (will use default if not provided)' 130 | }, 131 | name: { 132 | type: 'string', 133 | description: 'Updated field name' 134 | }, 135 | description: { 136 | type: 'string', 137 | description: 'Updated description of the field' 138 | }, 139 | placeholder: { 140 | type: 'string', 141 | description: 'Updated placeholder text for the field' 142 | }, 143 | showInForms: { 144 | type: 'boolean', 145 | description: 'Whether the field should be shown in forms' 146 | }, 147 | options: { 148 | type: 'array', 149 | items: { 150 | type: 'object', 151 | properties: { 152 | key: { 153 | type: 'string', 154 | description: 'Key of the option' 155 | }, 156 | label: { 157 | type: 'string', 158 | description: 'Label of the option' 159 | }, 160 | url: { 161 | type: 'string', 162 | description: 'URL associated with the option (only for RADIO type)' 163 | } 164 | }, 165 | required: ['key', 'label'] 166 | }, 167 | description: 'Updated options (replaces all existing options - include all options you want to keep)' 168 | }, 169 | acceptedFormats: { 170 | type: 'string', 171 | enum: ['.pdf', '.docx', '.doc', '.jpg', '.jpeg', '.png', '.gif', '.csv', '.xlsx', '.xls', 'all'], 172 | description: 'Updated allowed file formats for uploads' 173 | }, 174 | maxFileLimit: { 175 | type: 'number', 176 | description: 'Updated maximum file limit for uploads' 177 | } 178 | }, 179 | required: ['id'] 180 | } 181 | }, 182 | { 183 | name: 'ghl_delete_custom_field', 184 | description: 'Delete a custom field by ID. This will permanently remove the field and its data.', 185 | inputSchema: { 186 | type: 'object', 187 | properties: { 188 | id: { 189 | type: 'string', 190 | description: 'The ID of the custom field to delete' 191 | } 192 | }, 193 | required: ['id'] 194 | } 195 | }, 196 | { 197 | name: 'ghl_get_custom_fields_by_object_key', 198 | description: 'Get all custom fields and folders for a specific object key (e.g., custom object or company).', 199 | inputSchema: { 200 | type: 'object', 201 | properties: { 202 | objectKey: { 203 | type: 'string', 204 | description: 'Object key to get fields for. Format: "custom_object.{objectKey}" for custom objects. Example: "custom_object.pet"' 205 | }, 206 | locationId: { 207 | type: 'string', 208 | description: 'GoHighLevel location ID (will use default if not provided)' 209 | } 210 | }, 211 | required: ['objectKey'] 212 | } 213 | }, 214 | // Custom Field Folder Management Tools 215 | { 216 | name: 'ghl_create_custom_field_folder', 217 | description: 'Create a new custom field folder for organizing fields within an object.', 218 | inputSchema: { 219 | type: 'object', 220 | properties: { 221 | objectKey: { 222 | type: 'string', 223 | description: 'Object key for the folder. Format: "custom_object.{objectKey}" for custom objects. Example: "custom_object.pet"' 224 | }, 225 | name: { 226 | type: 'string', 227 | description: 'Name of the folder' 228 | }, 229 | locationId: { 230 | type: 'string', 231 | description: 'GoHighLevel location ID (will use default if not provided)' 232 | } 233 | }, 234 | required: ['objectKey', 'name'] 235 | } 236 | }, 237 | { 238 | name: 'ghl_update_custom_field_folder', 239 | description: 'Update the name of an existing custom field folder.', 240 | inputSchema: { 241 | type: 'object', 242 | properties: { 243 | id: { 244 | type: 'string', 245 | description: 'The ID of the folder to update' 246 | }, 247 | name: { 248 | type: 'string', 249 | description: 'New name for the folder' 250 | }, 251 | locationId: { 252 | type: 'string', 253 | description: 'GoHighLevel location ID (will use default if not provided)' 254 | } 255 | }, 256 | required: ['id', 'name'] 257 | } 258 | }, 259 | { 260 | name: 'ghl_delete_custom_field_folder', 261 | description: 'Delete a custom field folder. This will also affect any fields within the folder.', 262 | inputSchema: { 263 | type: 'object', 264 | properties: { 265 | id: { 266 | type: 'string', 267 | description: 'The ID of the folder to delete' 268 | }, 269 | locationId: { 270 | type: 'string', 271 | description: 'GoHighLevel location ID (will use default if not provided)' 272 | } 273 | }, 274 | required: ['id'] 275 | } 276 | } 277 | ]; 278 | } 279 | 280 | async executeCustomFieldV2Tool(name: string, args: any): Promise { 281 | try { 282 | switch (name) { 283 | case 'ghl_get_custom_field_by_id': { 284 | const params: MCPV2GetCustomFieldByIdParams = args; 285 | const result = await this.apiClient.getCustomFieldV2ById(params.id); 286 | return { 287 | success: true, 288 | data: result.data, 289 | message: `Custom field/folder retrieved successfully` 290 | }; 291 | } 292 | 293 | case 'ghl_create_custom_field': { 294 | const params: MCPV2CreateCustomFieldParams = args; 295 | const result = await this.apiClient.createCustomFieldV2({ 296 | locationId: params.locationId || '', 297 | name: params.name, 298 | description: params.description, 299 | placeholder: params.placeholder, 300 | showInForms: params.showInForms ?? true, 301 | options: params.options, 302 | acceptedFormats: params.acceptedFormats, 303 | dataType: params.dataType, 304 | fieldKey: params.fieldKey, 305 | objectKey: params.objectKey, 306 | maxFileLimit: params.maxFileLimit, 307 | allowCustomOption: params.allowCustomOption, 308 | parentId: params.parentId 309 | }); 310 | return { 311 | success: true, 312 | data: result.data, 313 | message: `Custom field '${params.fieldKey}' created successfully` 314 | }; 315 | } 316 | 317 | case 'ghl_update_custom_field': { 318 | const params: MCPV2UpdateCustomFieldParams = args; 319 | const result = await this.apiClient.updateCustomFieldV2(params.id, { 320 | locationId: params.locationId || '', 321 | name: params.name, 322 | description: params.description, 323 | placeholder: params.placeholder, 324 | showInForms: params.showInForms ?? true, 325 | options: params.options, 326 | acceptedFormats: params.acceptedFormats, 327 | maxFileLimit: params.maxFileLimit 328 | }); 329 | return { 330 | success: true, 331 | data: result.data, 332 | message: `Custom field updated successfully` 333 | }; 334 | } 335 | 336 | case 'ghl_delete_custom_field': { 337 | const params: MCPV2DeleteCustomFieldParams = args; 338 | const result = await this.apiClient.deleteCustomFieldV2(params.id); 339 | return { 340 | success: true, 341 | data: result.data, 342 | message: `Custom field deleted successfully` 343 | }; 344 | } 345 | 346 | case 'ghl_get_custom_fields_by_object_key': { 347 | const params: MCPV2GetCustomFieldsByObjectKeyParams = args; 348 | const result = await this.apiClient.getCustomFieldsV2ByObjectKey({ 349 | objectKey: params.objectKey, 350 | locationId: params.locationId || '' 351 | }); 352 | return { 353 | success: true, 354 | data: result.data, 355 | message: `Retrieved ${result.data?.fields?.length || 0} fields and ${result.data?.folders?.length || 0} folders for object '${params.objectKey}'` 356 | }; 357 | } 358 | 359 | case 'ghl_create_custom_field_folder': { 360 | const params: MCPV2CreateCustomFieldFolderParams = args; 361 | const result = await this.apiClient.createCustomFieldV2Folder({ 362 | objectKey: params.objectKey, 363 | name: params.name, 364 | locationId: params.locationId || '' 365 | }); 366 | return { 367 | success: true, 368 | data: result.data, 369 | message: `Custom field folder '${params.name}' created successfully` 370 | }; 371 | } 372 | 373 | case 'ghl_update_custom_field_folder': { 374 | const params: MCPV2UpdateCustomFieldFolderParams = args; 375 | const result = await this.apiClient.updateCustomFieldV2Folder(params.id, { 376 | name: params.name, 377 | locationId: params.locationId || '' 378 | }); 379 | return { 380 | success: true, 381 | data: result.data, 382 | message: `Custom field folder updated to '${params.name}'` 383 | }; 384 | } 385 | 386 | case 'ghl_delete_custom_field_folder': { 387 | const params: MCPV2DeleteCustomFieldFolderParams = args; 388 | const result = await this.apiClient.deleteCustomFieldV2Folder({ 389 | id: params.id, 390 | locationId: params.locationId || '' 391 | }); 392 | return { 393 | success: true, 394 | data: result.data, 395 | message: `Custom field folder deleted successfully` 396 | }; 397 | } 398 | 399 | default: 400 | throw new Error(`Unknown custom field V2 tool: ${name}`); 401 | } 402 | } catch (error) { 403 | return { 404 | success: false, 405 | error: error instanceof Error ? error.message : String(error), 406 | message: `Failed to execute ${name}` 407 | }; 408 | } 409 | } 410 | } -------------------------------------------------------------------------------- /tests/tools/blog-tools.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit Tests for Blog Tools 3 | * Tests all 7 blog management MCP tools 4 | */ 5 | 6 | import { describe, it, expect, beforeEach, jest } from '@jest/globals'; 7 | import { BlogTools } from '../../src/tools/blog-tools.js'; 8 | import { MockGHLApiClient, mockBlogPost, mockBlogSite, mockBlogAuthor, mockBlogCategory } from '../mocks/ghl-api-client.mock.js'; 9 | 10 | describe('BlogTools', () => { 11 | let blogTools: BlogTools; 12 | let mockGhlClient: MockGHLApiClient; 13 | 14 | beforeEach(() => { 15 | mockGhlClient = new MockGHLApiClient(); 16 | blogTools = new BlogTools(mockGhlClient as any); 17 | }); 18 | 19 | describe('getToolDefinitions', () => { 20 | it('should return 7 blog tool definitions', () => { 21 | const tools = blogTools.getToolDefinitions(); 22 | expect(tools).toHaveLength(7); 23 | 24 | const toolNames = tools.map(tool => tool.name); 25 | expect(toolNames).toEqual([ 26 | 'create_blog_post', 27 | 'update_blog_post', 28 | 'get_blog_posts', 29 | 'get_blog_sites', 30 | 'get_blog_authors', 31 | 'get_blog_categories', 32 | 'check_url_slug' 33 | ]); 34 | }); 35 | 36 | it('should have proper schema definitions for all tools', () => { 37 | const tools = blogTools.getToolDefinitions(); 38 | 39 | tools.forEach(tool => { 40 | expect(tool.name).toBeDefined(); 41 | expect(tool.description).toBeDefined(); 42 | expect(tool.inputSchema).toBeDefined(); 43 | expect(tool.inputSchema.type).toBe('object'); 44 | expect(tool.inputSchema.properties).toBeDefined(); 45 | }); 46 | }); 47 | }); 48 | 49 | describe('executeTool', () => { 50 | it('should route tool calls correctly', async () => { 51 | const createSpy = jest.spyOn(blogTools as any, 'createBlogPost'); 52 | const getSitesSpy = jest.spyOn(blogTools as any, 'getBlogSites'); 53 | 54 | await blogTools.executeTool('create_blog_post', { 55 | title: 'Test Post', 56 | blogId: 'blog_123', 57 | content: '

Test

', 58 | description: 'Test description', 59 | imageUrl: 'https://example.com/image.jpg', 60 | imageAltText: 'Test image', 61 | urlSlug: 'test-post', 62 | author: 'author_123', 63 | categories: ['cat_123'] 64 | }); 65 | await blogTools.executeTool('get_blog_sites', {}); 66 | 67 | expect(createSpy).toHaveBeenCalled(); 68 | expect(getSitesSpy).toHaveBeenCalled(); 69 | }); 70 | 71 | it('should throw error for unknown tool', async () => { 72 | await expect( 73 | blogTools.executeTool('unknown_tool', {}) 74 | ).rejects.toThrow('Unknown tool: unknown_tool'); 75 | }); 76 | }); 77 | 78 | describe('create_blog_post', () => { 79 | const validBlogPostData = { 80 | title: 'Test Blog Post', 81 | blogId: 'blog_123', 82 | content: '

Test Content

This is a test blog post.

', 83 | description: 'Test blog post description', 84 | imageUrl: 'https://example.com/test-image.jpg', 85 | imageAltText: 'Test image alt text', 86 | urlSlug: 'test-blog-post', 87 | author: 'author_123', 88 | categories: ['cat_123', 'cat_456'], 89 | tags: ['test', 'blog'] 90 | }; 91 | 92 | it('should create blog post successfully', async () => { 93 | const result = await blogTools.executeTool('create_blog_post', validBlogPostData); 94 | 95 | expect(result.success).toBe(true); 96 | expect(result.blogPost).toBeDefined(); 97 | expect(result.blogPost.title).toBe(validBlogPostData.title); 98 | expect(result.message).toContain('created successfully'); 99 | }); 100 | 101 | it('should set default status to DRAFT if not provided', async () => { 102 | const spy = jest.spyOn(mockGhlClient, 'createBlogPost'); 103 | 104 | await blogTools.executeTool('create_blog_post', validBlogPostData); 105 | 106 | expect(spy).toHaveBeenCalledWith( 107 | expect.objectContaining({ 108 | status: 'DRAFT' 109 | }) 110 | ); 111 | }); 112 | 113 | it('should set publishedAt when status is PUBLISHED', async () => { 114 | const spy = jest.spyOn(mockGhlClient, 'createBlogPost'); 115 | 116 | await blogTools.executeTool('create_blog_post', { 117 | ...validBlogPostData, 118 | status: 'PUBLISHED' 119 | }); 120 | 121 | expect(spy).toHaveBeenCalledWith( 122 | expect.objectContaining({ 123 | status: 'PUBLISHED', 124 | publishedAt: expect.any(String) 125 | }) 126 | ); 127 | }); 128 | 129 | it('should use custom publishedAt if provided', async () => { 130 | const customDate = '2024-06-01T12:00:00.000Z'; 131 | const spy = jest.spyOn(mockGhlClient, 'createBlogPost'); 132 | 133 | await blogTools.executeTool('create_blog_post', { 134 | ...validBlogPostData, 135 | publishedAt: customDate 136 | }); 137 | 138 | expect(spy).toHaveBeenCalledWith( 139 | expect.objectContaining({ 140 | publishedAt: customDate 141 | }) 142 | ); 143 | }); 144 | 145 | it('should handle API errors', async () => { 146 | const mockError = new Error('GHL API Error (400): Invalid blog data'); 147 | jest.spyOn(mockGhlClient, 'createBlogPost').mockRejectedValueOnce(mockError); 148 | 149 | await expect( 150 | blogTools.executeTool('create_blog_post', validBlogPostData) 151 | ).rejects.toThrow('Failed to create blog post'); 152 | }); 153 | }); 154 | 155 | describe('update_blog_post', () => { 156 | it('should update blog post successfully', async () => { 157 | const updateData = { 158 | postId: 'post_123', 159 | blogId: 'blog_123', 160 | title: 'Updated Title', 161 | status: 'PUBLISHED' as const 162 | }; 163 | 164 | const result = await blogTools.executeTool('update_blog_post', updateData); 165 | 166 | expect(result.success).toBe(true); 167 | expect(result.blogPost).toBeDefined(); 168 | expect(result.message).toBe('Blog post updated successfully'); 169 | }); 170 | 171 | it('should handle partial updates', async () => { 172 | const spy = jest.spyOn(mockGhlClient, 'updateBlogPost'); 173 | 174 | await blogTools.executeTool('update_blog_post', { 175 | postId: 'post_123', 176 | blogId: 'blog_123', 177 | title: 'New Title' 178 | }); 179 | 180 | expect(spy).toHaveBeenCalledWith('post_123', { 181 | locationId: 'test_location_123', 182 | blogId: 'blog_123', 183 | title: 'New Title' 184 | }); 185 | }); 186 | 187 | it('should include all provided fields', async () => { 188 | const spy = jest.spyOn(mockGhlClient, 'updateBlogPost'); 189 | 190 | const updateData = { 191 | postId: 'post_123', 192 | blogId: 'blog_123', 193 | title: 'Updated Title', 194 | content: '

Updated Content

', 195 | status: 'PUBLISHED' as const, 196 | tags: ['updated', 'test'] 197 | }; 198 | 199 | await blogTools.executeTool('update_blog_post', updateData); 200 | 201 | expect(spy).toHaveBeenCalledWith('post_123', { 202 | locationId: 'test_location_123', 203 | blogId: 'blog_123', 204 | title: 'Updated Title', 205 | rawHTML: '

Updated Content

', 206 | status: 'PUBLISHED', 207 | tags: ['updated', 'test'] 208 | }); 209 | }); 210 | }); 211 | 212 | describe('get_blog_posts', () => { 213 | it('should get blog posts successfully', async () => { 214 | const result = await blogTools.executeTool('get_blog_posts', { 215 | blogId: 'blog_123' 216 | }); 217 | 218 | expect(result.success).toBe(true); 219 | expect(result.posts).toBeDefined(); 220 | expect(Array.isArray(result.posts)).toBe(true); 221 | expect(result.count).toBeDefined(); 222 | expect(result.message).toContain('Retrieved'); 223 | }); 224 | 225 | it('should use default parameters', async () => { 226 | const spy = jest.spyOn(mockGhlClient, 'getBlogPosts'); 227 | 228 | await blogTools.executeTool('get_blog_posts', { 229 | blogId: 'blog_123' 230 | }); 231 | 232 | expect(spy).toHaveBeenCalledWith({ 233 | locationId: 'test_location_123', 234 | blogId: 'blog_123', 235 | limit: 10, 236 | offset: 0, 237 | searchTerm: undefined, 238 | status: undefined 239 | }); 240 | }); 241 | 242 | it('should handle search and filtering', async () => { 243 | const result = await blogTools.executeTool('get_blog_posts', { 244 | blogId: 'blog_123', 245 | limit: 5, 246 | offset: 10, 247 | searchTerm: 'test', 248 | status: 'PUBLISHED' 249 | }); 250 | 251 | expect(result.success).toBe(true); 252 | expect(result.posts).toBeDefined(); 253 | }); 254 | }); 255 | 256 | describe('get_blog_sites', () => { 257 | it('should get blog sites successfully', async () => { 258 | const result = await blogTools.executeTool('get_blog_sites', {}); 259 | 260 | expect(result.success).toBe(true); 261 | expect(result.sites).toBeDefined(); 262 | expect(Array.isArray(result.sites)).toBe(true); 263 | expect(result.count).toBeDefined(); 264 | expect(result.message).toContain('Retrieved'); 265 | }); 266 | 267 | it('should use default parameters', async () => { 268 | const spy = jest.spyOn(mockGhlClient, 'getBlogSites'); 269 | 270 | await blogTools.executeTool('get_blog_sites', {}); 271 | 272 | expect(spy).toHaveBeenCalledWith({ 273 | locationId: 'test_location_123', 274 | skip: 0, 275 | limit: 10, 276 | searchTerm: undefined 277 | }); 278 | }); 279 | 280 | it('should handle custom parameters', async () => { 281 | const result = await blogTools.executeTool('get_blog_sites', { 282 | limit: 5, 283 | skip: 2, 284 | searchTerm: 'main blog' 285 | }); 286 | 287 | expect(result.success).toBe(true); 288 | expect(result.sites).toBeDefined(); 289 | }); 290 | }); 291 | 292 | describe('get_blog_authors', () => { 293 | it('should get blog authors successfully', async () => { 294 | const result = await blogTools.executeTool('get_blog_authors', {}); 295 | 296 | expect(result.success).toBe(true); 297 | expect(result.authors).toBeDefined(); 298 | expect(Array.isArray(result.authors)).toBe(true); 299 | expect(result.count).toBeDefined(); 300 | expect(result.message).toContain('Retrieved'); 301 | }); 302 | 303 | it('should use default parameters', async () => { 304 | const spy = jest.spyOn(mockGhlClient, 'getBlogAuthors'); 305 | 306 | await blogTools.executeTool('get_blog_authors', {}); 307 | 308 | expect(spy).toHaveBeenCalledWith({ 309 | locationId: 'test_location_123', 310 | limit: 10, 311 | offset: 0 312 | }); 313 | }); 314 | 315 | it('should handle custom pagination', async () => { 316 | const result = await blogTools.executeTool('get_blog_authors', { 317 | limit: 20, 318 | offset: 5 319 | }); 320 | 321 | expect(result.success).toBe(true); 322 | expect(result.authors).toBeDefined(); 323 | }); 324 | }); 325 | 326 | describe('get_blog_categories', () => { 327 | it('should get blog categories successfully', async () => { 328 | const result = await blogTools.executeTool('get_blog_categories', {}); 329 | 330 | expect(result.success).toBe(true); 331 | expect(result.categories).toBeDefined(); 332 | expect(Array.isArray(result.categories)).toBe(true); 333 | expect(result.count).toBeDefined(); 334 | expect(result.message).toContain('Retrieved'); 335 | }); 336 | 337 | it('should use default parameters', async () => { 338 | const spy = jest.spyOn(mockGhlClient, 'getBlogCategories'); 339 | 340 | await blogTools.executeTool('get_blog_categories', {}); 341 | 342 | expect(spy).toHaveBeenCalledWith({ 343 | locationId: 'test_location_123', 344 | limit: 10, 345 | offset: 0 346 | }); 347 | }); 348 | 349 | it('should handle custom pagination', async () => { 350 | const result = await blogTools.executeTool('get_blog_categories', { 351 | limit: 15, 352 | offset: 3 353 | }); 354 | 355 | expect(result.success).toBe(true); 356 | expect(result.categories).toBeDefined(); 357 | }); 358 | }); 359 | 360 | describe('check_url_slug', () => { 361 | it('should check available URL slug successfully', async () => { 362 | const result = await blogTools.executeTool('check_url_slug', { 363 | urlSlug: 'new-blog-post' 364 | }); 365 | 366 | expect(result.success).toBe(true); 367 | expect(result.urlSlug).toBe('new-blog-post'); 368 | expect(result.exists).toBe(false); 369 | expect(result.available).toBe(true); 370 | expect(result.message).toContain('is available'); 371 | }); 372 | 373 | it('should detect existing URL slug', async () => { 374 | const result = await blogTools.executeTool('check_url_slug', { 375 | urlSlug: 'existing-slug' 376 | }); 377 | 378 | expect(result.success).toBe(true); 379 | expect(result.urlSlug).toBe('existing-slug'); 380 | expect(result.exists).toBe(true); 381 | expect(result.available).toBe(false); 382 | expect(result.message).toContain('is already in use'); 383 | }); 384 | 385 | it('should handle post ID exclusion for updates', async () => { 386 | const spy = jest.spyOn(mockGhlClient, 'checkUrlSlugExists'); 387 | 388 | await blogTools.executeTool('check_url_slug', { 389 | urlSlug: 'test-slug', 390 | postId: 'post_123' 391 | }); 392 | 393 | expect(spy).toHaveBeenCalledWith({ 394 | locationId: 'test_location_123', 395 | urlSlug: 'test-slug', 396 | postId: 'post_123' 397 | }); 398 | }); 399 | }); 400 | 401 | describe('error handling', () => { 402 | it('should propagate API client errors', async () => { 403 | const mockError = new Error('Network timeout'); 404 | jest.spyOn(mockGhlClient, 'createBlogPost').mockRejectedValueOnce(mockError); 405 | 406 | await expect( 407 | blogTools.executeTool('create_blog_post', { 408 | title: 'Test', 409 | blogId: 'blog_123', 410 | content: 'content', 411 | description: 'desc', 412 | imageUrl: 'url', 413 | imageAltText: 'alt', 414 | urlSlug: 'slug', 415 | author: 'author', 416 | categories: ['cat'] 417 | }) 418 | ).rejects.toThrow('Failed to create blog post: Error: Network timeout'); 419 | }); 420 | 421 | it('should handle blog not found errors', async () => { 422 | const mockError = new Error('GHL API Error (404): Blog not found'); 423 | jest.spyOn(mockGhlClient, 'getBlogPosts').mockRejectedValueOnce(mockError); 424 | 425 | await expect( 426 | blogTools.executeTool('get_blog_posts', { blogId: 'not_found' }) 427 | ).rejects.toThrow('Failed to get blog posts'); 428 | }); 429 | 430 | it('should handle invalid blog post data', async () => { 431 | const mockError = new Error('GHL API Error (422): Invalid blog post data'); 432 | jest.spyOn(mockGhlClient, 'updateBlogPost').mockRejectedValueOnce(mockError); 433 | 434 | await expect( 435 | blogTools.executeTool('update_blog_post', { 436 | postId: 'post_123', 437 | blogId: 'blog_123', 438 | title: '' 439 | }) 440 | ).rejects.toThrow('Failed to update blog post'); 441 | }); 442 | }); 443 | 444 | describe('input validation', () => { 445 | it('should validate required fields in create_blog_post', () => { 446 | const tools = blogTools.getToolDefinitions(); 447 | const createTool = tools.find(tool => tool.name === 'create_blog_post'); 448 | 449 | expect(createTool?.inputSchema.required).toEqual([ 450 | 'title', 'blogId', 'content', 'description', 451 | 'imageUrl', 'imageAltText', 'urlSlug', 'author', 'categories' 452 | ]); 453 | }); 454 | 455 | it('should validate required fields in update_blog_post', () => { 456 | const tools = blogTools.getToolDefinitions(); 457 | const updateTool = tools.find(tool => tool.name === 'update_blog_post'); 458 | 459 | expect(updateTool?.inputSchema.required).toEqual(['postId', 'blogId']); 460 | }); 461 | 462 | it('should validate blog post status enum', () => { 463 | const tools = blogTools.getToolDefinitions(); 464 | const createTool = tools.find(tool => tool.name === 'create_blog_post'); 465 | 466 | expect(createTool?.inputSchema.properties.status.enum).toEqual([ 467 | 'DRAFT', 'PUBLISHED', 'SCHEDULED', 'ARCHIVED' 468 | ]); 469 | }); 470 | 471 | it('should validate URL slug requirement', () => { 472 | const tools = blogTools.getToolDefinitions(); 473 | const checkSlugTool = tools.find(tool => tool.name === 'check_url_slug'); 474 | 475 | expect(checkSlugTool?.inputSchema.required).toEqual(['urlSlug']); 476 | }); 477 | }); 478 | 479 | describe('data transformation', () => { 480 | it('should transform content to rawHTML in create request', async () => { 481 | const spy = jest.spyOn(mockGhlClient, 'createBlogPost'); 482 | 483 | await blogTools.executeTool('create_blog_post', { 484 | title: 'Test', 485 | blogId: 'blog_123', 486 | content: '

Test Content

', 487 | description: 'desc', 488 | imageUrl: 'url', 489 | imageAltText: 'alt', 490 | urlSlug: 'slug', 491 | author: 'author', 492 | categories: ['cat'] 493 | }); 494 | 495 | expect(spy).toHaveBeenCalledWith( 496 | expect.objectContaining({ 497 | rawHTML: '

Test Content

' 498 | }) 499 | ); 500 | }); 501 | 502 | it('should transform content to rawHTML in update request', async () => { 503 | const spy = jest.spyOn(mockGhlClient, 'updateBlogPost'); 504 | 505 | await blogTools.executeTool('update_blog_post', { 506 | postId: 'post_123', 507 | blogId: 'blog_123', 508 | content: '

Updated Content

' 509 | }); 510 | 511 | expect(spy).toHaveBeenCalledWith('post_123', 512 | expect.objectContaining({ 513 | rawHTML: '

Updated Content

' 514 | }) 515 | ); 516 | }); 517 | 518 | it('should include location ID in all requests', async () => { 519 | const createSpy = jest.spyOn(mockGhlClient, 'createBlogPost'); 520 | const getSitesSpy = jest.spyOn(mockGhlClient, 'getBlogSites'); 521 | 522 | await blogTools.executeTool('create_blog_post', { 523 | title: 'Test', 524 | blogId: 'blog_123', 525 | content: 'content', 526 | description: 'desc', 527 | imageUrl: 'url', 528 | imageAltText: 'alt', 529 | urlSlug: 'slug', 530 | author: 'author', 531 | categories: ['cat'] 532 | }); 533 | 534 | await blogTools.executeTool('get_blog_sites', {}); 535 | 536 | expect(createSpy).toHaveBeenCalledWith( 537 | expect.objectContaining({ 538 | locationId: 'test_location_123' 539 | }) 540 | ); 541 | 542 | expect(getSitesSpy).toHaveBeenCalledWith( 543 | expect.objectContaining({ 544 | locationId: 'test_location_123' 545 | }) 546 | ); 547 | }); 548 | }); 549 | }); -------------------------------------------------------------------------------- /src/tools/blog-tools.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MCP Blog Tools for GoHighLevel Integration 3 | * Exposes blog management capabilities to ChatGPT 4 | */ 5 | 6 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 7 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 8 | import { 9 | MCPCreateBlogPostParams, 10 | MCPUpdateBlogPostParams, 11 | MCPGetBlogPostsParams, 12 | MCPGetBlogSitesParams, 13 | MCPGetBlogAuthorsParams, 14 | MCPGetBlogCategoriesParams, 15 | MCPCheckUrlSlugParams, 16 | GHLBlogPostStatus, 17 | GHLBlogPost, 18 | GHLBlogSite, 19 | GHLBlogAuthor, 20 | GHLBlogCategory 21 | } from '../types/ghl-types.js'; 22 | 23 | /** 24 | * Blog Tools Class 25 | * Implements MCP tools for blog management 26 | */ 27 | export class BlogTools { 28 | constructor(private ghlClient: GHLApiClient) {} 29 | 30 | /** 31 | * Get all blog tool definitions for MCP server 32 | */ 33 | getToolDefinitions(): Tool[] { 34 | return [ 35 | // 1. Create Blog Post 36 | { 37 | name: 'create_blog_post', 38 | description: 'Create a new blog post in GoHighLevel. Requires blog ID, author ID, and category IDs which can be obtained from other blog tools.', 39 | inputSchema: { 40 | type: 'object', 41 | properties: { 42 | title: { 43 | type: 'string', 44 | description: 'Blog post title' 45 | }, 46 | blogId: { 47 | type: 'string', 48 | description: 'Blog site ID (use get_blog_sites to find available blogs)' 49 | }, 50 | content: { 51 | type: 'string', 52 | description: 'Full HTML content of the blog post' 53 | }, 54 | description: { 55 | type: 'string', 56 | description: 'Short description/excerpt of the blog post' 57 | }, 58 | imageUrl: { 59 | type: 'string', 60 | description: 'URL of the featured image for the blog post' 61 | }, 62 | imageAltText: { 63 | type: 'string', 64 | description: 'Alt text for the featured image (for SEO and accessibility)' 65 | }, 66 | urlSlug: { 67 | type: 'string', 68 | description: 'URL slug for the blog post (use check_url_slug to verify availability)' 69 | }, 70 | author: { 71 | type: 'string', 72 | description: 'Author ID (use get_blog_authors to find available authors)' 73 | }, 74 | categories: { 75 | type: 'array', 76 | items: { type: 'string' }, 77 | description: 'Array of category IDs (use get_blog_categories to find available categories)' 78 | }, 79 | tags: { 80 | type: 'array', 81 | items: { type: 'string' }, 82 | description: 'Optional array of tags for the blog post' 83 | }, 84 | status: { 85 | type: 'string', 86 | enum: ['DRAFT', 'PUBLISHED', 'SCHEDULED', 'ARCHIVED'], 87 | description: 'Publication status of the blog post', 88 | default: 'DRAFT' 89 | }, 90 | canonicalLink: { 91 | type: 'string', 92 | description: 'Optional canonical URL for SEO' 93 | }, 94 | publishedAt: { 95 | type: 'string', 96 | description: 'Optional ISO timestamp for publication date (defaults to now for PUBLISHED status)' 97 | } 98 | }, 99 | required: ['title', 'blogId', 'content', 'description', 'imageUrl', 'imageAltText', 'urlSlug', 'author', 'categories'] 100 | } 101 | }, 102 | 103 | // 2. Update Blog Post 104 | { 105 | name: 'update_blog_post', 106 | description: 'Update an existing blog post in GoHighLevel. All fields except postId and blogId are optional.', 107 | inputSchema: { 108 | type: 'object', 109 | properties: { 110 | postId: { 111 | type: 'string', 112 | description: 'Blog post ID to update' 113 | }, 114 | blogId: { 115 | type: 'string', 116 | description: 'Blog site ID that contains the post' 117 | }, 118 | title: { 119 | type: 'string', 120 | description: 'Updated blog post title' 121 | }, 122 | content: { 123 | type: 'string', 124 | description: 'Updated HTML content of the blog post' 125 | }, 126 | description: { 127 | type: 'string', 128 | description: 'Updated description/excerpt of the blog post' 129 | }, 130 | imageUrl: { 131 | type: 'string', 132 | description: 'Updated featured image URL' 133 | }, 134 | imageAltText: { 135 | type: 'string', 136 | description: 'Updated alt text for the featured image' 137 | }, 138 | urlSlug: { 139 | type: 'string', 140 | description: 'Updated URL slug (use check_url_slug to verify availability)' 141 | }, 142 | author: { 143 | type: 'string', 144 | description: 'Updated author ID' 145 | }, 146 | categories: { 147 | type: 'array', 148 | items: { type: 'string' }, 149 | description: 'Updated array of category IDs' 150 | }, 151 | tags: { 152 | type: 'array', 153 | items: { type: 'string' }, 154 | description: 'Updated array of tags' 155 | }, 156 | status: { 157 | type: 'string', 158 | enum: ['DRAFT', 'PUBLISHED', 'SCHEDULED', 'ARCHIVED'], 159 | description: 'Updated publication status' 160 | }, 161 | canonicalLink: { 162 | type: 'string', 163 | description: 'Updated canonical URL' 164 | }, 165 | publishedAt: { 166 | type: 'string', 167 | description: 'Updated ISO timestamp for publication date' 168 | } 169 | }, 170 | required: ['postId', 'blogId'] 171 | } 172 | }, 173 | 174 | // 3. Get Blog Posts 175 | { 176 | name: 'get_blog_posts', 177 | description: 'Get blog posts from a specific blog site. Use this to list and search existing blog posts.', 178 | inputSchema: { 179 | type: 'object', 180 | properties: { 181 | blogId: { 182 | type: 'string', 183 | description: 'Blog site ID to get posts from (use get_blog_sites to find available blogs)' 184 | }, 185 | limit: { 186 | type: 'number', 187 | description: 'Number of posts to retrieve (default: 10, max recommended: 50)', 188 | default: 10 189 | }, 190 | offset: { 191 | type: 'number', 192 | description: 'Number of posts to skip for pagination (default: 0)', 193 | default: 0 194 | }, 195 | searchTerm: { 196 | type: 'string', 197 | description: 'Optional search term to filter posts by title or content' 198 | }, 199 | status: { 200 | type: 'string', 201 | enum: ['DRAFT', 'PUBLISHED', 'SCHEDULED', 'ARCHIVED'], 202 | description: 'Optional filter by publication status' 203 | } 204 | }, 205 | required: ['blogId'] 206 | } 207 | }, 208 | 209 | // 4. Get Blog Sites 210 | { 211 | name: 'get_blog_sites', 212 | description: 'Get all blog sites for the current location. Use this to find available blogs before creating or managing posts.', 213 | inputSchema: { 214 | type: 'object', 215 | properties: { 216 | limit: { 217 | type: 'number', 218 | description: 'Number of blogs to retrieve (default: 10)', 219 | default: 10 220 | }, 221 | skip: { 222 | type: 'number', 223 | description: 'Number of blogs to skip for pagination (default: 0)', 224 | default: 0 225 | }, 226 | searchTerm: { 227 | type: 'string', 228 | description: 'Optional search term to filter blogs by name' 229 | } 230 | } 231 | } 232 | }, 233 | 234 | // 5. Get Blog Authors 235 | { 236 | name: 'get_blog_authors', 237 | description: 'Get all available blog authors for the current location. Use this to find author IDs for creating blog posts.', 238 | inputSchema: { 239 | type: 'object', 240 | properties: { 241 | limit: { 242 | type: 'number', 243 | description: 'Number of authors to retrieve (default: 10)', 244 | default: 10 245 | }, 246 | offset: { 247 | type: 'number', 248 | description: 'Number of authors to skip for pagination (default: 0)', 249 | default: 0 250 | } 251 | } 252 | } 253 | }, 254 | 255 | // 6. Get Blog Categories 256 | { 257 | name: 'get_blog_categories', 258 | description: 'Get all available blog categories for the current location. Use this to find category IDs for creating blog posts.', 259 | inputSchema: { 260 | type: 'object', 261 | properties: { 262 | limit: { 263 | type: 'number', 264 | description: 'Number of categories to retrieve (default: 10)', 265 | default: 10 266 | }, 267 | offset: { 268 | type: 'number', 269 | description: 'Number of categories to skip for pagination (default: 0)', 270 | default: 0 271 | } 272 | } 273 | } 274 | }, 275 | 276 | // 7. Check URL Slug 277 | { 278 | name: 'check_url_slug', 279 | description: 'Check if a URL slug is available for use. Use this before creating or updating blog posts to ensure unique URLs.', 280 | inputSchema: { 281 | type: 'object', 282 | properties: { 283 | urlSlug: { 284 | type: 'string', 285 | description: 'URL slug to check for availability' 286 | }, 287 | postId: { 288 | type: 'string', 289 | description: 'Optional post ID when updating an existing post (to exclude itself from the check)' 290 | } 291 | }, 292 | required: ['urlSlug'] 293 | } 294 | } 295 | ]; 296 | } 297 | 298 | /** 299 | * Execute blog tool based on tool name and arguments 300 | */ 301 | async executeTool(name: string, args: any): Promise { 302 | switch (name) { 303 | case 'create_blog_post': 304 | return this.createBlogPost(args as MCPCreateBlogPostParams); 305 | 306 | case 'update_blog_post': 307 | return this.updateBlogPost(args as MCPUpdateBlogPostParams); 308 | 309 | case 'get_blog_posts': 310 | return this.getBlogPosts(args as MCPGetBlogPostsParams); 311 | 312 | case 'get_blog_sites': 313 | return this.getBlogSites(args as MCPGetBlogSitesParams); 314 | 315 | case 'get_blog_authors': 316 | return this.getBlogAuthors(args as MCPGetBlogAuthorsParams); 317 | 318 | case 'get_blog_categories': 319 | return this.getBlogCategories(args as MCPGetBlogCategoriesParams); 320 | 321 | case 'check_url_slug': 322 | return this.checkUrlSlug(args as MCPCheckUrlSlugParams); 323 | 324 | default: 325 | throw new Error(`Unknown tool: ${name}`); 326 | } 327 | } 328 | 329 | /** 330 | * CREATE BLOG POST 331 | */ 332 | private async createBlogPost(params: MCPCreateBlogPostParams): Promise<{ success: boolean; blogPost: GHLBlogPost; message: string }> { 333 | try { 334 | // Set default publishedAt if status is PUBLISHED and no date provided 335 | let publishedAt = params.publishedAt; 336 | if (!publishedAt && params.status === 'PUBLISHED') { 337 | publishedAt = new Date().toISOString(); 338 | } else if (!publishedAt) { 339 | publishedAt = new Date().toISOString(); // Always provide a date 340 | } 341 | 342 | const blogPostData = { 343 | title: params.title, 344 | locationId: this.ghlClient.getConfig().locationId, 345 | blogId: params.blogId, 346 | imageUrl: params.imageUrl, 347 | description: params.description, 348 | rawHTML: params.content, 349 | status: (params.status as GHLBlogPostStatus) || 'DRAFT', 350 | imageAltText: params.imageAltText, 351 | categories: params.categories, 352 | tags: params.tags || [], 353 | author: params.author, 354 | urlSlug: params.urlSlug, 355 | canonicalLink: params.canonicalLink, 356 | publishedAt: publishedAt 357 | }; 358 | 359 | const result = await this.ghlClient.createBlogPost(blogPostData); 360 | 361 | if (result.success && result.data) { 362 | return { 363 | success: true, 364 | blogPost: result.data.data, 365 | message: `Blog post "${params.title}" created successfully with ID: ${result.data.data._id}` 366 | }; 367 | } else { 368 | throw new Error('Failed to create blog post - no data returned'); 369 | } 370 | } catch (error) { 371 | throw new Error(`Failed to create blog post: ${error}`); 372 | } 373 | } 374 | 375 | /** 376 | * UPDATE BLOG POST 377 | */ 378 | private async updateBlogPost(params: MCPUpdateBlogPostParams): Promise<{ success: boolean; blogPost: GHLBlogPost; message: string }> { 379 | try { 380 | const updateData: any = { 381 | locationId: this.ghlClient.getConfig().locationId, 382 | blogId: params.blogId 383 | }; 384 | 385 | // Only include fields that are provided 386 | if (params.title) updateData.title = params.title; 387 | if (params.content) updateData.rawHTML = params.content; 388 | if (params.description) updateData.description = params.description; 389 | if (params.imageUrl) updateData.imageUrl = params.imageUrl; 390 | if (params.imageAltText) updateData.imageAltText = params.imageAltText; 391 | if (params.urlSlug) updateData.urlSlug = params.urlSlug; 392 | if (params.author) updateData.author = params.author; 393 | if (params.categories) updateData.categories = params.categories; 394 | if (params.tags) updateData.tags = params.tags; 395 | if (params.status) updateData.status = params.status; 396 | if (params.canonicalLink) updateData.canonicalLink = params.canonicalLink; 397 | if (params.publishedAt) updateData.publishedAt = params.publishedAt; 398 | 399 | const result = await this.ghlClient.updateBlogPost(params.postId, updateData); 400 | 401 | if (result.success && result.data) { 402 | return { 403 | success: true, 404 | blogPost: result.data.updatedBlogPost, 405 | message: `Blog post updated successfully` 406 | }; 407 | } else { 408 | throw new Error('Failed to update blog post - no data returned'); 409 | } 410 | } catch (error) { 411 | throw new Error(`Failed to update blog post: ${error}`); 412 | } 413 | } 414 | 415 | /** 416 | * GET BLOG POSTS 417 | */ 418 | private async getBlogPosts(params: MCPGetBlogPostsParams): Promise<{ success: boolean; posts: GHLBlogPost[]; count: number; message: string }> { 419 | try { 420 | const searchParams = { 421 | locationId: this.ghlClient.getConfig().locationId, 422 | blogId: params.blogId, 423 | limit: params.limit || 10, 424 | offset: params.offset || 0, 425 | searchTerm: params.searchTerm, 426 | status: params.status 427 | }; 428 | 429 | const result = await this.ghlClient.getBlogPosts(searchParams); 430 | 431 | if (result.success && result.data) { 432 | const posts = result.data.blogs || []; 433 | return { 434 | success: true, 435 | posts: posts, 436 | count: posts.length, 437 | message: `Retrieved ${posts.length} blog posts from blog ${params.blogId}` 438 | }; 439 | } else { 440 | throw new Error('Failed to get blog posts - no data returned'); 441 | } 442 | } catch (error) { 443 | throw new Error(`Failed to get blog posts: ${error}`); 444 | } 445 | } 446 | 447 | /** 448 | * GET BLOG SITES 449 | */ 450 | private async getBlogSites(params: MCPGetBlogSitesParams): Promise<{ success: boolean; sites: GHLBlogSite[]; count: number; message: string }> { 451 | try { 452 | const searchParams = { 453 | locationId: this.ghlClient.getConfig().locationId, 454 | skip: params.skip || 0, 455 | limit: params.limit || 10, 456 | searchTerm: params.searchTerm 457 | }; 458 | 459 | const result = await this.ghlClient.getBlogSites(searchParams); 460 | 461 | if (result.success && result.data) { 462 | const sites = result.data.data || []; 463 | return { 464 | success: true, 465 | sites: sites, 466 | count: sites.length, 467 | message: `Retrieved ${sites.length} blog sites` 468 | }; 469 | } else { 470 | throw new Error('Failed to get blog sites - no data returned'); 471 | } 472 | } catch (error) { 473 | throw new Error(`Failed to get blog sites: ${error}`); 474 | } 475 | } 476 | 477 | /** 478 | * GET BLOG AUTHORS 479 | */ 480 | private async getBlogAuthors(params: MCPGetBlogAuthorsParams): Promise<{ success: boolean; authors: GHLBlogAuthor[]; count: number; message: string }> { 481 | try { 482 | const searchParams = { 483 | locationId: this.ghlClient.getConfig().locationId, 484 | limit: params.limit || 10, 485 | offset: params.offset || 0 486 | }; 487 | 488 | const result = await this.ghlClient.getBlogAuthors(searchParams); 489 | 490 | if (result.success && result.data) { 491 | const authors = result.data.authors || []; 492 | return { 493 | success: true, 494 | authors: authors, 495 | count: authors.length, 496 | message: `Retrieved ${authors.length} blog authors` 497 | }; 498 | } else { 499 | throw new Error('Failed to get blog authors - no data returned'); 500 | } 501 | } catch (error) { 502 | throw new Error(`Failed to get blog authors: ${error}`); 503 | } 504 | } 505 | 506 | /** 507 | * GET BLOG CATEGORIES 508 | */ 509 | private async getBlogCategories(params: MCPGetBlogCategoriesParams): Promise<{ success: boolean; categories: GHLBlogCategory[]; count: number; message: string }> { 510 | try { 511 | const searchParams = { 512 | locationId: this.ghlClient.getConfig().locationId, 513 | limit: params.limit || 10, 514 | offset: params.offset || 0 515 | }; 516 | 517 | const result = await this.ghlClient.getBlogCategories(searchParams); 518 | 519 | if (result.success && result.data) { 520 | const categories = result.data.categories || []; 521 | return { 522 | success: true, 523 | categories: categories, 524 | count: categories.length, 525 | message: `Retrieved ${categories.length} blog categories` 526 | }; 527 | } else { 528 | throw new Error('Failed to get blog categories - no data returned'); 529 | } 530 | } catch (error) { 531 | throw new Error(`Failed to get blog categories: ${error}`); 532 | } 533 | } 534 | 535 | /** 536 | * CHECK URL SLUG 537 | */ 538 | private async checkUrlSlug(params: MCPCheckUrlSlugParams): Promise<{ success: boolean; urlSlug: string; exists: boolean; available: boolean; message: string }> { 539 | try { 540 | const checkParams = { 541 | locationId: this.ghlClient.getConfig().locationId, 542 | urlSlug: params.urlSlug, 543 | postId: params.postId 544 | }; 545 | 546 | const result = await this.ghlClient.checkUrlSlugExists(checkParams); 547 | 548 | if (result.success && result.data !== undefined) { 549 | const exists = result.data.exists; 550 | return { 551 | success: true, 552 | urlSlug: params.urlSlug, 553 | exists: exists, 554 | available: !exists, 555 | message: exists 556 | ? `URL slug "${params.urlSlug}" is already in use` 557 | : `URL slug "${params.urlSlug}" is available` 558 | }; 559 | } else { 560 | throw new Error('Failed to check URL slug - no data returned'); 561 | } 562 | } catch (error) { 563 | throw new Error(`Failed to check URL slug: ${error}`); 564 | } 565 | } 566 | } -------------------------------------------------------------------------------- /src/tools/social-media-tools.ts: -------------------------------------------------------------------------------- 1 | import { Tool } from '@modelcontextprotocol/sdk/types.js'; 2 | import { GHLApiClient } from '../clients/ghl-api-client.js'; 3 | import { 4 | MCPSearchPostsParams, 5 | MCPCreatePostParams, 6 | MCPGetPostParams, 7 | MCPUpdatePostParams, 8 | MCPDeletePostParams, 9 | MCPBulkDeletePostsParams, 10 | MCPGetAccountsParams, 11 | MCPDeleteAccountParams, 12 | MCPUploadCSVParams, 13 | MCPGetUploadStatusParams, 14 | MCPSetAccountsParams, 15 | MCPGetCSVPostParams, 16 | MCPFinalizeCSVParams, 17 | MCPDeleteCSVParams, 18 | MCPDeleteCSVPostParams, 19 | MCPGetCategoriesParams, 20 | MCPGetCategoryParams, 21 | MCPGetTagsParams, 22 | MCPGetTagsByIdsParams, 23 | MCPStartOAuthParams, 24 | MCPGetOAuthAccountsParams, 25 | MCPAttachOAuthAccountParams 26 | } from '../types/ghl-types.js'; 27 | 28 | export class SocialMediaTools { 29 | constructor(private ghlClient: GHLApiClient) {} 30 | 31 | getTools(): Tool[] { 32 | return [ 33 | // Post Management Tools 34 | { 35 | name: 'search_social_posts', 36 | description: 'Search and filter social media posts across all platforms', 37 | inputSchema: { 38 | type: 'object', 39 | properties: { 40 | type: { 41 | type: 'string', 42 | enum: ['recent', 'all', 'scheduled', 'draft', 'failed', 'in_review', 'published', 'in_progress', 'deleted'], 43 | description: 'Filter posts by status', 44 | default: 'all' 45 | }, 46 | accounts: { 47 | type: 'string', 48 | description: 'Comma-separated account IDs to filter by' 49 | }, 50 | skip: { type: 'number', description: 'Number of posts to skip', default: 0 }, 51 | limit: { type: 'number', description: 'Number of posts to return', default: 10 }, 52 | fromDate: { type: 'string', description: 'Start date (ISO format)' }, 53 | toDate: { type: 'string', description: 'End date (ISO format)' }, 54 | includeUsers: { type: 'boolean', description: 'Include user data in response', default: true }, 55 | postType: { 56 | type: 'string', 57 | enum: ['post', 'story', 'reel'], 58 | description: 'Type of post to search for' 59 | } 60 | }, 61 | required: ['fromDate', 'toDate'] 62 | } 63 | }, 64 | { 65 | name: 'create_social_post', 66 | description: 'Create a new social media post for multiple platforms', 67 | inputSchema: { 68 | type: 'object', 69 | properties: { 70 | accountIds: { 71 | type: 'array', 72 | items: { type: 'string' }, 73 | description: 'Array of social media account IDs to post to' 74 | }, 75 | summary: { type: 'string', description: 'Post content/text' }, 76 | media: { 77 | type: 'array', 78 | items: { 79 | type: 'object', 80 | properties: { 81 | url: { type: 'string', description: 'Media URL' }, 82 | caption: { type: 'string', description: 'Media caption' }, 83 | type: { type: 'string', description: 'Media MIME type' } 84 | }, 85 | required: ['url'] 86 | }, 87 | description: 'Media attachments' 88 | }, 89 | status: { 90 | type: 'string', 91 | enum: ['draft', 'scheduled', 'published'], 92 | description: 'Post status', 93 | default: 'draft' 94 | }, 95 | scheduleDate: { type: 'string', description: 'Schedule date for post (ISO format)' }, 96 | followUpComment: { type: 'string', description: 'Follow-up comment' }, 97 | type: { 98 | type: 'string', 99 | enum: ['post', 'story', 'reel'], 100 | description: 'Type of post' 101 | }, 102 | tags: { 103 | type: 'array', 104 | items: { type: 'string' }, 105 | description: 'Tag IDs to associate with post' 106 | }, 107 | categoryId: { type: 'string', description: 'Category ID' }, 108 | userId: { type: 'string', description: 'User ID creating the post' } 109 | }, 110 | required: ['accountIds', 'summary', 'type'] 111 | } 112 | }, 113 | { 114 | name: 'get_social_post', 115 | description: 'Get details of a specific social media post', 116 | inputSchema: { 117 | type: 'object', 118 | properties: { 119 | postId: { type: 'string', description: 'Social media post ID' } 120 | }, 121 | required: ['postId'] 122 | } 123 | }, 124 | { 125 | name: 'update_social_post', 126 | description: 'Update an existing social media post', 127 | inputSchema: { 128 | type: 'object', 129 | properties: { 130 | postId: { type: 'string', description: 'Social media post ID' }, 131 | summary: { type: 'string', description: 'Updated post content' }, 132 | status: { 133 | type: 'string', 134 | enum: ['draft', 'scheduled', 'published'], 135 | description: 'Updated post status' 136 | }, 137 | scheduleDate: { type: 'string', description: 'Updated schedule date' }, 138 | tags: { 139 | type: 'array', 140 | items: { type: 'string' }, 141 | description: 'Updated tag IDs' 142 | } 143 | }, 144 | required: ['postId'] 145 | } 146 | }, 147 | { 148 | name: 'delete_social_post', 149 | description: 'Delete a social media post', 150 | inputSchema: { 151 | type: 'object', 152 | properties: { 153 | postId: { type: 'string', description: 'Social media post ID to delete' } 154 | }, 155 | required: ['postId'] 156 | } 157 | }, 158 | { 159 | name: 'bulk_delete_social_posts', 160 | description: 'Delete multiple social media posts at once (max 50)', 161 | inputSchema: { 162 | type: 'object', 163 | properties: { 164 | postIds: { 165 | type: 'array', 166 | items: { type: 'string' }, 167 | description: 'Array of post IDs to delete', 168 | maxItems: 50 169 | } 170 | }, 171 | required: ['postIds'] 172 | } 173 | }, 174 | 175 | // Account Management Tools 176 | { 177 | name: 'get_social_accounts', 178 | description: 'Get all connected social media accounts and groups', 179 | inputSchema: { 180 | type: 'object', 181 | properties: {}, 182 | additionalProperties: false 183 | } 184 | }, 185 | { 186 | name: 'delete_social_account', 187 | description: 'Delete a social media account connection', 188 | inputSchema: { 189 | type: 'object', 190 | properties: { 191 | accountId: { type: 'string', description: 'Account ID to delete' }, 192 | companyId: { type: 'string', description: 'Company ID' }, 193 | userId: { type: 'string', description: 'User ID' } 194 | }, 195 | required: ['accountId'] 196 | } 197 | }, 198 | 199 | // CSV Operations Tools 200 | { 201 | name: 'upload_social_csv', 202 | description: 'Upload CSV file for bulk social media posts', 203 | inputSchema: { 204 | type: 'object', 205 | properties: { 206 | file: { type: 'string', description: 'CSV file data (base64 or file path)' } 207 | }, 208 | required: ['file'] 209 | } 210 | }, 211 | { 212 | name: 'get_csv_upload_status', 213 | description: 'Get status of CSV uploads', 214 | inputSchema: { 215 | type: 'object', 216 | properties: { 217 | skip: { type: 'number', description: 'Number to skip', default: 0 }, 218 | limit: { type: 'number', description: 'Number to return', default: 10 }, 219 | includeUsers: { type: 'boolean', description: 'Include user data' }, 220 | userId: { type: 'string', description: 'Filter by user ID' } 221 | } 222 | } 223 | }, 224 | { 225 | name: 'set_csv_accounts', 226 | description: 'Set accounts for CSV import processing', 227 | inputSchema: { 228 | type: 'object', 229 | properties: { 230 | accountIds: { 231 | type: 'array', 232 | items: { type: 'string' }, 233 | description: 'Account IDs for CSV import' 234 | }, 235 | filePath: { type: 'string', description: 'CSV file path' }, 236 | rowsCount: { type: 'number', description: 'Number of rows to process' }, 237 | fileName: { type: 'string', description: 'CSV file name' }, 238 | approver: { type: 'string', description: 'Approver user ID' }, 239 | userId: { type: 'string', description: 'User ID' } 240 | }, 241 | required: ['accountIds', 'filePath', 'rowsCount', 'fileName'] 242 | } 243 | }, 244 | 245 | // Categories & Tags Tools 246 | { 247 | name: 'get_social_categories', 248 | description: 'Get social media post categories', 249 | inputSchema: { 250 | type: 'object', 251 | properties: { 252 | searchText: { type: 'string', description: 'Search for categories' }, 253 | limit: { type: 'number', description: 'Number to return', default: 10 }, 254 | skip: { type: 'number', description: 'Number to skip', default: 0 } 255 | } 256 | } 257 | }, 258 | { 259 | name: 'get_social_category', 260 | description: 'Get a specific social media category by ID', 261 | inputSchema: { 262 | type: 'object', 263 | properties: { 264 | categoryId: { type: 'string', description: 'Category ID' } 265 | }, 266 | required: ['categoryId'] 267 | } 268 | }, 269 | { 270 | name: 'get_social_tags', 271 | description: 'Get social media post tags', 272 | inputSchema: { 273 | type: 'object', 274 | properties: { 275 | searchText: { type: 'string', description: 'Search for tags' }, 276 | limit: { type: 'number', description: 'Number to return', default: 10 }, 277 | skip: { type: 'number', description: 'Number to skip', default: 0 } 278 | } 279 | } 280 | }, 281 | { 282 | name: 'get_social_tags_by_ids', 283 | description: 'Get specific social media tags by their IDs', 284 | inputSchema: { 285 | type: 'object', 286 | properties: { 287 | tagIds: { 288 | type: 'array', 289 | items: { type: 'string' }, 290 | description: 'Array of tag IDs' 291 | } 292 | }, 293 | required: ['tagIds'] 294 | } 295 | }, 296 | 297 | // OAuth Integration Tools 298 | { 299 | name: 'start_social_oauth', 300 | description: 'Start OAuth process for social media platform', 301 | inputSchema: { 302 | type: 'object', 303 | properties: { 304 | platform: { 305 | type: 'string', 306 | enum: ['google', 'facebook', 'instagram', 'linkedin', 'twitter', 'tiktok', 'tiktok-business'], 307 | description: 'Social media platform' 308 | }, 309 | userId: { type: 'string', description: 'User ID initiating OAuth' }, 310 | page: { type: 'string', description: 'Page context' }, 311 | reconnect: { type: 'boolean', description: 'Whether this is a reconnection' } 312 | }, 313 | required: ['platform', 'userId'] 314 | } 315 | }, 316 | { 317 | name: 'get_platform_accounts', 318 | description: 'Get available accounts for a specific platform after OAuth', 319 | inputSchema: { 320 | type: 'object', 321 | properties: { 322 | platform: { 323 | type: 'string', 324 | enum: ['google', 'facebook', 'instagram', 'linkedin', 'twitter', 'tiktok', 'tiktok-business'], 325 | description: 'Social media platform' 326 | }, 327 | accountId: { type: 'string', description: 'OAuth account ID' } 328 | }, 329 | required: ['platform', 'accountId'] 330 | } 331 | } 332 | ]; 333 | } 334 | 335 | async executeTool(name: string, args: any): Promise { 336 | try { 337 | switch (name) { 338 | case 'search_social_posts': 339 | return await this.searchSocialPosts(args); 340 | case 'create_social_post': 341 | return await this.createSocialPost(args); 342 | case 'get_social_post': 343 | return await this.getSocialPost(args); 344 | case 'update_social_post': 345 | return await this.updateSocialPost(args); 346 | case 'delete_social_post': 347 | return await this.deleteSocialPost(args); 348 | case 'bulk_delete_social_posts': 349 | return await this.bulkDeleteSocialPosts(args); 350 | case 'get_social_accounts': 351 | return await this.getSocialAccounts(args); 352 | case 'delete_social_account': 353 | return await this.deleteSocialAccount(args); 354 | case 'get_social_categories': 355 | return await this.getSocialCategories(args); 356 | case 'get_social_category': 357 | return await this.getSocialCategory(args); 358 | case 'get_social_tags': 359 | return await this.getSocialTags(args); 360 | case 'get_social_tags_by_ids': 361 | return await this.getSocialTagsByIds(args); 362 | case 'start_social_oauth': 363 | return await this.startSocialOAuth(args); 364 | case 'get_platform_accounts': 365 | return await this.getPlatformAccounts(args); 366 | default: 367 | throw new Error(`Unknown tool: ${name}`); 368 | } 369 | } catch (error) { 370 | throw new Error(`Error executing ${name}: ${error}`); 371 | } 372 | } 373 | 374 | // Implementation methods 375 | private async searchSocialPosts(params: MCPSearchPostsParams) { 376 | const response = await this.ghlClient.searchSocialPosts({ 377 | type: params.type, 378 | accounts: params.accounts, 379 | skip: params.skip?.toString(), 380 | limit: params.limit?.toString(), 381 | fromDate: params.fromDate, 382 | toDate: params.toDate, 383 | includeUsers: params.includeUsers?.toString() || 'true', 384 | postType: params.postType 385 | }); 386 | 387 | return { 388 | success: true, 389 | posts: response.data?.posts || [], 390 | count: response.data?.count || 0, 391 | message: `Found ${response.data?.count || 0} social media posts` 392 | }; 393 | } 394 | 395 | private async createSocialPost(params: MCPCreatePostParams) { 396 | const response = await this.ghlClient.createSocialPost({ 397 | accountIds: params.accountIds, 398 | summary: params.summary, 399 | media: params.media, 400 | status: params.status, 401 | scheduleDate: params.scheduleDate, 402 | followUpComment: params.followUpComment, 403 | type: params.type, 404 | tags: params.tags, 405 | categoryId: params.categoryId, 406 | userId: params.userId 407 | }); 408 | 409 | return { 410 | success: true, 411 | post: response.data?.post, 412 | message: `Social media post created successfully` 413 | }; 414 | } 415 | 416 | private async getSocialPost(params: MCPGetPostParams) { 417 | const response = await this.ghlClient.getSocialPost(params.postId); 418 | 419 | return { 420 | success: true, 421 | post: response.data?.post, 422 | message: `Retrieved social media post ${params.postId}` 423 | }; 424 | } 425 | 426 | private async updateSocialPost(params: MCPUpdatePostParams) { 427 | const { postId, ...updateData } = params; 428 | const response = await this.ghlClient.updateSocialPost(postId, updateData); 429 | 430 | return { 431 | success: true, 432 | message: `Social media post ${postId} updated successfully` 433 | }; 434 | } 435 | 436 | private async deleteSocialPost(params: MCPDeletePostParams) { 437 | const response = await this.ghlClient.deleteSocialPost(params.postId); 438 | 439 | return { 440 | success: true, 441 | message: `Social media post ${params.postId} deleted successfully` 442 | }; 443 | } 444 | 445 | private async bulkDeleteSocialPosts(params: MCPBulkDeletePostsParams) { 446 | const response = await this.ghlClient.bulkDeleteSocialPosts({ postIds: params.postIds }); 447 | 448 | return { 449 | success: true, 450 | deletedCount: response.data?.deletedCount || 0, 451 | message: `${response.data?.deletedCount || 0} social media posts deleted successfully` 452 | }; 453 | } 454 | 455 | private async getSocialAccounts(params: MCPGetAccountsParams) { 456 | const response = await this.ghlClient.getSocialAccounts(); 457 | 458 | return { 459 | success: true, 460 | accounts: response.data?.accounts || [], 461 | groups: response.data?.groups || [], 462 | message: `Retrieved ${response.data?.accounts?.length || 0} social media accounts and ${response.data?.groups?.length || 0} groups` 463 | }; 464 | } 465 | 466 | private async deleteSocialAccount(params: MCPDeleteAccountParams) { 467 | const response = await this.ghlClient.deleteSocialAccount( 468 | params.accountId, 469 | params.companyId, 470 | params.userId 471 | ); 472 | 473 | return { 474 | success: true, 475 | message: `Social media account ${params.accountId} deleted successfully` 476 | }; 477 | } 478 | 479 | private async getSocialCategories(params: MCPGetCategoriesParams) { 480 | const response = await this.ghlClient.getSocialCategories( 481 | params.searchText, 482 | params.limit, 483 | params.skip 484 | ); 485 | 486 | return { 487 | success: true, 488 | categories: response.data?.categories || [], 489 | count: response.data?.count || 0, 490 | message: `Retrieved ${response.data?.count || 0} social media categories` 491 | }; 492 | } 493 | 494 | private async getSocialCategory(params: MCPGetCategoryParams) { 495 | const response = await this.ghlClient.getSocialCategory(params.categoryId); 496 | 497 | return { 498 | success: true, 499 | category: response.data?.category, 500 | message: `Retrieved social media category ${params.categoryId}` 501 | }; 502 | } 503 | 504 | private async getSocialTags(params: MCPGetTagsParams) { 505 | const response = await this.ghlClient.getSocialTags( 506 | params.searchText, 507 | params.limit, 508 | params.skip 509 | ); 510 | 511 | return { 512 | success: true, 513 | tags: response.data?.tags || [], 514 | count: response.data?.count || 0, 515 | message: `Retrieved ${response.data?.count || 0} social media tags` 516 | }; 517 | } 518 | 519 | private async getSocialTagsByIds(params: MCPGetTagsByIdsParams) { 520 | const response = await this.ghlClient.getSocialTagsByIds({ tagIds: params.tagIds }); 521 | 522 | return { 523 | success: true, 524 | tags: response.data?.tags || [], 525 | count: response.data?.count || 0, 526 | message: `Retrieved ${response.data?.count || 0} social media tags by IDs` 527 | }; 528 | } 529 | 530 | private async startSocialOAuth(params: MCPStartOAuthParams) { 531 | const response = await this.ghlClient.startSocialOAuth( 532 | params.platform, 533 | params.userId, 534 | params.page, 535 | params.reconnect 536 | ); 537 | 538 | return { 539 | success: true, 540 | oauthData: response.data, 541 | message: `OAuth process started for ${params.platform}` 542 | }; 543 | } 544 | 545 | private async getPlatformAccounts(params: MCPGetOAuthAccountsParams) { 546 | let response; 547 | 548 | switch (params.platform) { 549 | case 'google': 550 | response = await this.ghlClient.getGoogleBusinessLocations(params.accountId); 551 | break; 552 | case 'facebook': 553 | response = await this.ghlClient.getFacebookPages(params.accountId); 554 | break; 555 | case 'instagram': 556 | response = await this.ghlClient.getInstagramAccounts(params.accountId); 557 | break; 558 | case 'linkedin': 559 | response = await this.ghlClient.getLinkedInAccounts(params.accountId); 560 | break; 561 | case 'twitter': 562 | response = await this.ghlClient.getTwitterProfile(params.accountId); 563 | break; 564 | case 'tiktok': 565 | response = await this.ghlClient.getTikTokProfile(params.accountId); 566 | break; 567 | case 'tiktok-business': 568 | response = await this.ghlClient.getTikTokBusinessProfile(params.accountId); 569 | break; 570 | default: 571 | throw new Error(`Unsupported platform: ${params.platform}`); 572 | } 573 | 574 | return { 575 | success: true, 576 | platformAccounts: response.data, 577 | message: `Retrieved ${params.platform} accounts for OAuth ID ${params.accountId}` 578 | }; 579 | } 580 | } --------------------------------------------------------------------------------