Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,34 +178,51 @@ paths, networking flags, etc.) — only the visual layout.

## 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:
The studio talks to its managed `thv` over a UNIX domain socket on macOS/Linux
and a Windows named pipe on Windows. To test the UI with a custom `thv` binary,
run it manually with the same `--socket` flag the studio uses internally and
point the studio at it via `THV_SOCKET`:

Comment thread
samuv marked this conversation as resolved.
1. Start your custom `thv` binary with the serve command:

**macOS / Linux**

```bash
thv serve \
--openapi \
--host=127.0.0.1 --port=50000 \
--socket=/tmp/thv-dev.sock \
--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.
**Windows (PowerShell)**

```powershell
thv.exe serve `
--openapi `
--socket='\\.\pipe\thv-dev' `
--experimental-mcp `
--experimental-mcp-host=127.0.0.1 `
--experimental-mcp-port=50001
```

2. Set `THV_SOCKET` (and `THV_MCP_PORT` if you also need the experimental MCP
backend) and start the dev server:

```bash
THV_PORT=50000 THV_MCP_PORT=50001 pnpm start
THV_SOCKET=/tmp/thv-dev.sock 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.
On Windows:

```powershell
$env:THV_SOCKET = '\\.\pipe\thv-dev'; $env:THV_MCP_PORT = '50001'; pnpm start
```

> 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.
The UI displays a banner with the socket / pipe path when `THV_SOCKET` is set.
This works in development mode only; packaged builds use the embedded binary and
an auto-generated per-process socket path.

## Code signing

Expand Down
97 changes: 68 additions & 29 deletions e2e-tests/helpers/app-relaunch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import http from 'node:http'
import path from 'path'
import {
_electron as electron,
Expand Down Expand Up @@ -29,7 +30,7 @@ function getExecutablePath(): string {
export interface LaunchedApp {
app: ElectronApplication
window: Page
baseUrl: string
socketPath: string
/**
* Terminate the app without waiting on the renderer's before-quit teardown.
*
Expand Down Expand Up @@ -74,18 +75,20 @@ export async function launchApp(userDataDir: string): Promise<LaunchedApp> {

await window.getByRole('link', { name: /mcp servers/i }).waitFor()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re: the env: {} block in electron.launch just above (around line 57): the discovery dir is hardcoded to xdg.StateHome/toolhive/server (pkg/server/discovery/discovery.go:51). No flag, no env override exposed on the thv side, but xdg.StateHome itself honors XDG_STATE_HOME.

This helper isolates userDataDir per launch but not the discovery state, so any parallel run on the same host collides on the global discovery file:

  • dev running studio locally + e2e suite on the same box
  • two parallel e2e jobs on a shared CI runner
  • retry attempts that overlap

Two reasonable fixes:

  • Quick: set XDG_STATE_HOME to a per-launch tmp dir in the env block here, and mirror that in toolhive-manager.ts when TOOLHIVE_E2E=true.
  • Cleaner: have thv expose --discovery-dir= so studio can pin it explicitly without piggybacking on XDG.

Not blocking, but worth noting in the test plan since the change makes the discovery file load-bearing for startup.


const port = await window.evaluate(async () => {
const socketPath = await window.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (await (globalThis as any).electronAPI.getToolhivePort()) as number
return (await (globalThis as any).electronAPI.getToolhiveSocketPath()) as
| string
| undefined
})

if (!port) {
throw new Error('Failed to resolve ToolHive port from the launched app')
if (!socketPath) {
throw new Error(
'Failed to resolve ToolHive socket path from the launched app'
)
}

const baseUrl = `http://127.0.0.1:${port}`

await waitForThvReady(baseUrl)
await waitForThvReady(socketPath)

const close = async () => {
// Force an immediate exit via Electron's app.exit(), bypassing before-quit
Expand Down Expand Up @@ -113,35 +116,71 @@ export async function launchApp(userDataDir: string): Promise<LaunchedApp> {
}
}

return { app, window, baseUrl, close }
return { app, window, socketPath, close }
}

/**
* Performs an HTTP request against the thv server over its UNIX socket. Used
* by e2e helpers to seed/inspect state out-of-band from the app UI, mirroring
* the transport the production renderer uses (via the IPC bridge).
*/
function socketRequest(
socketPath: string,
apiPath: string,
init?: { method?: string; headers?: Record<string, string>; body?: string }
): Promise<{ status: number; text: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
socketPath,
path: apiPath,
method: init?.method ?? 'GET',
headers: {
'content-type': 'application/json',
...(init?.headers ?? {}),
},
},
(res) => {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () =>
resolve({
status: res.statusCode ?? 500,
text: Buffer.concat(chunks).toString('utf-8'),
})
)
res.on('error', reject)
}
)
req.on('error', reject)
if (init?.body) req.write(init.body)
req.end()
})
}

/**
* Thin wrapper around `fetch` that raises on non-2xx/4xx responses the caller
* wants to treat as failures, optionally returning parsed JSON.
* Thin wrapper around the thv UNIX socket transport that raises on unexpected
* statuses and parses JSON when present.
*/
export async function thvFetch<T = unknown>(
baseUrl: string,
socketPath: string,
apiPath: string,
init?: RequestInit & { expectStatus?: number[] }
init?: {
method?: string
headers?: Record<string, string>
body?: string
expectStatus?: number[]
}
): Promise<{ status: number; json: T | null }> {
const { expectStatus, ...rest } = init ?? {}
const res = await fetch(`${baseUrl}${apiPath}`, {
...rest,
headers: {
'content-type': 'application/json',
...(rest.headers ?? {}),
},
})
const { status, text } = await socketRequest(socketPath, apiPath, rest)

if (expectStatus && !expectStatus.includes(res.status)) {
const body = await res.text()
if (expectStatus && !expectStatus.includes(status)) {
throw new Error(
`thvFetch ${apiPath} expected status in [${expectStatus.join(',')}], got ${res.status}: ${body}`
`thvFetch ${apiPath} expected status in [${expectStatus.join(',')}], got ${status}: ${text}`
)
}

const text = await res.text()
let json: T | null = null
if (text) {
try {
Expand All @@ -150,24 +189,24 @@ export async function thvFetch<T = unknown>(
json = null
}
}
return { status: res.status, json }
return { status, json }
}

async function waitForThvReady(
baseUrl: string,
socketPath: string,
{ timeoutMs = 30_000 } = {}
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(`${baseUrl}/api/v1beta/groups`)
if (res.ok) return
const { status } = await socketRequest(socketPath, '/api/v1beta/groups')
if (status >= 200 && status < 300) return
} catch {
// keep polling
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(
`ToolHive API at ${baseUrl} did not become ready within ${timeoutMs}ms`
`ToolHive API at socket ${socketPath} did not become ready within ${timeoutMs}ms`
)
}
24 changes: 13 additions & 11 deletions e2e-tests/mcp-optimizer-startup-cleanup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,16 @@ async function createGroupViaUi(
}

async function seedOptimizerState(
baseUrl: string,
socketPath: string,
testServer: TestMcpServer
): Promise<void> {
await thvFetch(baseUrl, '/api/v1beta/groups', {
await thvFetch(socketPath, '/api/v1beta/groups', {
method: 'POST',
body: JSON.stringify({ name: OPTIMIZER_GROUP }),
expectStatus: [200, 201],
})

await thvFetch(baseUrl, '/api/v1beta/clients/register', {
await thvFetch(socketPath, '/api/v1beta/clients/register', {
method: 'POST',
body: JSON.stringify({
names: [TEST_CLIENT],
Expand All @@ -78,7 +78,7 @@ async function seedOptimizerState(
// Create a remote meta-mcp workload so GET /workloads/meta-mcp later returns
// the ALLOWED_GROUPS env var that drives the restoration path. A remote
// workload avoids any Docker image pull complications.
await thvFetch(baseUrl, '/api/v1beta/workloads', {
await thvFetch(socketPath, '/api/v1beta/workloads', {
method: 'POST',
body: JSON.stringify({
name: META_MCP_SERVER,
Expand All @@ -93,13 +93,13 @@ async function seedOptimizerState(
})
}

async function waitForOptimizerCleanup(baseUrl: string): Promise<void> {
async function waitForOptimizerCleanup(socketPath: string): Promise<void> {
await expect
.poll(
async () => {
const { json } = await thvFetch<{
groups?: Array<{ name?: string; registered_clients?: string[] }>
}>(baseUrl, '/api/v1beta/groups', { expectStatus: [200] })
}>(socketPath, '/api/v1beta/groups', { expectStatus: [200] })
const groups = json?.groups ?? []
const optimizerGroup = groups.find((g) => g.name === OPTIMIZER_GROUP)
const customGroup = groups.find((g) => g.name === CUSTOM_GROUP)
Expand Down Expand Up @@ -147,12 +147,12 @@ test.describe('MCP Optimizer startup cleanup', () => {
const firstLaunch = await launchApp(userDataDir)
try {
await createGroupViaUi(firstLaunch, CUSTOM_GROUP)
await seedOptimizerState(firstLaunch.baseUrl, testServer)
await seedOptimizerState(firstLaunch.socketPath, testServer)

// Sanity: both groups exist and optimizer has the registered client.
const { json: seeded } = await thvFetch<{
groups?: Array<{ name?: string; registered_clients?: string[] }>
}>(firstLaunch.baseUrl, '/api/v1beta/groups', { expectStatus: [200] })
}>(firstLaunch.socketPath, '/api/v1beta/groups', { expectStatus: [200] })
const seededOptimizer = seeded?.groups?.find(
(g) => g.name === OPTIMIZER_GROUP
)
Expand All @@ -166,19 +166,21 @@ test.describe('MCP Optimizer startup cleanup', () => {
// startup cleanup hook, which restores clients and deletes the group.
const secondLaunch = await launchApp(userDataDir)
try {
await waitForOptimizerCleanup(secondLaunch.baseUrl)
await waitForOptimizerCleanup(secondLaunch.socketPath)

// The meta-mcp workload is deleted as part of ?with-workloads=true.
const { status: workloadStatus } = await thvFetch(
secondLaunch.baseUrl,
secondLaunch.socketPath,
`/api/v1beta/workloads/${META_MCP_SERVER}`
)
expect(workloadStatus).toBe(404)

// The user's custom group is preserved.
const { json: finalGroups } = await thvFetch<{
groups?: Array<{ name?: string }>
}>(secondLaunch.baseUrl, '/api/v1beta/groups', { expectStatus: [200] })
}>(secondLaunch.socketPath, '/api/v1beta/groups', {
expectStatus: [200],
})
expect(finalGroups?.groups?.some((g) => g.name === CUSTOM_GROUP)).toBe(
true
)
Expand Down
8 changes: 3 additions & 5 deletions main/src/app-events/block-quit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {
recreateMainWindowForShutdown,
sendToMainWindowRenderer,
} from '../main-window'
import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager'
import { stopToolhive, binPath } from '../toolhive-manager'
import { stopAllServers } from '../graceful-exit'
import { createMainProcessFetch } from '../unix-socket-fetch'
import { safeTrayDestroy } from '../system-tray'
import { delay } from '../../../utils/delay'
import log from '../logger'
Expand Down Expand Up @@ -39,10 +40,7 @@ export async function blockQuit(source: string, event?: Electron.Event) {
}

try {
const port = getToolhivePort()
if (port) {
await stopAllServers(binPath, port)
}
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
} catch (err) {
log.error('Teardown failed: ', err)
} finally {
Expand Down
8 changes: 3 additions & 5 deletions main/src/app-events/process-signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {
setTearingDownState,
setQuittingState,
} from '../app-state'
import { getToolhivePort, stopToolhive, binPath } from '../toolhive-manager'
import { stopToolhive, binPath } from '../toolhive-manager'
import { stopAllServers } from '../graceful-exit'
import { createMainProcessFetch } from '../unix-socket-fetch'
import { safeTrayDestroy } from '../system-tray'
import log from '../logger'

Expand All @@ -17,10 +18,7 @@ export function register() {
setQuittingState(true)
log.info(`[${sig}] delaying exit for teardown...`)
try {
const port = getToolhivePort()
if (port) {
await stopAllServers(binPath, port)
}
await stopAllServers(binPath, { createFetch: createMainProcessFetch })
} finally {
stopToolhive()
safeTrayDestroy()
Expand Down
12 changes: 5 additions & 7 deletions main/src/app-events/when-ready.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ import { initTray, safeTrayDestroy } from '../system-tray'
import { createApplicationMenu } from '../menu'
import {
startToolhive,
getToolhivePort,
isToolhiveRunning,
stopToolhive,
} from '../toolhive-manager'
import { registerApiFetchHandlers } from '../unix-socket-fetch'
import { getMainWindow, createMainWindow, hideMainWindow } from '../main-window'
import { extractDeepLinkFromArgs, handleDeepLink } from '../deep-links'
import { getCspString } from '../csp'
Expand Down Expand Up @@ -71,6 +71,9 @@ export function register() {
// Start ToolHive with tray reference
await startToolhive()

// Register IPC handlers for renderer -> main -> thv API bridge
registerApiFetchHandlers()

// Create main window
try {
const mainWindow = await createMainWindow()
Expand Down Expand Up @@ -128,20 +131,15 @@ export function register() {
}
}

// Setup CSP headers
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
if (process.env.NODE_ENV === 'development') {
return callback({ responseHeaders: details.responseHeaders })
}
const port = getToolhivePort()
if (port == null) {
throw new Error('[content-security-policy] ToolHive port is not set')
}
return callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
getCspString(port, import.meta.env.VITE_SENTRY_DSN),
getCspString(import.meta.env.VITE_SENTRY_DSN),
],
},
})
Expand Down
Loading
Loading