diff --git a/main/src/utils/mcp-tools.ts b/main/src/utils/mcp-tools.ts index 4151eb89d..7f32de283 100644 --- a/main/src/utils/mcp-tools.ts +++ b/main/src/utils/mcp-tools.ts @@ -8,6 +8,17 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import type { CoreWorkload } from '@api/types.gen' import log from '../logger' +/** + * Determines if the platform is probably using native containers. + * Native containers mean Docker runs directly on the host OS (Linux), + * as opposed to running in a VM (macOS/Windows with Docker Desktop). + * When native containers are used, host networking mode allows direct + * access to the host's network stack. + */ +function isProbablyUsingNativeContainers(): boolean { + return process.platform === 'linux' +} + export interface McpToolDefinition { description?: string inputSchema: Tool['inputSchema'] @@ -56,7 +67,13 @@ export function createTransport(workload: CoreWorkload): MCPClientConfig { }), }), 'streamable-http': () => { - const url = new URL(`http://localhost:${workload.port}/mcp`) + // On platforms with native containers (Linux), use fixed port for mcp-optimizer + // to work around thv port management bugs in host networking mode + const useFixedPort = + isProbablyUsingNativeContainers() && + workload.name === 'internal---meta-mcp' + const port = useFixedPort ? 50051 : workload.port + const url = new URL(`http://localhost:${port}/mcp`) return { name: workload.name, transport: new StreamableHTTPClientTransport(url), diff --git a/renderer/src/common/lib/__tests__/meta-optimizer.test.ts b/renderer/src/common/lib/__tests__/meta-optimizer.test.ts new file mode 100644 index 000000000..6eee67ef7 --- /dev/null +++ b/renderer/src/common/lib/__tests__/meta-optimizer.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { initMetaOptimizer } from '../meta-optimizer' +import { server } from '@/common/mocks/node' +import { http, HttpResponse } from 'msw' +import { mswEndpoint } from '@/common/mocks/customHandlers' +import log from 'electron-log/renderer' +import { queryClient } from '../query-client' +import * as apiSdk from '@api/sdk.gen' +import { + ALLOWED_GROUPS_ENV_VAR, + MCP_OPTIMIZER_GROUP_NAME, + META_MCP_SERVER_NAME, +} from '../constants' + +vi.mock('electron-log/renderer', () => ({ + default: { + error: vi.fn(), + info: vi.fn(), + }, +})) + +const mockElectronAPI = { + featureFlags: { + get: vi.fn(), + }, + getToolhivePort: vi.fn(), + isLinux: true, // Tests run on Linux by default (for host networking tests) +} + +Object.defineProperty(window, 'electronAPI', { + value: mockElectronAPI, + writable: true, +}) + +describe('Meta Optimizer', () => { + beforeEach(() => { + vi.clearAllMocks() + queryClient.clear() + }) + + describe('initMetaOptimizer', () => { + it('not initialize when both flags are disabled', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(false) + + const getGroupsSpy = vi.spyOn(apiSdk, 'getApiV1BetaGroups') + const postGroupsSpy = vi.spyOn(apiSdk, 'postApiV1BetaGroups') + const postWorkloadsSpy = vi.spyOn(apiSdk, 'postApiV1BetaWorkloads') + + await initMetaOptimizer() + + expect(getGroupsSpy).not.toHaveBeenCalled() + expect(postGroupsSpy).not.toHaveBeenCalled() + expect(postWorkloadsSpy).not.toHaveBeenCalled() + expect(log.error).not.toHaveBeenCalled() + expect(log.info).not.toHaveBeenCalled() + }) + + it('initialize and create group when it does not exist', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(true) + mockElectronAPI.getToolhivePort.mockResolvedValue(50055) + + const postGroupsSpy = vi.spyOn(apiSdk, 'postApiV1BetaGroups') + + server.use( + // Mock groups check - group doesn't exist + http.get(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json({ groups: [] }) + ), + + // Mock workload already exists + http.get(mswEndpoint('/api/v1beta/workloads/:name'), () => + HttpResponse.json({ name: META_MCP_SERVER_NAME }) + ) + ) + + await initMetaOptimizer() + + expect(postGroupsSpy).toHaveBeenCalledWith({ + body: { name: MCP_OPTIMIZER_GROUP_NAME }, + }) + }) + + it('initialize and create group and workload when they do not exist', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(true) + mockElectronAPI.getToolhivePort.mockResolvedValue(50055) + + const postWorkloadsSpy = vi.spyOn(apiSdk, 'postApiV1BetaWorkloads') + + server.use( + // Mock groups check - group doesn't exist + http.get(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json({ groups: [] }) + ), + // Mock workload check - workload doesn't exist (404) + http.get(mswEndpoint('/api/v1beta/workloads/:name'), () => + HttpResponse.json({ error: 'Workload not found' }, { status: 404 }) + ), + // Mock server from registry - mcp-optimizer not found, falls back to meta-mcp + http.get( + mswEndpoint('/api/v1beta/registry/:name/servers/:serverName'), + ({ params }) => { + if (params.serverName === 'mcp-optimizer') { + return HttpResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + // Fallback to meta-mcp + return HttpResponse.json({ + server: { + image: 'ghcr.io/stackloklabs/meta-mcp:latest', + transport: 'streamable-http', + }, + }) + } + ) + ) + + await initMetaOptimizer() + + expect(postWorkloadsSpy).toHaveBeenCalledWith({ + body: expect.objectContaining({ + name: META_MCP_SERVER_NAME, + group: MCP_OPTIMIZER_GROUP_NAME, + // Image is hardcoded to mcp-optimizer:latest + image: 'ghcr.io/stackloklabs/mcp-optimizer:latest', + transport: 'streamable-http', + target_port: 50051, + env_vars: { + [ALLOWED_GROUPS_ENV_VAR]: 'default', + TOOLHIVE_HOST: '127.0.0.1', + TOOLHIVE_PORT: '50055', + }, + permission_profile: { + network: { + mode: 'host', + }, + }, + }), + }) + }) + + it('skip workload creation if it already exists', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(true) + + const postWorkloadsSpy = vi.spyOn(apiSdk, 'postApiV1BetaWorkloads') + + server.use( + // Mock group exists + http.get(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json({ + groups: [{ name: MCP_OPTIMIZER_GROUP_NAME }], + }) + ), + // Mock workload already exists + http.get(mswEndpoint('/api/v1beta/workloads/:name'), () => + HttpResponse.json({ + name: META_MCP_SERVER_NAME, + group: MCP_OPTIMIZER_GROUP_NAME, + }) + ) + ) + + await initMetaOptimizer() + + expect(postWorkloadsSpy).not.toHaveBeenCalled() + }) + + it('handle group fetch errors gracefully', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(true) + + server.use( + http.get(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json({ error: 'Server error' }, { status: 500 }) + ) + ) + + await initMetaOptimizer() + + expect(log.error).toHaveBeenCalledWith( + '[ensureMetaOptimizerGroup] Error checking group:', + expect.any(Error) + ) + }) + + it('not call workload creation when group creation fails', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(true) + + const postWorkloadsSpy = vi.spyOn(apiSdk, 'postApiV1BetaWorkloads') + + server.use( + // Mock groups check - group doesn't exist + http.get(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json({ groups: [] }) + ), + // Mock group creation fails + http.post(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json( + { error: 'Failed to create group' }, + { status: 500 } + ) + ), + // Mock workload check - workload doesn't exist (404) + http.get(mswEndpoint('/api/v1beta/workloads/:name'), () => + HttpResponse.json({ error: 'Workload not found' }, { status: 404 }) + ) + ) + + await initMetaOptimizer() + + expect(postWorkloadsSpy).not.toHaveBeenCalled() + expect(log.error).toHaveBeenCalledWith( + '[createMetaOptimizerGroup] Failed to create group:', + expect.objectContaining({ error: 'Failed to create group' }) + ) + }) + + it('handle workload creation failure', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(true) + mockElectronAPI.getToolhivePort.mockResolvedValue(50055) + + server.use( + // Mock groups check - group doesn't exist + http.get(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json({ groups: [] }) + ), + http.get(mswEndpoint('/api/v1beta/workloads/:name'), () => + HttpResponse.json({ error: 'Workload not found' }, { status: 404 }) + ), + http.get( + mswEndpoint('/api/v1beta/registry/:name/servers/:serverName'), + ({ params }) => { + if (params.serverName === 'mcp-optimizer') { + return HttpResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + // Fallback to meta-mcp + return HttpResponse.json({ + server: { + image: 'ghcr.io/stackloklabs/meta-mcp:latest', + transport: 'streamable-http', + }, + }) + } + ), + http.post(mswEndpoint('/api/v1beta/workloads'), () => + HttpResponse.json( + { error: 'Failed to create workload' }, + { status: 500 } + ) + ) + ) + + await initMetaOptimizer() + + expect(log.error).toHaveBeenCalledWith( + '[createMetaOptimizerWorkload] Failed to create meta optimizer workload:', + expect.objectContaining({ error: 'Failed to create workload' }) + ) + }) + + it('handle missing server from registry', async () => { + mockElectronAPI.featureFlags.get.mockResolvedValue(true) + mockElectronAPI.getToolhivePort.mockResolvedValue(50055) + + server.use( + // Mock groups check - group doesn't exist + http.get(mswEndpoint('/api/v1beta/groups'), () => + HttpResponse.json({ groups: [] }) + ), + http.get(mswEndpoint('/api/v1beta/workloads/:name'), () => + HttpResponse.json({ error: 'Workload not found' }, { status: 404 }) + ), + // Both mcp-optimizer and meta-mcp return null (not found) + http.get( + mswEndpoint('/api/v1beta/registry/:name/servers/:serverName'), + ({ params }) => { + if (params.serverName === 'mcp-optimizer') { + return HttpResponse.json( + { error: 'Server not found' }, + { status: 404 } + ) + } + // meta-mcp also returns null + return HttpResponse.json({ server: null }) + } + ) + ) + + await initMetaOptimizer() + + expect(log.info).toHaveBeenCalledWith( + '[createMetaOptimizerWorkload] Server not found in the registry' + ) + }) + }) +}) diff --git a/renderer/src/common/lib/constants.ts b/renderer/src/common/lib/constants.ts index 20a5e41c0..cd210207d 100644 --- a/renderer/src/common/lib/constants.ts +++ b/renderer/src/common/lib/constants.ts @@ -2,3 +2,14 @@ export const MCP_OPTIMIZER_GROUP_NAME = '__mcp-optimizer__' export const META_MCP_SERVER_NAME = 'internal---meta-mcp' export const ALLOWED_GROUPS_ENV_VAR = 'ALLOWED_GROUPS' export const MCP_OPTIMIZER_REGISTRY_SERVER_NAME = 'mcp-optimizer' + +/** + * Determines if the platform is probably using native containers. + * Native containers mean Docker runs directly on the host OS (Linux), + * as opposed to running in a VM (macOS/Windows with Docker Desktop). + * When native containers are used, host networking mode allows direct + * access to the host's network stack. + */ +export function isProbablyUsingNativeContainers(): boolean { + return window.electronAPI.isLinux +} diff --git a/renderer/src/common/lib/meta-optimizer.ts b/renderer/src/common/lib/meta-optimizer.ts new file mode 100644 index 000000000..a48131b4d --- /dev/null +++ b/renderer/src/common/lib/meta-optimizer.ts @@ -0,0 +1,228 @@ +import { featureFlagKeys } from '../../../../utils/feature-flags' +import log from 'electron-log/renderer' +import { queryClient } from './query-client' +import { + getApiV1BetaGroups, + postApiV1BetaGroups, + postApiV1BetaWorkloads, +} from '@api/sdk.gen' +import type { V1CreateRequest } from '@api/types.gen' +import { + getApiV1BetaGroupsQueryKey, + getApiV1BetaRegistryByNameServersByServerNameOptions, + getApiV1BetaWorkloadsByNameOptions, +} from '@api/@tanstack/react-query.gen' +import { + META_MCP_SERVER_NAME, + MCP_OPTIMIZER_GROUP_NAME, + ALLOWED_GROUPS_ENV_VAR, + isProbablyUsingNativeContainers, +} from './constants' + +async function ensureMetaOptimizerWorkload() { + try { + const workloadDetail = await queryClient.fetchQuery( + getApiV1BetaWorkloadsByNameOptions({ + path: { name: META_MCP_SERVER_NAME }, + }) + ) + + return workloadDetail + } catch (error) { + const isNotFoundError = + typeof error === 'string' && error.includes('Workload not found') + + if (isNotFoundError) { + return undefined + } + + // Log unexpected errors + log.error('[ensureMetaOptimizerWorkload] Error fetching workload:', error) + return undefined + } +} + +async function createMetaOptimizerWorkload() { + try { + const workloadDetail = await ensureMetaOptimizerWorkload() + if (workloadDetail?.group === MCP_OPTIMIZER_GROUP_NAME) { + return workloadDetail + } + + // Try to fetch mcp-optimizer first, fall back to meta-mcp if not found + let server + try { + const response = await queryClient.fetchQuery( + getApiV1BetaRegistryByNameServersByServerNameOptions({ + path: { + name: 'default', + serverName: 'mcp-optimizer', + }, + }) + ) + server = response.server + } catch { + log.info( + '[createMetaOptimizerWorkload] mcp-optimizer not found in registry, falling back to meta-mcp' + ) + // Fallback to meta-mcp registry entry + const response = await queryClient.fetchQuery( + getApiV1BetaRegistryByNameServersByServerNameOptions({ + path: { + name: 'default', + serverName: 'meta-mcp', + }, + }) + ) + server = response.server + } + + if (!server) { + log.info('[createMetaOptimizerWorkload] Server not found in the registry') + return + } + + // Get the thv serve port from the main process + const toolhivePort = await window.electronAPI.getToolhivePort() + if (!toolhivePort) { + log.error( + '[createMetaOptimizerWorkload] ToolHive port not available, cannot create workload' + ) + return + } + + // On platforms with native containers (Linux), use host networking mode + // to allow the container to access the host's ToolHive API + const useHostNetworking = isProbablyUsingNativeContainers() + + const body: V1CreateRequest = { + name: META_MCP_SERVER_NAME, + // Use the latest mcp-optimizer image (fixed for x86_64) + image: 'ghcr.io/stackloklabs/mcp-optimizer:latest', + transport: server.transport, + // Use fixed port for host networking mode (avoids thv port management bugs on Linux) + ...(useHostNetworking && { target_port: 50051 }), + env_vars: { + [ALLOWED_GROUPS_ENV_VAR]: 'default', + // With host networking mode on Linux, localhost refers to the host machine + ...(useHostNetworking && { + TOOLHIVE_HOST: '127.0.0.1', + TOOLHIVE_PORT: String(toolhivePort), + }), + }, + secrets: [], + cmd_arguments: [], + network_isolation: false, + volumes: [], + group: MCP_OPTIMIZER_GROUP_NAME, + ...(useHostNetworking && { + permission_profile: { + network: { + mode: 'host', + }, + }, + }), + } + + const response = await postApiV1BetaWorkloads({ + body, + }) + + if (response.error) { + log.error( + '[createMetaOptimizerWorkload] Failed to create meta optimizer workload:', + response.error + ) + return + } + return response.data + } catch (error) { + log.error( + '[createMetaOptimizerWorkload] Failed to create meta optimizer workload:', + error instanceof Error ? error.message : error + ) + return + } +} + +async function createMetaOptimizerGroup() { + const response = await postApiV1BetaGroups({ + body: { name: MCP_OPTIMIZER_GROUP_NAME }, + }) + + if (response.error) { + log.error( + '[createMetaOptimizerGroup] Failed to create group:', + response.error + ) + return undefined + } + + await queryClient.invalidateQueries({ + queryKey: getApiV1BetaGroupsQueryKey(), + }) + + // Create workload after group creation succeeds + return await createMetaOptimizerWorkload() +} + +async function ensureMetaOptimizerGroup() { + try { + const rawGroups = await queryClient.fetchQuery({ + queryKey: getApiV1BetaGroupsQueryKey(), + queryFn: async () => { + const response = await getApiV1BetaGroups() + + if (response.error) { + throw new Error(`Failed to fetch groups: ${response.error}`) + } + + return response.data + }, + staleTime: 0, + gcTime: 0, + }) + + const metaOptimizerGrp = rawGroups?.groups?.find( + (group) => group.name === MCP_OPTIMIZER_GROUP_NAME + ) + + if (!metaOptimizerGrp) { + return await createMetaOptimizerGroup() + } + + return await createMetaOptimizerWorkload() + } catch (error) { + log.error('[ensureMetaOptimizerGroup] Error checking group:', error) + return undefined + } +} + +export async function initMetaOptimizer() { + try { + const [experimentalFeaturesEnabled, metaOptimizerEnabled] = + await Promise.allSettled([ + window.electronAPI.featureFlags.get( + // Check experimental features flag to prevent inconsistent state. + // Currently, the meta optimizer cannot be disabled independently, + // so we need to check this flag as well. + featureFlagKeys.EXPERIMENTAL_FEATURES + ), + window.electronAPI.featureFlags.get(featureFlagKeys.META_OPTIMIZER), + ]) + + const isExperimentalEnabled = + experimentalFeaturesEnabled.status === 'fulfilled' && + experimentalFeaturesEnabled.value === true + + const isOptimizerEnabled = + metaOptimizerEnabled.status === 'fulfilled' && + metaOptimizerEnabled.value === true + + if (isExperimentalEnabled && isOptimizerEnabled) { + await ensureMetaOptimizerGroup() + } + } catch (error) { + log.error('[initMetaOptimizer] Failed to initialize meta optimizer:', error) + } +}