Skip to main content
Glama

Debugg AI MCP

Official
by debugg-ai
tunnelManager.tsโ€ข11 kB
/** * Tunnel Management Service * Provides high-level tunnel management abstraction for localhost URLs */ import { Logger } from '../../utils/logger.js'; import { isLocalhostUrl, extractLocalhostPort, generateTunnelUrl } from '../../utils/urlParser.js'; import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'module'; // Use createRequire to avoid ES module resolution issues const require = createRequire(import.meta.url); let ngrokModule: any = null; async function getNgrok() { if (!ngrokModule) { try { ngrokModule = require('ngrok'); } catch (error) { throw new Error(`Failed to load ngrok module: ${error}`); } } return ngrokModule; } const logger = new Logger({ module: 'tunnelManager' }); export interface TunnelInfo { tunnelId: string; originalUrl: string; tunnelUrl: string; publicUrl: string; port: number; createdAt: number; lastAccessedAt: number; autoShutoffTimer?: NodeJS.Timeout; } export interface TunnelResult { url: string; tunnelId?: string; isLocalhost: boolean; } class TunnelManager { private activeTunnels = new Map<string, TunnelInfo>(); private initialized = false; private readonly TUNNEL_TIMEOUT_MS = 60 * 55 * 1000; // 55 minutes (we get billed by the hour, so dont want to run 1 min past the hour) private async ensureInitialized(): Promise<void> { if (!this.initialized) { try { const ngrok = await getNgrok(); // Try to get the API to check if ngrok is running const api = ngrok.getApi(); if (!api) { logger.debug('ngrok API not available, may need to start first tunnel'); } this.initialized = true; } catch (error) { logger.debug(`ngrok initialization check: ${error}`); this.initialized = true; // Continue anyway, let connection attempt handle the error } } } /** * Reset the auto-shutoff timer for a tunnel */ private resetTunnelTimer(tunnelInfo: TunnelInfo): void { // Clear existing timer if (tunnelInfo.autoShutoffTimer) { clearTimeout(tunnelInfo.autoShutoffTimer); } // Update last access time tunnelInfo.lastAccessedAt = Date.now(); // Set new timer tunnelInfo.autoShutoffTimer = setTimeout(async () => { logger.info(`Auto-shutting down tunnel ${tunnelInfo.tunnelId} after 60 minutes of inactivity`); try { await this.stopTunnel(tunnelInfo.tunnelId); } catch (error) { logger.error(`Failed to auto-shutdown tunnel ${tunnelInfo.tunnelId}:`, error); } }, this.TUNNEL_TIMEOUT_MS); logger.debug(`Reset timer for tunnel ${tunnelInfo.tunnelId}, will auto-shutdown at ${new Date(tunnelInfo.lastAccessedAt + this.TUNNEL_TIMEOUT_MS).toISOString()}`); } /** * Touch a tunnel to reset its timer (called when the tunnel is used) */ touchTunnel(tunnelId: string): void { const tunnelInfo = this.activeTunnels.get(tunnelId); if (tunnelInfo) { this.resetTunnelTimer(tunnelInfo); } } /** * Touch a tunnel by URL (convenience method) */ touchTunnelByUrl(url: string): void { const tunnelId = this.extractTunnelId(url); if (tunnelId) { this.touchTunnel(tunnelId); } } /** * Process a URL and create a tunnel if needed * Returns the URL to use (either original or tunneled) and tunnel metadata */ async processUrl(url: string, authToken?: string, specificTunnelId?: string): Promise<TunnelResult> { if (!isLocalhostUrl(url)) { return { url, isLocalhost: false }; } const port = extractLocalhostPort(url); if (!port) { throw new Error(`Could not extract port from localhost URL: ${url}`); } // Check if we already have a tunnel for this port const existingTunnel = this.findTunnelByPort(port); if (existingTunnel) { const publicUrl = generateTunnelUrl(url, existingTunnel.tunnelId); logger.info(`Reusing existing tunnel for port ${port}: ${publicUrl}`); return { url: publicUrl, tunnelId: existingTunnel.tunnelId, isLocalhost: true }; } // Create new tunnel if (!authToken) { throw new Error('Auth token required to create tunnel for localhost URL'); } const tunnelId = specificTunnelId || uuidv4(); const tunnelInfo = await this.createTunnel(url, port, tunnelId, authToken); return { url: tunnelInfo.publicUrl, tunnelId: tunnelInfo.tunnelId, isLocalhost: true }; } /** * Check if a URL is a tunnel URL */ isTunnelUrl(url: string): boolean { return url.includes('.ngrok.debugg.ai'); } /** * Extract tunnel ID from a tunnel URL */ extractTunnelId(url: string): string | null { const match = url.match(/https?:\/\/([^.]+)\.ngrok\.debugg\.ai/); return match ? match[1] : null; } /** * Get tunnel info by ID */ getTunnelInfo(tunnelId: string): TunnelInfo | undefined { return this.activeTunnels.get(tunnelId); } /** * Find tunnel by port */ private findTunnelByPort(port: number): TunnelInfo | undefined { for (const tunnel of this.activeTunnels.values()) { if (tunnel.port === port) { return tunnel; } } return undefined; } /** * Create a new tunnel */ private async createTunnel(originalUrl: string, port: number, tunnelId: string, authToken: string): Promise<TunnelInfo> { await this.ensureInitialized(); const tunnelDomain = `${tunnelId}.ngrok.debugg.ai`; logger.info(`Creating tunnel for localhost:${port} with domain ${tunnelDomain}`); try { // Get ngrok module dynamically const ngrok = await getNgrok(); // Set auth token first logger.debug(`Setting ngrok auth token`); await ngrok.authtoken({ authtoken: authToken }); // Create tunnel options const tunnelOptions = { proto: 'http' as const, addr: process.env.DOCKER_CONTAINER === "true" ? `host.docker.internal:${port}` : port, hostname: tunnelDomain, authtoken: authToken // Don't override configPath - let ngrok use its default configuration }; logger.debug(`Connecting tunnel with options: ${JSON.stringify({ ...tunnelOptions, authtoken: '[REDACTED]' })}`); // For ngrok v5, we might need to handle the connection differently let tunnelUrl: string; try { tunnelUrl = await ngrok.connect(tunnelOptions); } catch (connectError) { // If connection fails due to ngrok not running, try with different options if (connectError instanceof Error && connectError.message.includes('ECONNREFUSED')) { logger.info('ngrok daemon not running, attempting to start tunnel with minimal options'); const minimalOptions = { proto: 'http' as const, addr: process.env.DOCKER_CONTAINER === "true" ? `host.docker.internal:${port}` : port, authtoken: authToken }; tunnelUrl = await ngrok.connect(minimalOptions); } else { throw connectError; } } if (!tunnelUrl) { throw new Error('Failed to create tunnel'); } // Generate the public URL maintaining path, search, and hash from original const publicUrl = generateTunnelUrl(originalUrl, tunnelId); // Store tunnel info const now = Date.now(); const tunnelInfo: TunnelInfo = { tunnelId, originalUrl, tunnelUrl, publicUrl, port, createdAt: now, lastAccessedAt: now }; this.activeTunnels.set(tunnelId, tunnelInfo); // Start the auto-shutoff timer this.resetTunnelTimer(tunnelInfo); logger.info(`Tunnel created: ${publicUrl} -> localhost:${port}`); return tunnelInfo; } catch (error) { logger.error(`Failed to create tunnel for ${originalUrl}:`, error); // Try to provide more helpful error messages if (error instanceof Error && error.message.includes('ECONNREFUSED')) { throw new Error(`Failed to create tunnel: ngrok daemon not running or connection refused. Original error: ${error.message}`); } else if (error instanceof Error && error.message.includes('authtoken')) { throw new Error(`Failed to create tunnel: Invalid or missing auth token. Original error: ${error.message}`); } else { throw new Error(`Failed to create tunnel: ${error instanceof Error ? error.message : 'Unknown error'}`); } } } /** * Stop a tunnel by ID */ async stopTunnel(tunnelId: string): Promise<void> { const tunnelInfo = this.activeTunnels.get(tunnelId); if (!tunnelInfo) { logger.warn(`Tunnel ${tunnelId} not found for cleanup`); return; } try { // Clear the auto-shutoff timer if (tunnelInfo.autoShutoffTimer) { clearTimeout(tunnelInfo.autoShutoffTimer); } const ngrok = await getNgrok(); await ngrok.disconnect(tunnelInfo.tunnelUrl); this.activeTunnels.delete(tunnelId); logger.info(`Cleaned up tunnel: ${tunnelInfo.publicUrl}`); } catch (error) { logger.error(`Failed to cleanup tunnel ${tunnelId}:`, error); throw error; } } /** * Stop all active tunnels */ async stopAllTunnels(): Promise<void> { const tunnelIds = Array.from(this.activeTunnels.keys()); const cleanupPromises = tunnelIds.map(tunnelId => this.stopTunnel(tunnelId).catch(error => logger.error(`Failed to stop tunnel ${tunnelId}:`, error) ) ); await Promise.all(cleanupPromises); logger.info(`Stopped ${tunnelIds.length} tunnels`); } /** * Get all active tunnels */ getActiveTunnels(): TunnelInfo[] { return Array.from(this.activeTunnels.values()); } /** * Get tunnel status with timing information */ getTunnelStatus(tunnelId: string): { tunnel: TunnelInfo; age: number; timeSinceLastAccess: number; timeUntilAutoShutoff: number; } | null { const tunnel = this.activeTunnels.get(tunnelId); if (!tunnel) { return null; } const now = Date.now(); const age = now - tunnel.createdAt; const timeSinceLastAccess = now - tunnel.lastAccessedAt; const timeUntilAutoShutoff = Math.max(0, (tunnel.lastAccessedAt + this.TUNNEL_TIMEOUT_MS) - now); return { tunnel, age, timeSinceLastAccess, timeUntilAutoShutoff }; } /** * Get all tunnel statuses */ getAllTunnelStatuses() { const statuses = []; for (const tunnelId of this.activeTunnels.keys()) { const status = this.getTunnelStatus(tunnelId); if (status) { statuses.push(status); } } return statuses; } } // Singleton instance const tunnelManager = new TunnelManager(); export { tunnelManager }; export default TunnelManager;

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/debugg-ai/debugg-ai-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server