diff --git a/mcpjam-inspector/bin/start.js b/mcpjam-inspector/bin/start.js index 5be373298..4d502baa8 100755 --- a/mcpjam-inspector/bin/start.js +++ b/mcpjam-inspector/bin/start.js @@ -120,23 +120,97 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms, true)); } -function isPortAvailable(port) { +function isPortAvailable(port, host = "127.0.0.1") { return new Promise((resolve) => { const server = createServer(); + let settled = false; + const onCloseError = () => {}; + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + cleanup(); + server.once("error", onCloseError); + try { + server.close(() => { + server.removeListener("error", onCloseError); + resolve(false); + }); + } catch { + server.removeListener("error", onCloseError); + resolve(false); + } + }, 1000); - server.listen(port, "127.0.0.1", () => { + const cleanup = () => { + clearTimeout(timeout); + server.removeListener("error", onError); + server.removeListener("listening", onListening); + }; + + const onListening = () => { + if (settled) { + return; + } + settled = true; + cleanup(); server.close(() => { resolve(true); }); - }); + }; - server.on("error", () => { - // Port is not available + const onError = () => { + if (settled) { + return; + } + settled = true; + cleanup(); resolve(false); - }); + }; + + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); }); } +function parsePort(value) { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error(`Invalid port value: ${value}`); + } + return parsed; +} + +async function findAvailablePort( + startPort, + host, + maxPortOffset = 100, + verbose = false, +) { + const maxPort = Math.min(startPort + maxPortOffset, 65535); + + if (maxPort < startPort) { + throw new Error( + `No available port found in range ${startPort}-${maxPort}`, + ); + } + + for (let port = startPort; port <= maxPort; port++) { + const isAvailable = await isPortAvailable(port, host); + if (isAvailable) { + return port; + } + + if (verbose) { + logWarning(`Port ${port} unavailable; checking next port`); + } + } + + throw new Error(`No available port found in range ${startPort}-${maxPort}`); +} + function spawnPromise(command, args, options) { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -357,11 +431,6 @@ async function main() { if (parsingFlags && arg === "--port" && i + 1 < args.length) { const port = args[++i]; envVars.PORT = port; - // Default: localhost in development, 127.0.0.1 in production - const defaultHost = - process.env.ENVIRONMENT === "dev" ? "localhost" : "127.0.0.1"; - const baseHost = process.env.HOST || defaultHost; - envVars.BASE_URL = `http://${baseHost}:${port}`; continue; } @@ -623,17 +692,26 @@ async function main() { // Apply parsed environment variables to process.env first Object.assign(process.env, envVars); - // Port configuration (fixed default to 6274) - const requestedPort = 6274; + const requestedPortCandidate = + process.env.SERVER_PORT !== undefined + ? process.env.SERVER_PORT + : envVars.PORT; + const requestedPort = requestedPortCandidate + ? parsePort(requestedPortCandidate) + : 6274; let PORT; + const defaultHost = + process.env.ENVIRONMENT === "dev" ? "localhost" : "127.0.0.1"; + const baseHost = process.env.HOST || defaultHost; + const host = baseHost; try { // Check if user explicitly set a port via --port flag - const hasExplicitPort = envVars.PORT !== undefined; + const hasExplicitPort = requestedPortCandidate !== undefined; if (hasExplicitPort) { - if (await isPortAvailable(requestedPort)) { - PORT = requestedPort.toString(); + if (await isPortAvailable(requestedPort, host)) { + PORT = String(requestedPort); } else { logError(`Explicitly requested port ${requestedPort} is not available`); logInfo( @@ -642,23 +720,17 @@ async function main() { throw new Error(`Port ${requestedPort} is already in use`); } } else { - // Fixed port policy: use default port 6274 and fail fast if unavailable - if (await isPortAvailable(requestedPort)) { - PORT = requestedPort.toString(); - } else { - logError( - `Default port ${requestedPort} is already in use. Please free the port`, + const resolvedPort = await findAvailablePort(requestedPort, host, 100, verboseLogs); + if (resolvedPort !== requestedPort) { + logInfo( + `Default port ${requestedPort} is busy. Using next available port ${resolvedPort}.`, ); - throw new Error(`Port ${requestedPort} is already in use`); } + PORT = String(resolvedPort); } // Update environment variables with the final port envVars.PORT = PORT; - // Default: localhost in development, 127.0.0.1 in production - const defaultHost = - process.env.ENVIRONMENT === "dev" ? "localhost" : "127.0.0.1"; - const baseHost = process.env.HOST || defaultHost; envVars.BASE_URL = `http://${baseHost}:${PORT}`; Object.assign(process.env, envVars); } catch (error) { @@ -744,9 +816,6 @@ async function main() { // Open the browser automatically // Use BASE_URL if set, otherwise construct from HOST and PORT // Default: localhost in development, 127.0.0.1 in production - const defaultHost = - process.env.ENVIRONMENT === "dev" ? "localhost" : "127.0.0.1"; - const host = process.env.HOST || defaultHost; let url = process.env.BASE_URL || `http://${host}:${PORT}`; // Append initial tab hash if specified diff --git a/mcpjam-inspector/src/main.ts b/mcpjam-inspector/src/main.ts index f13a4dac3..d99dc68ad 100644 --- a/mcpjam-inspector/src/main.ts +++ b/mcpjam-inspector/src/main.ts @@ -9,8 +9,8 @@ Sentry.init({ import { app, BrowserWindow, shell, Menu } from "electron"; import { serve } from "@hono/node-server"; +import { createServer } from "node:net"; import path from "path"; -import { createHonoApp } from "../server/app.js"; import log from "electron-log"; import { updateElectronApp } from "update-electron-app"; import { registerListeners } from "./ipc/listeners-register.js"; @@ -41,10 +41,146 @@ let server: any = null; let serverPort: number = 0; const isDev = process.env.NODE_ENV === "development"; +const DEFAULT_ELECTRON_PORT = 6274; +const PORT_SCAN_LIMIT = 100; +const MAX_PORT_NUMBER = 65535; + +type PortParseResult = { + value: number; + isExplicit: boolean; +}; + +function parsePort(value: string | undefined, fallback: number): PortParseResult { + if (value === undefined || value.trim() === "") { + return { value: fallback, isExplicit: false }; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_PORT_NUMBER) { + log.warn(`Ignoring invalid port value "${value}", using fallback ${fallback}`); + return { value: fallback, isExplicit: false }; + } + + return { value: parsed, isExplicit: true }; +} + +function getRequestedPort(): { + port: number; + hasExplicitPort: boolean; +} { + const explicitPort = + process.env.ELECTRON_PORT ?? + process.env.SERVER_PORT ?? + process.env.PORT; + const parsedPort = parsePort(explicitPort, DEFAULT_ELECTRON_PORT); + + return { + port: parsedPort.value, + hasExplicitPort: parsedPort.isExplicit, + }; +} + +function isPortAvailable(port: number, host: string): Promise { + return new Promise((resolve) => { + const server = createServer(); + let settled = false; + const onCloseError = () => {}; + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + cleanup(); + server.once("error", onCloseError); + try { + server.close(() => { + server.removeListener("error", onCloseError); + resolve(false); + }); + } catch { + server.removeListener("error", onCloseError); + resolve(false); + } + }, 1000); + + const cleanup = () => { + clearTimeout(timeout); + server.removeListener("error", onError); + server.removeListener("listening", onListening); + }; + + const onListening = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + server.close(() => { + resolve(true); + }); + }; + + const onError = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(false); + }; + + server.once("error", onError); + server.once("listening", onListening); + server.listen(port, host); + }); +} + +async function findAvailablePort( + requestedPort: number, + host: string, + hasExplicitPort = false, +): Promise { + if (requestedPort < 1 || requestedPort > MAX_PORT_NUMBER) { + throw new Error(`Requested port ${requestedPort} is outside valid range 1-${MAX_PORT_NUMBER}`); + } + + if (await isPortAvailable(requestedPort, host)) { + return requestedPort; + } + + if (hasExplicitPort) { + throw new Error(`Requested port ${requestedPort} is already in use`); + } + + const maxPort = Math.min(requestedPort + PORT_SCAN_LIMIT, MAX_PORT_NUMBER); + if (maxPort <= requestedPort) { + throw new Error(`No available port found in range ${requestedPort}-${maxPort}`); + } + + for (let port = requestedPort + 1; port <= maxPort; port++) { + if (await isPortAvailable(port, host)) { + log.warn( + `Port ${requestedPort} was unavailable. Using fallback free port ${port}`, + ); + return port; + } + } + + throw new Error( + `No available port found in range ${requestedPort}-${maxPort}`, + ); +} + +function getServerUrl(port: number, host: string): string { + return `http://${host}:${port}`; +} async function startHonoServer(): Promise { try { - const port = 6274; + const hostname = app.isPackaged ? "127.0.0.1" : "localhost"; + const { port: requestedPort, hasExplicitPort } = getRequestedPort(); + const port = await findAvailablePort(requestedPort, hostname, hasExplicitPort); + // Set environment variables to tell the server it's running in Electron process.env.ELECTRON_APP = "true"; process.env.IS_PACKAGED = app.isPackaged ? "true" : "false"; @@ -53,19 +189,18 @@ async function startHonoServer(): Promise { ? process.resourcesPath : app.getAppPath(); process.env.NODE_ENV = app.isPackaged ? "production" : "development"; + process.env.SERVER_PORT = String(port); + const { createHonoApp } = await import("../server/app.js"); const honoApp = createHonoApp(); - // Bind to 127.0.0.1 when packaged to avoid IPv6-only localhost issues - const hostname = app.isPackaged ? "127.0.0.1" : "localhost"; - server = serve({ fetch: honoApp.fetch, port, hostname, }); - log.info(`🚀 MCPJam Server started on port ${port}`); + log.info(`🚀 MCPJam Server started on port ${port} (${hostname})`); return port; } catch (error) { log.error("Failed to start Hono server:", error); @@ -201,8 +336,9 @@ function createAppMenu(): void { app.whenReady().then(async () => { try { // Start the embedded Hono server + const serverHost = app.isPackaged ? "127.0.0.1" : "localhost"; serverPort = await startHonoServer(); - const serverUrl = `http://127.0.0.1:${serverPort}`; + const serverUrl = getServerUrl(serverPort, serverHost); // Create the main window createAppMenu(); @@ -237,14 +373,15 @@ app.on("window-all-closed", () => { app.on("activate", async () => { // On macOS, re-create window when the dock icon is clicked if (BrowserWindow.getAllWindows().length === 0) { + const serverHost = app.isPackaged ? "127.0.0.1" : "localhost"; if (serverPort > 0) { - const serverUrl = `http://127.0.0.1:${serverPort}`; + const serverUrl = getServerUrl(serverPort, serverHost); mainWindow = createMainWindow(serverUrl); } else { // Restart server if needed try { serverPort = await startHonoServer(); - const serverUrl = `http://127.0.0.1:${serverPort}`; + const serverUrl = getServerUrl(serverPort, serverHost); mainWindow = createMainWindow(serverUrl); } catch (error) { log.error("Failed to restart server:", error); @@ -270,7 +407,7 @@ app.on("open-url", (event, url) => { // Compute the base URL the renderer should load const baseUrl = isDev ? MAIN_WINDOW_VITE_DEV_SERVER_URL - : `http://127.0.0.1:${serverPort}`; + : getServerUrl(serverPort, app.isPackaged ? "127.0.0.1" : "localhost"); const callbackUrl = new URL("/callback", baseUrl); if (code) callbackUrl.searchParams.set("code", code);