import cors from 'cors'; import express from 'express'; import http from 'node:http'; import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import type { FastMCP } from 'fastmcp'; import type { CorsOptions } from 'cors'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { findAvailablePortSequential } from './findAvailablePort.js'; const execAsync = promisify(exec); const INTERNAL_PORT_MIN = 45000; const INTERNAL_PORT_MAX = 46000; const MAX_RECOMMENDED_SERVERS = 5; // Allow override via env var, but default to null (use random port) // Validate parsed value to avoid NaN/0 breaking startup const envLoopbackRaw = process.env.FASTMCP_LOOPBACK_PORT; const parsedLoopback = envLoopbackRaw ? Number.parseInt(envLoopbackRaw, 10) : NaN; const DEFAULT_LOOPBACK_PORT = Number.isFinite(parsedLoopback) && parsedLoopback > 0 ? parsedLoopback : null; const JSON_CONTENT_TYPE_REGEX = /application\/json/i; const MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024; // 10 MB limit to prevent excessive buffering type JsonRpcRequestBody = { jsonrpc?: string; method?: string; id?: unknown; params?: Record; }; const extractJsonContentType = (header: string | string[] | undefined): string | undefined => { if (!header) { return undefined; } if (Array.isArray(header)) { return header[0]; } return header; }; const isJsonContentType = (header: string | string[] | undefined): boolean => { const value = extractJsonContentType(header); return typeof value === 'string' && JSON_CONTENT_TYPE_REGEX.test(value); }; const toBuffer = (chunk: Buffer | string): Buffer => { return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); }; const sendJsonRpcError = ( res: ServerResponse, requestId: unknown, toolName: string ) => { if (res.headersSent) { return; } const payload = { jsonrpc: '2.0', id: requestId ?? null, error: { code: -32601, message: `Tool '${toolName}' is not available. Use the 'conversation.*' tools exposed by AI Universe instead.` } }; const body = JSON.stringify(payload); res.statusCode = 400; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Length', Buffer.byteLength(body).toString()); res.end(body); }; export interface LoggerLike { info?(message: string): void; warn?(message: string): void; error?(message: string, error?: unknown): void; } /** * Count running server instances by looking for tsx/node processes running server.ts */ async function countRunningServers(): Promise { try { const { stdout } = await execAsync( 'ps aux | grep -E "(tsx|node).*server\\.ts" | grep -v grep | wc -l' ); return Number.parseInt(stdout.trim(), 10) || 0; } catch { return 0; } } export interface StartFastMcpHttpProxyOptions { server: FastMCP; listenPort: number; host?: string; logger?: LoggerLike; corsOptions?: CorsOptions; proxyPath?: string; httpModule?: typeof http; startPort?: number; portSearchDirection?: 'up' | 'down'; configureApp?: (app: any) => void; loopbackPort?: number; allowLoopbackFallback?: boolean; /** * Optional callback to inject authenticated context into MCP request body. * Called before forwarding the request to FastMCP. * Use this to extract userId from Authorization header and inject it into params. * * @param headers - Request headers (e.g., for Authorization token) * @param parsedBody - Parsed JSON-RPC request body * @returns Modified body with injected auth context, or original body if no changes */ injectAuthContext?: (headers: IncomingMessage['headers'], parsedBody: JsonRpcRequestBody) => Promise; } export interface StartFastMcpHttpProxyResult { app: express.Express; expressServer: http.Server; mcpPort: number; } /** * Start a FastMCP server using the HTTP stream transport and expose it via an Express proxy. * By default, uses random port selection for the internal FastMCP server (45000-45100 range). * Override with FASTMCP_LOOPBACK_PORT env var or loopbackPort parameter if needed. */ export async function startFastMcpHttpProxy({ server, listenPort, host = 'localhost', logger, corsOptions, proxyPath = '/mcp', httpModule = http, startPort = INTERNAL_PORT_MIN, portSearchDirection = 'up', configureApp, loopbackPort, allowLoopbackFallback = true, // Changed default to true for random port behavior injectAuthContext }: StartFastMcpHttpProxyOptions): Promise { const app = express(); if (corsOptions) { app.use(cors(corsOptions)); } configureApp?.(app); // Set up proxy handler first (will handle connection errors gracefully if MCP server isn't ready) // Use a mutable reference so we can update mcpPort after MCP server starts let mcpPort = 0; // Sentinel value until MCP server starts app.use(proxyPath, (req, res) => { if (mcpPort <= 0) { logger?.warn?.('Received MCP request before FastMCP server was ready'); if (!res.headersSent) { const payload = { error: 'MCP server not ready', message: 'FastMCP server is still starting. Please retry shortly.' }; const body = JSON.stringify(payload); res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Length', Buffer.byteLength(body).toString()); res.end(body); } req.resume(); return; } const requestChunks: Buffer[] = []; let accumulatedLength = 0; let bodyRejected = false; req.on('data', (chunk: Buffer | string) => { if (bodyRejected) { return; } const bufferChunk = toBuffer(chunk); accumulatedLength += bufferChunk.length; if (accumulatedLength > MAX_REQUEST_BODY_SIZE) { bodyRejected = true; logger?.warn?.( `Rejected MCP proxy request exceeding ${MAX_REQUEST_BODY_SIZE} bytes from ${req.socket.remoteAddress ?? 'unknown'}` ); if (!res.headersSent) { const payload = { error: 'Payload Too Large', message: `Request body exceeds ${MAX_REQUEST_BODY_SIZE} byte limit` }; const responseBody = JSON.stringify(payload); res.statusCode = 413; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Length', Buffer.byteLength(responseBody).toString()); res.end(responseBody); } req.destroy(); return; } requestChunks.push(bufferChunk); }); req.on('error', (error) => { if (bodyRejected) { return; } logger?.error?.('Request error', error); if (!res.headersSent) { res.statusCode = 500; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'Request stream error', message: error instanceof Error ? error.message : String(error) })); } }); req.on('end', async () => { if (bodyRejected) { return; } const bodyBuffer = requestChunks.length > 0 ? Buffer.concat(requestChunks) : Buffer.alloc(0); const contentType = extractJsonContentType(req.headers['content-type']); let parsedBody: JsonRpcRequestBody | null = null; let shouldFilterToolsList = false; // SECURITY: Always parse body if present, regardless of Content-Type header // This prevents bypass attacks via missing/incorrect Content-Type if (bodyBuffer.length > 0) { try { parsedBody = JSON.parse(bodyBuffer.toString('utf8')) as JsonRpcRequestBody; } catch (parseError) { const message = parseError instanceof Error ? parseError.message : String(parseError); logger?.warn?.(`Failed to parse MCP proxy request body as JSON: ${message}`); // Reject malformed JSON to prevent bypass attempts res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error: Invalid JSON' }, id: null })); return; } } const requestId = parsedBody?.id ?? null; if (parsedBody?.method === 'tools/call') { const params = parsedBody.params ?? {}; const toolName = typeof params.name === 'string' ? params.name : ''; if (toolName.startsWith('convo.')) { logger?.warn?.(`Blocked direct convo tool invocation: ${toolName}`); sendJsonRpcError(res, parsedBody.id, toolName); return; } } else if (parsedBody?.method === 'tools/list') { shouldFilterToolsList = true; } const logContext = { toolName: typeof parsedBody?.params?.name === 'string' ? parsedBody.params.name : undefined, requestId }; // SECURITY FIX: Inject authenticated user context before forwarding to FastMCP // This allows tools to use the authenticated userId instead of trusting payload userId let modifiedBody = parsedBody; if (injectAuthContext && parsedBody) { try { const injectedBody = await injectAuthContext(req.headers, parsedBody); if (injectedBody) { modifiedBody = injectedBody; logger?.info?.(`Injected authenticated user context into MCP request (tool=${logContext.toolName ?? 'unknown'}, requestId=${logContext.requestId ?? 'null'})`); } } catch (error) { logger?.error?.(`Failed to inject auth context for tool=${logContext.toolName ?? 'unknown'} requestId=${logContext.requestId ?? 'null'}: ${error instanceof Error ? error.message : String(error)}`); // SECURITY: Return generic error message to avoid leaking implementation details res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32001, message: 'Authentication failed' }, id: parsedBody.id ?? null })); return; } } // Use modified body if auth context was injected, otherwise use original body const finalBody = modifiedBody && modifiedBody !== parsedBody ? Buffer.from(JSON.stringify(modifiedBody)) : bodyBuffer; // Update Content-Length header if body was modified const proxyHeaders = { ...req.headers }; if (finalBody !== bodyBuffer) { proxyHeaders['content-length'] = String(finalBody.length); } const proxy = httpModule.request({ hostname: '127.0.0.1', port: mcpPort, path: req.originalUrl ?? proxyPath, method: req.method, headers: proxyHeaders }, (proxyRes: IncomingMessage) => { // Filter out CORS headers from FastMCP response to prevent wildcard override // Express CORS middleware (applied earlier) already set the correct CORS headers const filteredHeaders = Object.entries(proxyRes.headers).reduce((acc, [key, value]) => { if (!key.toLowerCase().startsWith('access-control-')) { acc[key] = value; } return acc; }, {} as Record); const responseContentType = extractJsonContentType(proxyRes.headers['content-type']); const shouldFilterResponse = shouldFilterToolsList && isJsonContentType(responseContentType); if (!shouldFilterResponse) { res.writeHead(proxyRes.statusCode ?? 200, filteredHeaders); proxyRes.pipe(res, { end: true }); return; } const responseChunks: Buffer[] = []; proxyRes.on('data', (chunk: Buffer | string) => { responseChunks.push(toBuffer(chunk)); }); proxyRes.on('error', (error) => { logger?.error?.('Proxy response error', error); if (!res.headersSent) { res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'Bad Gateway', message: 'Failed to proxy request to MCP server', details: error instanceof Error ? error.message : String(error) })); } }); proxyRes.on('end', () => { let filteredBodyBuffer = Buffer.concat(responseChunks); try { const parsedResponse = JSON.parse(filteredBodyBuffer.toString('utf8')) as { result?: { tools?: Array> }; }; if (parsedResponse?.result?.tools && Array.isArray(parsedResponse.result.tools)) { const filteredTools = parsedResponse.result.tools.filter((tool) => { const name = typeof tool?.name === 'string' ? tool.name : ''; return !name.startsWith('convo.'); }); if (filteredTools.length !== parsedResponse.result.tools.length) { parsedResponse.result.tools = filteredTools; logger?.info?.('Filtered convo.* tools from tools/list response'); } filteredBodyBuffer = Buffer.from(JSON.stringify(parsedResponse)); } } catch (error) { const message = error instanceof Error ? error.message : String(error); logger?.warn?.(`Failed to filter tools/list response body: ${message}`); } const headersWithLength = { ...filteredHeaders }; for (const headerName of Object.keys(headersWithLength)) { const normalized = headerName.toLowerCase(); if (normalized === 'transfer-encoding' || normalized === 'content-length') { delete headersWithLength[headerName]; } } headersWithLength['Content-Length'] = Buffer.byteLength(filteredBodyBuffer).toString(); res.writeHead(proxyRes.statusCode ?? 200, headersWithLength); res.end(filteredBodyBuffer); }); }); proxy.on('error', (error) => { logger?.error?.('Proxy error', error); if (!res.headersSent) { res.statusCode = 502; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ error: 'Bad Gateway', message: 'Failed to proxy request to MCP server', details: error instanceof Error ? error.message : String(error) })); } }); if (finalBody.length > 0) { proxy.write(finalBody); } proxy.end(); }); }); // Start Express server and wait for it to be fully listening BEFORE proceeding // This ensures Cloud Run health checks pass immediately when they hit port 8080 const expressServer = await new Promise>((resolve, reject) => { let serverRef: ReturnType | undefined; const cleanup = () => { if (!serverRef) { return; } serverRef.off?.('error', handleError); serverRef.off?.('listening', handleListening); serverRef.removeListener?.('error', handleError); serverRef.removeListener?.('listening', handleListening); }; const handleListening = function (this: ReturnType) { serverRef = this; logger?.info?.(`✅ Express server listening on ${host}:${listenPort}`); cleanup(); resolve(this); }; const handleError = (error: unknown) => { logger?.error?.('❌ Express server failed to start:', error); cleanup(); reject(error); }; serverRef = app.listen(listenPort, host, handleListening as () => void); serverRef.on('error', handleError); }); try { // Now start the MCP server (Express is already listening, so Cloud Run health checks will pass) // The proxy handler will gracefully return 502 until MCP is fully initialized logger?.info?.('🚀 Starting internal MCP server...'); // Check server count and warn if too many instances const serverCount = await countRunningServers(); if (serverCount > MAX_RECOMMENDED_SERVERS) { logger?.warn?.( `⚠️ Warning: ${serverCount} server instances detected (recommended max: ${MAX_RECOMMENDED_SERVERS}). ` + `Consider stopping unused instances to free resources.` ); } // Use random port selection by default (null means find available port) // Override with explicit loopbackPort param or FASTMCP_LOOPBACK_PORT env var const desiredPort = Number.isFinite(loopbackPort) && loopbackPort && loopbackPort > 0 ? loopbackPort : (DEFAULT_LOOPBACK_PORT ?? null); let lastError: unknown; let attempts = 0; const tryStart = async (port: number) => { try { await server.start({ transportType: 'httpStream', httpStream: { port, host: '127.0.0.1', stateless: true, enableJsonResponse: true } }); logger?.info?.(`✅ FastMCP internal server started on port ${port}`); return port; } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message.includes('EADDRINUSE')) { throw Object.assign(error instanceof Error ? error : new Error(message), { code: 'EADDRINUSE' }); } throw error; } }; // If no explicit port specified (desiredPort is null), use random port selection if (desiredPort === null) { logger?.info?.(`🎲 Using random port selection for internal FastMCP server (${INTERNAL_PORT_MIN}-${INTERNAL_PORT_MAX})`); const span = INTERNAL_PORT_MAX - INTERNAL_PORT_MIN + 1; // Try up to 20 different random starting ports while (attempts < 20) { try { // Generate a random starting port in the range for this attempt const randomStartPort = INTERNAL_PORT_MIN + Math.floor(Math.random() * span); // Try to find an available port - first search up, then down let candidatePort: number | null = null; try { // Try searching upward from random start const upwardPort = await findAvailablePortSequential(randomStartPort, 'up'); if (upwardPort >= INTERNAL_PORT_MIN && upwardPort <= INTERNAL_PORT_MAX) { candidatePort = upwardPort; } } catch { // Upward search failed, will try downward } if (candidatePort === null) { // Try searching downward from random start try { const downwardPort = await findAvailablePortSequential(randomStartPort, 'down'); if (downwardPort >= INTERNAL_PORT_MIN && downwardPort <= INTERNAL_PORT_MAX) { candidatePort = downwardPort; } } catch { // Both directions failed, will retry with new random start } } // If we couldn't find a port in either direction, try next random start if (candidatePort === null) { attempts += 1; logger?.info?.(`No available port found in range from ${randomStartPort}, retrying with new random start (attempt ${attempts}/20)`); continue; } // Try to start FastMCP on the validated port mcpPort = await tryStart(candidatePort); // Success! Break out of the loop break; } catch (error) { const message = error instanceof Error ? error.message : String(error); // If it's not a port-in-use error, throw immediately if (!message.includes('EADDRINUSE')) { throw error; } // Port was taken (race condition), try next random port lastError = error; attempts += 1; logger?.info?.(`Port collision, retrying with new random start (attempt ${attempts}/20)`); } } if (attempts >= 20) { throw lastError ?? new Error(`Unable to find available port in range ${INTERNAL_PORT_MIN}-${INTERNAL_PORT_MAX} after 20 attempts`); } } else { // Explicit port specified - try it first, then fallback if allowed try { mcpPort = await tryStart(desiredPort); } catch (error) { lastError = error; const isAddrInUse = typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'EADDRINUSE'; if (!isAddrInUse || !allowLoopbackFallback) { const errorMessage = isAddrInUse ? `Port ${mcpPort} is already in use. Free the port (e.g. run "lsof -ti:${mcpPort} | xargs kill -9") or use random port selection.` : (error instanceof Error ? error.message : String(error)); throw new Error(errorMessage); } // Fallback to random port selection logger?.warn?.(`⚠️ Port ${desiredPort} in use, falling back to random port selection`); let searchPort = startPort; while (attempts < 20) { let candidatePort: number | undefined; try { candidatePort = await findAvailablePortSequential(searchPort, portSearchDirection); mcpPort = await tryStart(candidatePort); break; } catch (startError) { const message = startError instanceof Error ? startError.message : String(startError); if (!message.includes('EADDRINUSE')) { throw startError; } lastError = startError; attempts += 1; const nextBase = candidatePort ?? searchPort; searchPort = portSearchDirection === 'up' ? nextBase + 1 : Math.max(nextBase - 1, INTERNAL_PORT_MIN); } } if (attempts >= 20) { throw lastError ?? new Error('Unable to find available port for FastMCP server'); } } } return { app, expressServer, mcpPort }; } catch (error) { await new Promise((resolve) => { expressServer.close((closeError?: Error | null) => { if (closeError) { logger?.error?.('Error while closing Express server after MCP startup failure', closeError); } else { logger?.info?.('Express server shut down after MCP startup failure'); } resolve(); }); }); throw error; } }