From a0f09fba28db3357d279abaeb9c7c5eb7af05e90 Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:46:30 +0200 Subject: [PATCH] fix: resolve HTTP server URL handling and security issues (#41, #42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add intelligent URL detection supporting BASE_URL, PUBLIC_URL, and proxy headers - Fix hardcoded localhost URLs in server console output - Add hostname validation to prevent host header injection attacks - Restrict URL schemes to http/https only (block javascript:, file://, etc.) - Remove sensitive environment data from API responses - Add GET endpoints (/, /mcp) for better API discovery - Fix version inconsistency between server implementations - Update HTTP bridge to use HOST/PORT environment variables - Add comprehensive test scripts for URL configuration and security This resolves issues #41 and #42 by making the HTTP server properly handle deployment behind reverse proxies and adds critical security validations. ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 9 ++ README.md | 2 +- data/nodes.db | Bin 25616384 -> 25616384 bytes docs/CHANGELOG.md | 39 ++++++ docs/HTTP_DEPLOYMENT.md | 35 ++++++ package.json | 3 +- scripts/http-bridge.js | 5 +- scripts/test-security.ts | 96 +++++++++++++++ scripts/test-url-configuration.ts | 192 ++++++++++++++++++++++++++++++ src/http-server-single-session.ts | 80 ++++++++++++- src/http-server.ts | 77 +++++++++++- src/utils/url-detector.ts | 111 +++++++++++++++++ 12 files changed, 641 insertions(+), 8 deletions(-) create mode 100644 scripts/test-security.ts create mode 100755 scripts/test-url-configuration.ts create mode 100644 src/utils/url-detector.ts diff --git a/.env.example b/.env.example index 41052b3..4612fa9 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,15 @@ USE_FIXED_HTTP=true PORT=3000 HOST=0.0.0.0 +# Base URL Configuration (optional) +# Set this when running behind a proxy or when the server is accessed via a different URL +# than what it binds to. If not set, URLs will be auto-detected from proxy headers (if TRUST_PROXY is set) +# or constructed from HOST and PORT. +# Examples: +# BASE_URL=https://n8n-mcp.example.com +# BASE_URL=https://your-domain.com:8443 +# PUBLIC_URL=https://n8n-mcp.mydomain.com (alternative to BASE_URL) + # Authentication token for HTTP mode (REQUIRED) # Generate with: openssl rand -base64 32 AUTH_TOKEN=your-secure-token-here diff --git a/README.md b/README.md index 2528a28..527aa04 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub stars](https://img.shields.io/github/stars/czlonkowski/n8n-mcp?style=social)](https://github.com/czlonkowski/n8n-mcp) -[![Version](https://img.shields.io/badge/version-2.7.13-blue.svg)](https://github.com/czlonkowski/n8n-mcp) +[![Version](https://img.shields.io/badge/version-2.7.15-blue.svg)](https://github.com/czlonkowski/n8n-mcp) [![npm version](https://img.shields.io/npm/v/n8n-mcp.svg)](https://www.npmjs.com/package/n8n-mcp) [![n8n version](https://img.shields.io/badge/n8n-v1.101.1-orange.svg)](https://github.com/n8n-io/n8n) [![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fczlonkowski%2Fn8n--mcp-green.svg)](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp) diff --git a/data/nodes.db b/data/nodes.db index 96c003862337e8db6ba378571375c9765bd74ef7..5388341f31fdb869a0bc42877bb19e9bd583e3e1 100644 GIT binary patch delta 1341 zcmWmA)q)TN00q%qX{3=7q`MK6?p9h5>7kU6Zb765MMc3@4D9X>6g#oIySp2&59bHY zy4^d?>uY ztdx`TQb8(8C8;d?NfoIo)ug)Ako~2m)RF_Fw$zcjQcvnj18FFYq_H%SrqWEBOABc! zt)#WIk+#xK4wUxNK@O6GrK5C`&eBD?N;l~)J*21flHSrs`bt0PF9T$t43fceh#V?I zWT*_2;W9!-$|yNZM#~sET*k@~GETvmCNLExk9d#tK@3A zMy{3X&Q9+SuA z33*bUlBeYv*(1-&bMm~rATP>G^0K@lugYuky1XH8%3Jcbyd&?*d-A?~ARo#{^09m( zpUP+QxqKmC%2)EWd?Vk=ck;dbAV11a^0WLRzshg&yZj-4%3t!g{3HL$-Ypbvr7-r1 zB2hGoMe!&RC8Jc7j(wv{l#OyxJ}N}Ts1%iBzo-&bqgqst8nJ)Wj9PI()Q&n)H|jbS7aCD4L(K)(A*XS1Aqet|NUeP=H zMBnHa{bN83j6pFt4v9l!NDPf(F+4`Z$QTue#poClhsW4BBF4q|m=F_VQcR91@&EtS zm=@DxM$C*O?tUyHm{(dYKww`f?lzAXY)3Ndp074vX2yzqEbwXO9?3{`^tV&N=i!^ zDJ$iqyi|~iQb{Vy{!&G%N;Ro2HKeB0lG<{B)RDSUPwGnpX()}Pu^cE(q^UHMgQU5% zkd|_=w30)lwX~78(oWh-2k9t>N+;3o#>zMuFB9YlIZ`IdQ8G#LGg+p{RGB8zWroa@Su$JZ$Xq#E zj*)pXUlzzhStN^Pi7b_6vRqckN?9eVWsR(rb+TSI$VS;Dn`MhATjf|ePPWPMa)O*F z+vOxVS$4=yIYmyD)8uqHL(Y`5y z*T}VUom?+B$c=K7+$^`qt#X^(E_cYCa+lmK_sG3+pWH7G$ZmO19+HRU5qVS|lgH%= z*&|QNQ}VPtBhSin^1QqtFUm{uvb-X%%4_nvydiJOTk^KNBk#(4^1gf^AIeAav3w$* z%4hPqd?8=TSMs%dBj3t*^1bYpALK{*Nq&}J&F!qTe zQ8bE0@hA}`W8c^>N=4}?6J?`Zl#dEgF)Bsn*gvX7)uT2VU=h&oX>>P7u% z5DlYIG>!wKNi>aSaZogm7SS>ej#hCHrhq|=nx&_(C8GMqf2y+ZqYq@M9=6I zy`xX`jegNT2E@P^6oX?(42@wiJPwP)V?>OMQ87Bk#Ml@Y<6}Y`5l6 { + serverProcess.stdout.on('data', (data) => { + if (data.toString().includes('Press Ctrl+C to stop')) { + resolve(undefined); + } + }); + }); + + const testCases = [ + { + name: 'Valid proxy headers', + headers: { + 'X-Forwarded-Host': 'example.com', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'Malicious host header (with path)', + headers: { + 'X-Forwarded-Host': 'evil.com/path/to/evil', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'Malicious host header (with @)', + headers: { + 'X-Forwarded-Host': 'user@evil.com', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'Invalid hostname (multiple dots)', + headers: { + 'X-Forwarded-Host': '.....', + 'X-Forwarded-Proto': 'https' + } + }, + { + name: 'IPv6 address', + headers: { + 'X-Forwarded-Host': '[::1]:3000', + 'X-Forwarded-Proto': 'https' + } + } + ]; + + for (const testCase of testCases) { + try { + const response = await axios.get('http://localhost:3999/', { + headers: testCase.headers, + timeout: 2000 + }); + + const endpoints = response.data.endpoints; + const healthUrl = endpoints?.health?.url || 'N/A'; + + console.log(`โœ… ${testCase.name}`); + console.log(` Response: ${healthUrl}`); + + // Check if malicious headers were blocked + if (testCase.name.includes('Malicious') || testCase.name.includes('Invalid')) { + if (healthUrl.includes('evil.com') || healthUrl.includes('@') || healthUrl.includes('.....')) { + console.log(' โŒ SECURITY ISSUE: Malicious header was not blocked!'); + } else { + console.log(' โœ… Malicious header was blocked'); + } + } + } catch (error) { + console.log(`โŒ ${testCase.name} - Request failed`); + } + console.log(''); + } + + serverProcess.kill(); +} + +testMaliciousHeaders().catch(console.error); \ No newline at end of file diff --git a/scripts/test-url-configuration.ts b/scripts/test-url-configuration.ts new file mode 100755 index 0000000..c0179e8 --- /dev/null +++ b/scripts/test-url-configuration.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * Test script for URL configuration in n8n-MCP HTTP server + * Tests various BASE_URL, TRUST_PROXY, and proxy header scenarios + */ + +import axios from 'axios'; +import { spawn } from 'child_process'; +import { logger } from '../src/utils/logger'; + +interface TestCase { + name: string; + env: Record; + expectedUrls?: { + health: string; + mcp: string; + }; + proxyHeaders?: Record; +} + +const testCases: TestCase[] = [ + { + name: 'Default configuration (no BASE_URL)', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3001' + }, + expectedUrls: { + health: 'http://localhost:3001/health', + mcp: 'http://localhost:3001/mcp' + } + }, + { + name: 'With BASE_URL configured', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3002', + BASE_URL: 'https://n8n-mcp.example.com' + }, + expectedUrls: { + health: 'https://n8n-mcp.example.com/health', + mcp: 'https://n8n-mcp.example.com/mcp' + } + }, + { + name: 'With PUBLIC_URL configured', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3003', + PUBLIC_URL: 'https://api.company.com/mcp' + }, + expectedUrls: { + health: 'https://api.company.com/mcp/health', + mcp: 'https://api.company.com/mcp/mcp' + } + }, + { + name: 'With TRUST_PROXY and proxy headers', + env: { + MCP_MODE: 'http', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3004', + TRUST_PROXY: '1' + }, + proxyHeaders: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'proxy.example.com' + } + }, + { + name: 'Fixed HTTP implementation', + env: { + MCP_MODE: 'http', + USE_FIXED_HTTP: 'true', + AUTH_TOKEN: 'test-token-for-testing-only', + PORT: '3005', + BASE_URL: 'https://fixed.example.com' + }, + expectedUrls: { + health: 'https://fixed.example.com/health', + mcp: 'https://fixed.example.com/mcp' + } + } +]; + +async function runTest(testCase: TestCase): Promise { + console.log(`\n๐Ÿงช Testing: ${testCase.name}`); + console.log('Environment:', testCase.env); + + const serverProcess = spawn('node', ['dist/mcp/index.js'], { + env: { ...process.env, ...testCase.env } + }); + + let serverOutput = ''; + let serverStarted = false; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + serverProcess.kill(); + reject(new Error('Server startup timeout')); + }, 10000); + + serverProcess.stdout.on('data', (data) => { + const output = data.toString(); + serverOutput += output; + + if (output.includes('Press Ctrl+C to stop the server')) { + serverStarted = true; + clearTimeout(timeout); + + // Give server a moment to fully initialize + setTimeout(async () => { + try { + // Test root endpoint + const rootUrl = `http://localhost:${testCase.env.PORT}/`; + const rootResponse = await axios.get(rootUrl, { + headers: testCase.proxyHeaders || {} + }); + + console.log('โœ… Root endpoint response:'); + console.log(` - Endpoints: ${JSON.stringify(rootResponse.data.endpoints, null, 2)}`); + + // Test health endpoint + const healthUrl = `http://localhost:${testCase.env.PORT}/health`; + const healthResponse = await axios.get(healthUrl); + console.log(`โœ… Health endpoint status: ${healthResponse.data.status}`); + + // Test MCP info endpoint + const mcpUrl = `http://localhost:${testCase.env.PORT}/mcp`; + const mcpResponse = await axios.get(mcpUrl); + console.log(`โœ… MCP info endpoint: ${mcpResponse.data.description}`); + + // Check console output + if (testCase.expectedUrls) { + const outputContainsExpectedUrls = + serverOutput.includes(testCase.expectedUrls.health) && + serverOutput.includes(testCase.expectedUrls.mcp); + + if (outputContainsExpectedUrls) { + console.log('โœ… Console output shows expected URLs'); + } else { + console.log('โŒ Console output does not show expected URLs'); + console.log('Expected:', testCase.expectedUrls); + } + } + + serverProcess.kill(); + resolve(); + } catch (error) { + console.error('โŒ Test failed:', error instanceof Error ? error.message : String(error)); + serverProcess.kill(); + reject(error); + } + }, 500); + } + }); + + serverProcess.stderr.on('data', (data) => { + console.error('Server error:', data.toString()); + }); + + serverProcess.on('close', (code) => { + if (!serverStarted) { + reject(new Error(`Server exited with code ${code} before starting`)); + } else { + resolve(); + } + }); + }); +} + +async function main() { + console.log('๐Ÿš€ n8n-MCP URL Configuration Test Suite'); + console.log('======================================'); + + for (const testCase of testCases) { + try { + await runTest(testCase); + console.log('โœ… Test passed\n'); + } catch (error) { + console.error('โŒ Test failed:', error instanceof Error ? error.message : String(error)); + console.log('\n'); + } + } + + console.log('โœจ All tests completed'); +} + +main().catch(console.error); \ No newline at end of file diff --git a/src/http-server-single-session.ts b/src/http-server-single-session.ts index 1639c14..62b0477 100644 --- a/src/http-server-single-session.ts +++ b/src/http-server-single-session.ts @@ -11,6 +11,8 @@ import { ConsoleManager } from './utils/console-manager'; import { logger } from './utils/logger'; import { readFileSync } from 'fs'; import dotenv from 'dotenv'; +import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector'; +import { PROJECT_VERSION } from './utils/version'; dotenv.config(); @@ -230,12 +232,44 @@ export class SingleSessionHTTPServer { next(); }); + // Root endpoint with API information + app.get('/', (req, res) => { + const port = parseInt(process.env.PORT || '3000'); + const host = process.env.HOST || '0.0.0.0'; + const baseUrl = detectBaseUrl(req, host, port); + const endpoints = formatEndpointUrls(baseUrl); + + res.json({ + name: 'n8n Documentation MCP Server', + version: PROJECT_VERSION, + description: 'Model Context Protocol server providing comprehensive n8n node documentation and workflow management', + endpoints: { + health: { + url: endpoints.health, + method: 'GET', + description: 'Health check and status information' + }, + mcp: { + url: endpoints.mcp, + method: 'GET/POST', + description: 'MCP endpoint - GET for info, POST for JSON-RPC' + } + }, + authentication: { + type: 'Bearer Token', + header: 'Authorization: Bearer ', + required_for: ['POST /mcp'] + }, + documentation: 'https://github.com/czlonkowski/n8n-mcp' + }); + }); + // Health check endpoint (no body parsing needed for GET) app.get('/health', (req, res) => { res.json({ status: 'ok', mode: 'single-session', - version: '2.3.2', + version: PROJECT_VERSION, uptime: Math.floor(process.uptime()), sessionActive: !!this.session, sessionAge: this.session @@ -250,6 +284,35 @@ export class SingleSessionHTTPServer { }); }); + // MCP information endpoint (no auth required for discovery) + app.get('/mcp', (req, res) => { + res.json({ + description: 'n8n Documentation MCP Server', + version: PROJECT_VERSION, + endpoints: { + mcp: { + method: 'POST', + path: '/mcp', + description: 'Main MCP JSON-RPC endpoint', + authentication: 'Bearer token required' + }, + health: { + method: 'GET', + path: '/health', + description: 'Health check endpoint', + authentication: 'None' + }, + root: { + method: 'GET', + path: '/', + description: 'API information', + authentication: 'None' + } + }, + documentation: 'https://github.com/czlonkowski/n8n-mcp' + }); + }); + // Main MCP endpoint with authentication app.post('/mcp', async (req: express.Request, res: express.Response): Promise => { // Enhanced authentication check with specific logging @@ -347,10 +410,21 @@ export class SingleSessionHTTPServer { this.expressServer = app.listen(port, host, () => { logger.info(`n8n MCP Single-Session HTTP Server started`, { port, host }); + + // Detect the base URL using our utility + const baseUrl = getStartupBaseUrl(host, port); + const endpoints = formatEndpointUrls(baseUrl); + console.log(`n8n MCP Single-Session HTTP Server running on ${host}:${port}`); - console.log(`Health check: http://localhost:${port}/health`); - console.log(`MCP endpoint: http://localhost:${port}/mcp`); + console.log(`Health check: ${endpoints.health}`); + console.log(`MCP endpoint: ${endpoints.mcp}`); console.log('\nPress Ctrl+C to stop the server'); + + if (process.env.BASE_URL || process.env.PUBLIC_URL) { + console.log(`\nPublic URL configured: ${baseUrl}`); + } else if (process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) { + console.log(`\nNote: TRUST_PROXY is enabled. URLs will be auto-detected from proxy headers.`); + } }); // Handle server errors diff --git a/src/http-server.ts b/src/http-server.ts index 5c362a8..549ce5c 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -13,6 +13,7 @@ import { PROJECT_VERSION } from './utils/version'; import { isN8nApiConfigured } from './config/n8n-api'; import dotenv from 'dotenv'; import { readFileSync } from 'fs'; +import { getStartupBaseUrl, formatEndpointUrls, detectBaseUrl } from './utils/url-detector'; dotenv.config(); @@ -145,6 +146,38 @@ export async function startFixedHTTPServer() { const mcpServer = new N8NDocumentationMCPServer(); logger.info('Created persistent MCP server instance'); + // Root endpoint with API information + app.get('/', (req, res) => { + const port = parseInt(process.env.PORT || '3000'); + const host = process.env.HOST || '0.0.0.0'; + const baseUrl = detectBaseUrl(req, host, port); + const endpoints = formatEndpointUrls(baseUrl); + + res.json({ + name: 'n8n Documentation MCP Server', + version: PROJECT_VERSION, + description: 'Model Context Protocol server providing comprehensive n8n node documentation and workflow management', + endpoints: { + health: { + url: endpoints.health, + method: 'GET', + description: 'Health check and status information' + }, + mcp: { + url: endpoints.mcp, + method: 'GET/POST', + description: 'MCP endpoint - GET for info, POST for JSON-RPC' + } + }, + authentication: { + type: 'Bearer Token', + header: 'Authorization: Bearer ', + required_for: ['POST /mcp'] + }, + documentation: 'https://github.com/czlonkowski/n8n-mcp' + }); + }); + // Health check endpoint app.get('/health', (req, res) => { res.json({ @@ -181,6 +214,35 @@ export async function startFixedHTTPServer() { } }); + // MCP information endpoint (no auth required for discovery) + app.get('/mcp', (req, res) => { + res.json({ + description: 'n8n Documentation MCP Server', + version: PROJECT_VERSION, + endpoints: { + mcp: { + method: 'POST', + path: '/mcp', + description: 'Main MCP JSON-RPC endpoint', + authentication: 'Bearer token required' + }, + health: { + method: 'GET', + path: '/health', + description: 'Health check endpoint', + authentication: 'None' + }, + root: { + method: 'GET', + path: '/', + description: 'API information', + authentication: 'None' + } + }, + documentation: 'https://github.com/czlonkowski/n8n-mcp' + }); + }); + // Main MCP endpoint - handle each request with custom transport handling app.post('/mcp', async (req: express.Request, res: express.Response): Promise => { const startTime = Date.now(); @@ -414,10 +476,21 @@ export async function startFixedHTTPServer() { expressServer = app.listen(port, host, () => { logger.info(`n8n MCP Fixed HTTP Server started`, { port, host }); + + // Detect the base URL using our utility + const baseUrl = getStartupBaseUrl(host, port); + const endpoints = formatEndpointUrls(baseUrl); + console.log(`n8n MCP Fixed HTTP Server running on ${host}:${port}`); - console.log(`Health check: http://localhost:${port}/health`); - console.log(`MCP endpoint: http://localhost:${port}/mcp`); + console.log(`Health check: ${endpoints.health}`); + console.log(`MCP endpoint: ${endpoints.mcp}`); console.log('\nPress Ctrl+C to stop the server'); + + if (process.env.BASE_URL || process.env.PUBLIC_URL) { + console.log(`\nPublic URL configured: ${baseUrl}`); + } else if (process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) { + console.log(`\nNote: TRUST_PROXY is enabled. URLs will be auto-detected from proxy headers.`); + } }); // Handle errors diff --git a/src/utils/url-detector.ts b/src/utils/url-detector.ts new file mode 100644 index 0000000..30e7c28 --- /dev/null +++ b/src/utils/url-detector.ts @@ -0,0 +1,111 @@ +import { Request } from 'express'; +import { logger } from './logger'; + +/** + * Validates a hostname to prevent header injection attacks + */ +function isValidHostname(host: string): boolean { + // Allow alphanumeric, dots, hyphens, and optional port + return /^[a-zA-Z0-9.-]+(:[0-9]+)?$/.test(host) && host.length < 256; +} + +/** + * Validates a URL string + */ +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + // Only allow http and https protocols + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Detects the base URL for the server, considering: + * 1. Explicitly configured BASE_URL or PUBLIC_URL + * 2. Proxy headers (X-Forwarded-Proto, X-Forwarded-Host) + * 3. Host and port configuration + */ +export function detectBaseUrl(req: Request | null, host: string, port: number): string { + try { + // 1. Check for explicitly configured URL + const configuredUrl = process.env.BASE_URL || process.env.PUBLIC_URL; + if (configuredUrl) { + if (isValidUrl(configuredUrl)) { + logger.debug('Using configured BASE_URL/PUBLIC_URL', { url: configuredUrl }); + return configuredUrl.replace(/\/$/, ''); // Remove trailing slash + } else { + logger.warn('Invalid BASE_URL/PUBLIC_URL configured, falling back to auto-detection', { url: configuredUrl }); + } + } + + // 2. If we have a request, try to detect from proxy headers + if (req && process.env.TRUST_PROXY && Number(process.env.TRUST_PROXY) > 0) { + const proto = req.get('X-Forwarded-Proto') || req.protocol || 'http'; + const forwardedHost = req.get('X-Forwarded-Host'); + const hostHeader = req.get('Host'); + + const detectedHost = forwardedHost || hostHeader; + if (detectedHost && isValidHostname(detectedHost)) { + const baseUrl = `${proto}://${detectedHost}`; + logger.debug('Detected URL from proxy headers', { + proto, + forwardedHost, + hostHeader, + baseUrl + }); + return baseUrl; + } else if (detectedHost) { + logger.warn('Invalid hostname detected in proxy headers, using fallback', { detectedHost }); + } + } + + // 3. Fall back to configured host and port + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + const protocol = 'http'; // Default to http for local bindings + + // Don't show standard ports (for http only in this fallback case) + const needsPort = port !== 80; + const baseUrl = needsPort ? + `${protocol}://${displayHost}:${port}` : + `${protocol}://${displayHost}`; + + logger.debug('Using fallback URL from host/port', { + host, + displayHost, + port, + baseUrl + }); + + return baseUrl; + } catch (error) { + logger.error('Error detecting base URL, using fallback', error); + // Safe fallback + return `http://localhost:${port}`; + } +} + +/** + * Gets the base URL for console display during startup + * This is used when we don't have a request object yet + */ +export function getStartupBaseUrl(host: string, port: number): string { + return detectBaseUrl(null, host, port); +} + +/** + * Formats endpoint URLs for display + */ +export function formatEndpointUrls(baseUrl: string): { + health: string; + mcp: string; + root: string; +} { + return { + health: `${baseUrl}/health`, + mcp: `${baseUrl}/mcp`, + root: baseUrl + }; +} \ No newline at end of file