diff --git a/docs/README.md b/docs/README.md index 9be6d7dc7..c994acd77 100644 --- a/docs/README.md +++ b/docs/README.md @@ -102,6 +102,37 @@ it as `VITE_API_URL` in the `.env` file (locally) or in the CI environment. | `SENTRY_ORG` | `false` | `true` | `false` | Sentry organization. Used for sourcemap uploads at build-time to enable readable stacktraces. | | `SENTRY_PROJECT` | `false` | `true` | `false` | Sentry project name. Used for sourcemap uploads at build-time to enable readable stacktraces. | +## Developer notes: Using a custom thv binary (dev only) + +During development, you can test the UI with a custom `thv` binary by running it +manually: + +1. Start your custom `thv` binary with the serve command: + + ```bash + thv serve \ + --openapi \ + --host=127.0.0.1 --port=50000 \ + --experimental-mcp \ + --experimental-mcp-host=127.0.0.1 \ + --experimental-mcp-port=50001 + ``` + +2. Set the `THV_PORT` and `THV_MCP_PORT` environment variables and start the dev + server. + + ```bash + THV_PORT=50000 THV_MCP_PORT=50001 pnpm start + ``` + +The UI displays a banner with the HTTP address when using a custom port. This +works in development mode only; packaged builds use the embedded binary. + +> Note on MCP Optimizer If you plan to use the MCP Optimizer with an external +> `thv`, ensure `THV_PORT` is within the range `50000-50100`. The app starts its +> embedded server in this range, and the optimizer expects the ToolHive API to +> be reachable there. + ## Code signing Supports both macOS and Windows code signing. macOS uses Apple certificates, diff --git a/main/src/main.ts b/main/src/main.ts index 167672d9f..849244ebb 100644 --- a/main/src/main.ts +++ b/main/src/main.ts @@ -44,6 +44,7 @@ import { isToolhiveRunning, binPath, getToolhiveMcpPort, + isUsingCustomPort, } from './toolhive-manager' import log from './logger' import { getInstanceId, isOfficialReleaseBuild } from './util' @@ -455,6 +456,7 @@ ipcMain.handle('quit-app', (e) => { ipcMain.handle('get-toolhive-port', () => getToolhivePort()) ipcMain.handle('get-toolhive-mcp-port', () => getToolhiveMcpPort()) ipcMain.handle('is-toolhive-running', () => isToolhiveRunning()) +ipcMain.handle('is-using-custom-port', () => isUsingCustomPort()) // Window control handlers for custom title bar ipcMain.handle('window-minimize', () => { diff --git a/main/src/toolhive-manager.ts b/main/src/toolhive-manager.ts index 5a9afd7c2..82662569e 100644 --- a/main/src/toolhive-manager.ts +++ b/main/src/toolhive-manager.ts @@ -44,6 +44,13 @@ export function isToolhiveRunning(): boolean { return isRunning } +/** + * Returns whether the app is using a custom ToolHive port (externally managed thv). + */ +export function isUsingCustomPort(): boolean { + return !app.isPackaged && !!process.env.THV_PORT +} + async function findFreePort( minPort?: number, maxPort?: number @@ -103,13 +110,29 @@ async function findFreePort( export async function startToolhive(): Promise { Sentry.withScope>(async (scope) => { + if (isUsingCustomPort()) { + const customPort = parseInt(process.env.THV_PORT!, 10) + if (isNaN(customPort)) { + log.error( + `Invalid THV_PORT environment variable: ${process.env.THV_PORT}` + ) + return + } + toolhivePort = customPort + toolhiveMcpPort = process.env.THV_MCP_PORT + ? parseInt(process.env.THV_MCP_PORT!, 10) + : undefined + log.info(`Using external ToolHive on port ${toolhivePort}`) + return + } + if (!existsSync(binPath)) { log.error(`ToolHive binary not found at: ${binPath}`) return } - toolhivePort = await findFreePort(50000, 50100) toolhiveMcpPort = await findFreePort() + toolhivePort = await findFreePort(50000, 50100) log.info( `Starting ToolHive from: ${binPath} on port ${toolhivePort}, MCP on port ${toolhiveMcpPort}` ) diff --git a/preload/src/preload.ts b/preload/src/preload.ts index 8a27a9d0e..c8ebc26cd 100644 --- a/preload/src/preload.ts +++ b/preload/src/preload.ts @@ -40,6 +40,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getToolhiveVersion: () => TOOLHIVE_VERSION, // ToolHive is running isToolhiveRunning: () => ipcRenderer.invoke('is-toolhive-running'), + isUsingCustomPort: () => ipcRenderer.invoke('is-using-custom-port'), // Container engine check checkContainerEngine: () => ipcRenderer.invoke('check-container-engine'), @@ -240,8 +241,9 @@ export interface ElectronAPI { quitApp: () => Promise getToolhivePort: () => Promise getToolhiveMcpPort: () => Promise - getToolhiveVersion: () => Promise + getToolhiveVersion: () => string isToolhiveRunning: () => Promise + isUsingCustomPort: () => Promise checkContainerEngine: () => Promise<{ docker: boolean podman: boolean diff --git a/renderer/src/common/components/custom-port-banner.tsx b/renderer/src/common/components/custom-port-banner.tsx new file mode 100644 index 000000000..aee6b4cea --- /dev/null +++ b/renderer/src/common/components/custom-port-banner.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' +import { AlertTriangle } from 'lucide-react' +import { Alert, AlertDescription } from './ui/alert' +import log from 'electron-log/renderer' + +/** + * Banner that displays a warning when using a custom ToolHive port in development mode. + * Only visible when THV_PORT environment variable is set. + */ +export function CustomPortBanner() { + const [isCustomPort, setIsCustomPort] = useState(false) + const [port, setPort] = useState(undefined) + + useEffect(() => { + Promise.all([ + window.electronAPI.isUsingCustomPort(), + window.electronAPI.getToolhivePort(), + ]) + .then(([usingCustom, toolhivePort]) => { + setIsCustomPort(usingCustom) + setPort(toolhivePort) + }) + .catch((error: unknown) => { + log.error('Failed to get custom port info:', error) + }) + }, []) + + // Don't render if not using custom port or port is not available + if (!isCustomPort || !port) { + return null + } + + const httpAddress = `http://127.0.0.1:${port}` + + return ( + + + +
+ Using external ToolHive at + + {httpAddress} + +
+
+
+ ) +} diff --git a/renderer/src/routes/__root.tsx b/renderer/src/routes/__root.tsx index c333528a0..6fd6e2390 100644 --- a/renderer/src/routes/__root.tsx +++ b/renderer/src/routes/__root.tsx @@ -22,6 +22,7 @@ import '@fontsource-variable/inter/wght.css' import log from 'electron-log/renderer' import * as Sentry from '@sentry/electron/renderer' import { StartingToolHive } from '@/common/components/starting-toolhive' +import { CustomPortBanner } from '@/common/components/custom-port-banner' async function setupSecretProvider(queryClient: QueryClient) { const createEncryptedProvider = async () => @@ -53,6 +54,7 @@ function RootComponent() { return ( <> {!isShutdownRoute && } + {!isShutdownRoute && import.meta.env.DEV && }