Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 59 additions & 76 deletions src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import express from 'express';
import cors from 'cors';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import {
CallToolRequestSchema,
ErrorCode,
Expand Down Expand Up @@ -115,12 +116,13 @@ class GHLMCPHttpServer {
* Setup Express middleware and configuration
*/
private setupExpress(): void {
// Enable CORS for ChatGPT integration
// Enable CORS for all MCP clients (Claude.ai, ChatGPT, etc.)
this.app.use(cors({
origin: ['https://chatgpt.com', 'https://chat.openai.com', 'http://localhost:*'],
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true
origin: '*',
methods: ['GET', 'POST', 'OPTIONS', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Mcp-Session-Id', 'Last-Event-ID'],
exposedHeaders: ['Mcp-Session-Id'],
credentials: false
}));

// Parse JSON requests
Expand Down Expand Up @@ -350,39 +352,73 @@ class GHLMCPHttpServer {
}
});

// SSE endpoint for ChatGPT MCP connection
// ─────────────────────────────────────────────────────────────────────────
// Streamable HTTP endpoint — required for Claude.ai MCP connector
// ─────────────────────────────────────────────────────────────────────────
this.app.post('/mcp', async (req: express.Request, res: express.Response) => {
console.log('[GHL MCP HTTP] Streamable HTTP POST /mcp');
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
});
await this.server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('[GHL MCP HTTP] Streamable HTTP error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'MCP transport error' });
}
}
});

this.app.get('/mcp', async (req: express.Request, res: express.Response) => {
console.log('[GHL MCP HTTP] Streamable HTTP GET /mcp');
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
});
await this.server.connect(transport);
await transport.handleRequest(req, res);
} catch (error) {
console.error('[GHL MCP HTTP] Streamable HTTP GET error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'MCP transport error' });
}
}
});
Comment on lines +358 to +388

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

MCP SDK StreamableHTTPServerTransport stateless mode server.connect pattern

💡 Result:

In the MCP TypeScript SDK Streamable HTTP server transport, “stateless mode” is enabled by constructing the transport with sessionIdGenerator set to undefined; in that mode no Mcp-Session-Id/session validation is used. [1][2] 1) How server.connect fits the pattern - You still use the normal MCP pattern: create an McpServer, create a StreamableHTTPServerTransport (NodeStreamableHTTPServerTransport or WebStandardStreamableHTTPServerTransport), and attach it with server.connect(transport). The SDK explicitly describes connect(transport) as “Attaches to the given transport, starts it, and starts listening for messages.” [3][4] 2) What “stateless mode” changes for request handling - Stateful mode: the transport generates/uses a session id, includes it in responses (including initialization), and rejects requests with invalid/missing session ids. [5][6] - Stateless mode: no session id is included in any responses and “No session validation is performed.” [7][5] Practical consequence: because connections/messages are managed per-request, Streamable HTTP transport start is a no-op (“connections are managed per-request”). [1] 3) The server.connect + stateless server “pattern” - Create the transport with sessionIdGenerator: undefined (this signals stateless mode) and then call server.connect(transport) once per server instance. [1][4] - Then route incoming HTTP requests to transport.handleRequest(...). In the SDK’s stateless example, the server.connect(transport) call happens inside the HTTP route handler before calling transport.handleRequest(req, res, req.body), and cleanup happens on request close. [8] 4) Minimal TypeScript shape (Node.js) - Stateless transport: set sessionIdGenerator to undefined. [1][4] - Connect the MCP server: await server.connect(transport). [4][3] - Handle each HTTP request with transport.handleRequest(...). [8][1] Example (structurally matching the SDK example): - const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); 5) “server.connect pattern” with stateless and multiple requests - Because stateless mode doesn’t include/validate a Mcp-Session-Id, you should treat each HTTP request independently (no reusable session-based transport state). The transport/session-id generator behavior is what drives this. [7][5] Caveat (important): - Some SDK versions/history discussed issues around stateless behavior and reusing initialized transport state; the recommended approach in the current examples is to create/connect the right transport context for handling the request, and then close/cleanup after the request. [9][8] Bottom line: For StreamableHTTPServerTransport/NodeStreamableHTTPServerTransport stateless mode, “stateless” = sessionIdGenerator undefined (no Mcp-Session-Id, no session validation), while the overall server.connect attachment pattern stays the same: server.connect(transport) + per-request transport.handleRequest(...). [1][5][4][8]

Citations:


🏁 Script executed:

# Search for StreamableHTTPServerTransport class definition and any cleanup methods
rg -A 20 "class StreamableHTTPServerTransport" --type ts --type js

# Also search for any cleanup/close/disconnect patterns
rg "transport\.(close|disconnect|cleanup)" --type ts --type js

Repository: mastanley13/GoHighLevel-MCP

Length of output: 53


🏁 Script executed:

# Check if there's a connect() method signature and what it does
rg "connect\(" src/http-server.ts -B 2 -A 5

Repository: mastanley13/GoHighLevel-MCP

Length of output: 1161


🏁 Script executed:

# Look for any imports or types related to StreamableHTTPServerTransport
rg "StreamableHTTPServerTransport" --type ts --type js -B 2 -A 2

Repository: mastanley13/GoHighLevel-MCP

Length of output: 1058


🏁 Script executed:

# Search for any close/disconnect patterns on transport or server
rg "(transport|this\.server)\.(close|disconnect|end)" --type ts --type js

# Check if there's any cleanup after handleRequest in the codebase
rg -A 10 "handleRequest" src/http-server.ts

Repository: mastanley13/GoHighLevel-MCP

Length of output: 936


🏁 Script executed:

# Look at the SSE endpoint pattern for comparison to see how cleanup is handled
rg -B 5 -A 15 "SSEServerTransport" src/http-server.ts

Repository: mastanley13/GoHighLevel-MCP

Length of output: 1805


🌐 Web query:

StreamableHTTPServerTransport close cleanup resource management per-request

💡 Result:

In the Model Context Protocol (MCP) SDKs, StreamableHTTPServerTransport manages resources through both transport-level lifecycle methods and granular per-request cleanup mechanisms [1][2][3][4]. Per-request resource management is primarily handled via SSE stream management [3][4]: - Request-specific cleanup: The transport provides methods such as closeSSEStream(requestId) (in TypeScript) or closeSseStream(requestId) (in Kotlin), which close the specific SSE stream associated with a request [1][2][3][4]. This action triggers client reconnection, which is often used to implement polling behavior for long-running operations [1][2][3][4]. - Internal Mapping: The transport maintains internal mappings (e.g., _requestToStreamMapping and _streamMapping) to track and clean up these individual stream resources when requested or when an error occurs [3][4]. Lifecycle and Session management: - Transport-level close: The close method shuts down the transport and all its active connections [1]. This is used for broader resource reclamation, such as during server shutdown [5]. - Session-level cleanup: The transport supports an onsessionclosed callback, which is invoked when a session is closed (e.g., via an HTTP DELETE request) [3][4][6]. This callback is specifically designed for developers to perform custom cleanup of resources associated with a particular session [2][3][4]. - Distinction: It is important to distinguish between closing a transport and closing a session; for instance, if handling HTTP requests from multiple nodes, one might close a specific StreamableHTTPServerTransport instance after a single request finishes, while keeping the broader session active [2][3][4]. In practice, developers should leverage the onsessionclosed callback for session-associated resource management and use the provided SSE stream closing methods for granular, per-request stream control [2][3][4].

Citations:


Add explicit cleanup after handling stateless HTTP requests.

The /mcp POST and GET endpoints call server.connect(transport) on each request but do not clean up the transport resources afterward. While the pattern of calling connect() per-request is documented in the MCP SDK examples for stateless mode, the SDK also expects explicit resource management—either by calling transport.close() after handleRequest() completes or by leveraging the onsessionclosed callback for cleanup.

Without cleanup, repeated calls to server.connect() without releasing the previous transport may accumulate internal transport state and connections, leading to resource leaks or undefined behavior in the MCP Server instance.

Add await transport.close() after transport.handleRequest() completes in both handlers, or implement proper session lifecycle management using the SDK's cleanup callbacks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/http-server.ts` around lines 358 - 388, Both the POST and GET route
handlers for '/mcp' are missing explicit cleanup of the
StreamableHTTPServerTransport resource after the request is handled. Add await
transport.close() after the transport.handleRequest() call completes in both the
POST handler (which calls handleRequest with req, res, and req.body) and the GET
handler (which calls handleRequest with req and res). Place the close() call
after the handleRequest() await but before exiting the try block in each handler
to ensure proper resource cleanup on every request.


this.app.delete('/mcp', async (req: express.Request, res: express.Response) => {
console.log('[GHL MCP HTTP] Streamable HTTP DELETE /mcp (session close)');
res.status(200).json({ message: 'Session closed' });
});

// ─────────────────────────────────────────────────────────────────────────
// Legacy SSE endpoint — kept for backward compatibility
// ─────────────────────────────────────────────────────────────────────────
const handleSSE = async (req: express.Request, res: express.Response) => {
const sessionId = req.query.sessionId || 'unknown';
console.log(`[GHL MCP HTTP] New SSE connection from: ${req.ip}, sessionId: ${sessionId}, method: ${req.method}`);

try {
// Create SSE transport (this will set the headers)
const transport = new SSEServerTransport('/sse', res);

// Connect MCP server to transport
await this.server.connect(transport);

console.log(`[GHL MCP HTTP] SSE connection established for session: ${sessionId}`);

// Handle client disconnect
req.on('close', () => {
console.log(`[GHL MCP HTTP] SSE connection closed for session: ${sessionId}`);
});

} catch (error) {
console.error(`[GHL MCP HTTP] SSE connection error for session ${sessionId}:`, error);

// Only send error response if headers haven't been sent yet
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to establish SSE connection' });
} else {
// If headers were already sent, close the connection
res.end();
}
}
};

// Handle both GET and POST for SSE (MCP protocol requirements)
this.app.get('/sse', handleSSE);
this.app.post('/sse', handleSSE);

Expand All @@ -396,10 +432,11 @@ class GHLMCPHttpServer {
health: '/health',
capabilities: '/capabilities',
tools: '/tools',
mcp: '/mcp',
sse: '/sse'
},
tools: this.getToolsCount(),
documentation: 'https://github.com/your-repo/ghl-mcp-server'
documentation: 'https://github.com/potato-aesthetic-cyber/GoHighLevel-MCP'
});
});
}
Expand Down Expand Up @@ -451,43 +488,29 @@ class GHLMCPHttpServer {
*/
private isContactTool(toolName: string): boolean {
const contactToolNames = [
// Basic Contact Management
'create_contact', 'search_contacts', 'get_contact', 'update_contact',
'add_contact_tags', 'remove_contact_tags', 'delete_contact',
// Task Management
'get_contact_tasks', 'create_contact_task', 'get_contact_task', 'update_contact_task',
'delete_contact_task', 'update_task_completion',
// Note Management
'get_contact_notes', 'create_contact_note', 'get_contact_note', 'update_contact_note',
'delete_contact_note',
// Advanced Operations
'upsert_contact', 'get_duplicate_contact', 'get_contacts_by_business', 'get_contact_appointments',
// Bulk Operations
'bulk_update_contact_tags', 'bulk_update_contact_business',
// Followers Management
'add_contact_followers', 'remove_contact_followers',
// Campaign Management
'add_contact_to_campaign', 'remove_contact_from_campaign', 'remove_contact_from_all_campaigns',
// Workflow Management
'add_contact_to_workflow', 'remove_contact_from_workflow'
];
return contactToolNames.includes(toolName);
}

private isConversationTool(toolName: string): boolean {
const conversationToolNames = [
// Basic conversation operations
'send_sms', 'send_email', 'search_conversations', 'get_conversation',
'create_conversation', 'update_conversation', 'delete_conversation', 'get_recent_messages',
// Message management
'get_email_message', 'get_message', 'upload_message_attachments', 'update_message_status',
// Manual message creation
'add_inbound_message', 'add_outbound_call',
// Call recordings & transcriptions
'get_message_recording', 'get_message_transcription', 'download_transcription',
// Scheduling management
'cancel_scheduled_message', 'cancel_scheduled_email',
// Live chat features
'live_chat_typing'
];
return conversationToolNames.includes(toolName);
Expand All @@ -512,21 +535,14 @@ class GHLMCPHttpServer {

private isCalendarTool(toolName: string): boolean {
const calendarToolNames = [
// Calendar Groups Management
'get_calendar_groups', 'create_calendar_group', 'validate_group_slug',
'update_calendar_group', 'delete_calendar_group', 'disable_calendar_group',
// Calendars
'get_calendars', 'create_calendar', 'get_calendar', 'update_calendar', 'delete_calendar',
// Events and Appointments
'get_calendar_events', 'get_free_slots', 'create_appointment', 'get_appointment',
'update_appointment', 'delete_appointment',
// Appointment Notes
'get_appointment_notes', 'create_appointment_note', 'update_appointment_note', 'delete_appointment_note',
// Calendar Resources
'get_calendar_resources', 'get_calendar_resource_by_id', 'update_calendar_resource', 'delete_calendar_resource',
// Calendar Notifications
'get_calendar_notifications', 'create_calendar_notification', 'update_calendar_notification', 'delete_calendar_notification',
// Blocked Slots
'create_block_slot', 'update_block_slot', 'get_blocked_slots', 'delete_blocked_slot'
];
return calendarToolNames.includes(toolName);
Expand All @@ -542,55 +558,37 @@ class GHLMCPHttpServer {

private isLocationTool(toolName: string): boolean {
const locationToolNames = [
// Location Management
'search_locations', 'get_location', 'create_location', 'update_location', 'delete_location',
// Location Tags
'get_location_tags', 'create_location_tag', 'get_location_tag', 'update_location_tag', 'delete_location_tag',
// Location Tasks
'search_location_tasks',
// Custom Fields
'get_location_custom_fields', 'create_location_custom_field', 'get_location_custom_field',
'update_location_custom_field', 'delete_location_custom_field',
// Custom Values
'get_location_custom_values', 'create_location_custom_value', 'get_location_custom_value',
'update_location_custom_value', 'delete_location_custom_value',
// Templates
'get_location_templates', 'delete_location_template',
// Timezones
'get_timezones'
];
return locationToolNames.includes(toolName);
}

private isEmailISVTool(toolName: string): boolean {
const emailISVToolNames = [
'verify_email'
];
return emailISVToolNames.includes(toolName);
return ['verify_email'].includes(toolName);
}

private isSocialMediaTool(toolName: string): boolean {
const socialMediaToolNames = [
// Post Management
'search_social_posts', 'create_social_post', 'get_social_post', 'update_social_post',
'delete_social_post', 'bulk_delete_social_posts',
// Account Management
'get_social_accounts', 'delete_social_account',
// CSV Operations
'upload_social_csv', 'get_csv_upload_status', 'set_csv_accounts',
// Categories & Tags
'get_social_categories', 'get_social_category', 'get_social_tags', 'get_social_tags_by_ids',
// OAuth Integration
'start_social_oauth', 'get_platform_accounts'
];
return socialMediaToolNames.includes(toolName);
}

private isMediaTool(toolName: string): boolean {
const mediaToolNames = [
'get_media_files', 'upload_media_file', 'delete_media_file'
];
return mediaToolNames.includes(toolName);
return ['get_media_files', 'upload_media_file', 'delete_media_file'].includes(toolName);
}

private isObjectTool(toolName: string): boolean {
Expand Down Expand Up @@ -622,18 +620,11 @@ class GHLMCPHttpServer {
}

private isWorkflowTool(toolName: string): boolean {
const workflowToolNames = [
'ghl_get_workflows'
];
return workflowToolNames.includes(toolName);
return ['ghl_get_workflows'].includes(toolName);
}

private isSurveyTool(toolName: string): boolean {
const surveyToolNames = [
'ghl_get_surveys',
'ghl_get_survey_submissions'
];
return surveyToolNames.includes(toolName);
return ['ghl_get_surveys', 'ghl_get_survey_submissions'].includes(toolName);
}

private isStoreTool(toolName: string): boolean {
Expand Down Expand Up @@ -668,9 +659,7 @@ class GHLMCPHttpServer {
private async testGHLConnection(): Promise<void> {
try {
console.log('[GHL MCP HTTP] Testing GHL API connection...');

const result = await this.ghlClient.testConnection();

console.log('[GHL MCP HTTP] ✅ GHL API connection successful');
console.log(`[GHL MCP HTTP] Connected to location: ${result.data?.locationId}`);
} catch (error) {
Expand All @@ -687,16 +676,15 @@ class GHLMCPHttpServer {
console.log('=========================================');

try {
// Test GHL API connection
await this.testGHLConnection();

// Start HTTP server
this.app.listen(this.port, '0.0.0.0', () => {
console.log('✅ GoHighLevel MCP HTTP Server started successfully!');
console.log(`🌐 Server running on: http://0.0.0.0:${this.port}`);
console.log(`🔗 MCP Endpoint: http://0.0.0.0:${this.port}/mcp`);
console.log(`🔗 SSE Endpoint: http://0.0.0.0:${this.port}/sse`);
console.log(`📋 Tools Available: ${this.getToolsCount().total}`);
console.log('🎯 Ready for ChatGPT integration!');
console.log('🎯 Ready for Claude.ai and ChatGPT integration!');
console.log('=========================================');
});

Expand Down Expand Up @@ -725,21 +713,16 @@ function setupGracefulShutdown(): void {
*/
async function main(): Promise<void> {
try {
// Setup graceful shutdown
setupGracefulShutdown();

// Create and start HTTP server
const server = new GHLMCPHttpServer();
await server.start();

} catch (error) {
console.error('💥 Fatal error:', error);
process.exit(1);
}
}

// Start the server
main().catch((error) => {
console.error('Unhandled error:', error);
process.exit(1);
});
});