Files
prowler/ui/lib/lighthouse/mcp-client.ts
Chandrapal Badshah b9bfdc1a5a feat: Integrate Prowler MCP to Lighthouse AI (#9255)
Co-authored-by: Chandrapal Badshah <12944530+Chan9390@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2025-12-17 10:10:43 +01:00

358 lines
9.4 KiB
TypeScript

import "server-only";
import type { StructuredTool } from "@langchain/core/tools";
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
import {
addBreadcrumb,
captureException,
captureMessage,
} from "@sentry/nextjs";
import { getAuthContext } from "@/lib/lighthouse/auth-context";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
/** Maximum number of retry attempts for MCP connection */
const MAX_RETRY_ATTEMPTS = 3;
/** Delay between retry attempts in milliseconds */
const RETRY_DELAY_MS = 2000;
/** Time after which to attempt reconnection if MCP is unavailable (5 minutes) */
const RECONNECT_INTERVAL_MS = 5 * 60 * 1000;
/**
* Delays execution for specified milliseconds
*/
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* MCP Client State
* Using a class-based singleton for better encapsulation and testability
*/
class MCPClientManager {
private client: MultiServerMCPClient | null = null;
private tools: StructuredTool[] = [];
private available = false;
private initializationAttempted = false;
private initializationPromise: Promise<void> | null = null;
private lastAttemptTime: number | null = null;
/**
* Validates the MCP server URL from environment variables
*/
private validateMCPServerUrl(): string | null {
const mcpServerUrl = process.env.PROWLER_MCP_SERVER_URL;
if (!mcpServerUrl) {
// MCP is optional - not an error if not configured
return null;
}
try {
new URL(mcpServerUrl);
return mcpServerUrl;
} catch {
captureMessage(`Invalid PROWLER_MCP_SERVER_URL: ${mcpServerUrl}`, {
level: "error",
tags: {
error_source: SentryErrorSource.MCP_CLIENT,
error_type: SentryErrorType.MCP_CONNECTION_ERROR,
},
});
return null;
}
}
/**
* Checks if enough time has passed to allow a reconnection attempt
*/
private shouldAttemptReconnection(): boolean {
if (!this.lastAttemptTime) return true;
if (this.available) return false;
const timeSinceLastAttempt = Date.now() - this.lastAttemptTime;
return timeSinceLastAttempt >= RECONNECT_INTERVAL_MS;
}
/**
* Injects auth headers for Prowler App tools
*/
private handleBeforeToolCall = ({
name,
args,
}: {
serverName: string;
name: string;
args?: unknown;
}) => {
// Only inject auth for Prowler App tools (user-specific data)
// Prowler Hub and Prowler Docs tools don't require authentication
if (!name.startsWith("prowler_app_")) {
return { args };
}
const accessToken = getAuthContext();
if (!accessToken) {
addBreadcrumb({
category: "mcp-client",
message: `Auth context missing for tool: ${name}`,
level: "warning",
});
return { args };
}
return {
args,
headers: {
Authorization: `Bearer ${accessToken}`,
},
};
};
/**
* Attempts to connect to the MCP server with retry logic
*/
private async connectWithRetry(mcpServerUrl: string): Promise<boolean> {
for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
try {
this.client = new MultiServerMCPClient({
additionalToolNamePrefix: "",
mcpServers: {
prowler: {
transport: "http",
url: mcpServerUrl,
defaultToolTimeout: 180000, // 3 minutes
},
},
beforeToolCall: this.handleBeforeToolCall,
});
this.tools = await this.client.getTools();
this.available = true;
addBreadcrumb({
category: "mcp-client",
message: `MCP client connected successfully (attempt ${attempt})`,
level: "info",
data: { toolCount: this.tools.length },
});
return true;
} catch (error) {
const isLastAttempt = attempt === MAX_RETRY_ATTEMPTS;
const errorMessage =
error instanceof Error ? error.message : String(error);
addBreadcrumb({
category: "mcp-client",
message: `MCP connection attempt ${attempt}/${MAX_RETRY_ATTEMPTS} failed`,
level: "warning",
data: { error: errorMessage },
});
if (isLastAttempt) {
const isConnectionError =
errorMessage.includes("ECONNREFUSED") ||
errorMessage.includes("ENOTFOUND") ||
errorMessage.includes("timeout") ||
errorMessage.includes("network");
captureException(error, {
tags: {
error_type: isConnectionError
? SentryErrorType.MCP_CONNECTION_ERROR
: SentryErrorType.MCP_DISCOVERY_ERROR,
error_source: SentryErrorSource.MCP_CLIENT,
},
level: "error",
contexts: {
mcp: {
server_url: mcpServerUrl,
attempts: MAX_RETRY_ATTEMPTS,
error_message: errorMessage,
is_connection_error: isConnectionError,
},
},
});
console.error(`[MCP Client] Failed to initialize: ${errorMessage}`);
} else {
await delay(RETRY_DELAY_MS);
}
}
}
return false;
}
async initialize(): Promise<void> {
// Return if already initialized and available
if (this.available) {
return;
}
// If initialization in progress, wait for it
if (this.initializationPromise) {
return this.initializationPromise;
}
// Check if we should attempt reconnection (rate limiting)
if (this.initializationAttempted && !this.shouldAttemptReconnection()) {
return;
}
this.initializationPromise = this.performInitialization();
try {
await this.initializationPromise;
} finally {
this.initializationPromise = null;
}
}
private async performInitialization(): Promise<void> {
this.initializationAttempted = true;
this.lastAttemptTime = Date.now();
// Validate URL before attempting connection
const mcpServerUrl = this.validateMCPServerUrl();
if (!mcpServerUrl) {
this.available = false;
this.client = null;
this.tools = [];
return;
}
// Attempt connection with retry logic
const connected = await this.connectWithRetry(mcpServerUrl);
if (!connected) {
this.available = false;
this.client = null;
this.tools = [];
}
}
getTools(): StructuredTool[] {
return this.tools;
}
getToolsByPattern(pattern: RegExp): StructuredTool[] {
return this.tools.filter((tool) => pattern.test(tool.name));
}
getToolByName(name: string): StructuredTool | undefined {
return this.tools.find((tool) => tool.name === name);
}
getToolsByNames(names: string[]): StructuredTool[] {
return this.tools.filter((tool) => names.includes(tool.name));
}
isAvailable(): boolean {
return this.available;
}
/**
* Gets detailed status of the MCP connection
* Useful for debugging and health monitoring
*/
getConnectionStatus(): {
available: boolean;
toolCount: number;
lastAttemptTime: number | null;
initializationAttempted: boolean;
canRetry: boolean;
} {
return {
available: this.available,
toolCount: this.tools.length,
lastAttemptTime: this.lastAttemptTime,
initializationAttempted: this.initializationAttempted,
canRetry: this.shouldAttemptReconnection(),
};
}
/**
* Forces a reconnection attempt to the MCP server
* Useful when the server has been restarted or connection was lost
*/
async reconnect(): Promise<boolean> {
// Reset state to allow reconnection
this.available = false;
this.initializationAttempted = false;
this.lastAttemptTime = null;
// Attempt to initialize
await this.initialize();
return this.available;
}
reset(): void {
this.client = null;
this.tools = [];
this.available = false;
this.initializationAttempted = false;
this.initializationPromise = null;
this.lastAttemptTime = null;
}
}
// Singleton instance using global for HMR support in development
const globalForMCP = global as typeof global & {
mcpClientManager?: MCPClientManager;
};
function getManager(): MCPClientManager {
if (!globalForMCP.mcpClientManager) {
globalForMCP.mcpClientManager = new MCPClientManager();
}
return globalForMCP.mcpClientManager;
}
// Public API - maintains backwards compatibility
export async function initializeMCPClient(): Promise<void> {
return getManager().initialize();
}
export function getMCPTools(): StructuredTool[] {
return getManager().getTools();
}
export function getMCPToolsByPattern(namePattern: RegExp): StructuredTool[] {
return getManager().getToolsByPattern(namePattern);
}
export function getMCPToolByName(name: string): StructuredTool | undefined {
return getManager().getToolByName(name);
}
export function getMCPToolsByNames(names: string[]): StructuredTool[] {
return getManager().getToolsByNames(names);
}
export function isMCPAvailable(): boolean {
return getManager().isAvailable();
}
export function getMCPConnectionStatus(): {
available: boolean;
toolCount: number;
lastAttemptTime: number | null;
initializationAttempted: boolean;
canRetry: boolean;
} {
return getManager().getConnectionStatus();
}
export async function reconnectMCPClient(): Promise<boolean> {
return getManager().reconnect();
}
export function resetMCPClient(): void {
getManager().reset();
}