diff --git a/src/main/index.ts b/src/main/index.ts index b8c79c35..1238087d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -84,6 +84,7 @@ import { SkillsCatalogService, SkillsMutationService, SkillsWatcherService, + createExtensionsRuntimeAdapter, } from './services/extensions'; import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; @@ -871,8 +872,9 @@ async function initializeServices(): Promise { const officialMcpRegistry = new OfficialMcpRegistryService(); const glamaMcpService = new GlamaMcpEnrichmentService(); const mcpAggregator = new McpCatalogAggregator(officialMcpRegistry, glamaMcpService); - const mcpStateService = new McpInstallationStateService(); - const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(); + const extensionsRuntimeAdapter = createExtensionsRuntimeAdapter(); + const mcpStateService = new McpInstallationStateService(extensionsRuntimeAdapter); + const mcpHealthDiagnosticsService = new McpHealthDiagnosticsService(extensionsRuntimeAdapter); const skillsCatalogService = new SkillsCatalogService(); const skillsMutationService = new SkillsMutationService(); skillsWatcherService = new SkillsWatcherService(); @@ -884,8 +886,11 @@ async function initializeServices(): Promise { ); // Install services — resolve binary dynamically via ClaudeBinaryResolver - const pluginInstallService = new PluginInstallService(pluginCatalogService); - const mcpInstallService = new McpInstallService(mcpAggregator); + const pluginInstallService = new PluginInstallService( + pluginCatalogService, + extensionsRuntimeAdapter + ); + const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter); const apiKeyService = new ApiKeyService(); await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS); // warmup() and ensureInstalled() are deferred to after window creation diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 7b51dfc1..7577c112 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -239,8 +239,13 @@ function getMcpHealthDiagnostics(): McpHealthDiagnosticsService { return mcpHealthDiagnostics; } -async function handleMcpDiagnose(): Promise> { - return wrapHandler('mcpDiagnose', () => getMcpHealthDiagnostics().diagnose()); +async function handleMcpDiagnose( + _event: IpcMainInvokeEvent, + projectPath?: string +): Promise> { + return wrapHandler('mcpDiagnose', () => + getMcpHealthDiagnostics().diagnose(typeof projectPath === 'string' ? projectPath : undefined) + ); } // ── Install/Uninstall Handlers ──────────────────────────────────────────── diff --git a/src/main/services/extensions/index.ts b/src/main/services/extensions/index.ts index 1e056147..0190ab83 100644 --- a/src/main/services/extensions/index.ts +++ b/src/main/services/extensions/index.ts @@ -22,6 +22,11 @@ export { SkillsCatalogService } from './skills/SkillsCatalogService'; export { SkillsMutationService } from './skills/SkillsMutationService'; export { SkillsWatcherService } from './skills/SkillsWatcherService'; export { SkillValidator } from './skills/SkillValidator'; +export { + ClaudeExtensionsAdapter, + createExtensionsRuntimeAdapter, + MultimodelExtensionsAdapter, +} from './runtime/ExtensionsRuntimeAdapter'; export { McpHealthDiagnosticsService } from './state/McpHealthDiagnosticsService'; export { McpInstallationStateService } from './state/McpInstallationStateService'; export { PluginInstallationStateService } from './state/PluginInstallationStateService'; diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 687c904a..70daa65e 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -9,12 +9,14 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; import path from 'path'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; + import type { McpCatalogAggregator } from '../catalog/McpCatalogAggregator'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { McpCustomInstallRequest, McpInstallRequest, @@ -42,7 +44,10 @@ function scopeRequiresProjectPath(scope?: string): boolean { } export class McpInstallService { - constructor(private readonly aggregator: McpCatalogAggregator) {} + constructor( + private readonly aggregator: McpCatalogAggregator, + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} async install(request: McpInstallRequest): Promise { const { registryId, serverName, scope, projectPath, envValues, headers } = request; @@ -180,11 +185,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr) { @@ -295,11 +301,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stderr } = await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr) { @@ -364,11 +371,12 @@ export class McpInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); await execCli(claudeBinary, args, { timeout: TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); return { state: 'success' }; } catch (err) { diff --git a/src/main/services/extensions/install/PluginInstallService.ts b/src/main/services/extensions/install/PluginInstallService.ts index 0b994f9f..93479c38 100644 --- a/src/main/services/extensions/install/PluginInstallService.ts +++ b/src/main/services/extensions/install/PluginInstallService.ts @@ -7,12 +7,14 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; import path from 'path'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; + import type { PluginCatalogService } from '../catalog/PluginCatalogService'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { OperationResult, PluginInstallRequest } from '@shared/types/extensions'; const logger = createLogger('Extensions:PluginInstall'); @@ -31,7 +33,10 @@ function scopeRequiresProjectPath(scope?: string): boolean { } export class PluginInstallService { - constructor(private readonly catalogService: PluginCatalogService) {} + constructor( + private readonly catalogService: PluginCatalogService, + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} async install(request: PluginInstallRequest): Promise { const { pluginId, scope, projectPath } = request; @@ -95,11 +100,12 @@ export class PluginInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); const { stdout, stderr } = await execCli(claudeBinary, args, { timeout: INSTALL_TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); if (stderr && !stdout) { @@ -175,11 +181,12 @@ export class PluginInstallService { error: CLI_NOT_FOUND_MESSAGE, }; } + const env = await this.runtimeAdapter.buildManagementCliEnv(claudeBinary); await execCli(claudeBinary, args, { timeout: UNINSTALL_TIMEOUT_MS, cwd: projectPath, - env: buildEnrichedEnv(claudeBinary), + env, }); return { state: 'success' }; } catch (err) { diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts new file mode 100644 index 00000000..66c21893 --- /dev/null +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -0,0 +1,134 @@ +import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; +import { getConfiguredCliFlavor } from '@main/services/team/cliFlavor'; +import { execCli } from '@main/utils/childProcess'; +import { buildProviderAwareCliEnv } from '@main/services/runtime/providerAwareCliEnv'; +import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; + +import { McpConfigStateReader } from './McpConfigStateReader'; +import { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput } from './mcpDiagnosticsParser'; +import { parseInstalledMcpJsonOutput } from './mcpRuntimeJson'; + +import type { CliFlavor } from '@shared/types'; +import type { InstalledMcpEntry, McpServerDiagnostic } from '@shared/types/extensions'; + +const MCP_LIST_TIMEOUT_MS = 15_000; +const MCP_DIAGNOSE_TIMEOUT_MS = 30_000; + +export interface ExtensionsRuntimeAdapter { + readonly flavor: CliFlavor; + buildManagementCliEnv(binaryPath: string): Promise; + getInstalledMcp(projectPath?: string): Promise; + diagnoseMcp(projectPath?: string): Promise; +} + +export class ClaudeExtensionsAdapter implements ExtensionsRuntimeAdapter { + readonly flavor = 'claude' as const; + + constructor(private readonly stateReader = new McpConfigStateReader()) {} + + async buildManagementCliEnv(binaryPath: string): Promise { + const { env } = await buildProviderAwareCliEnv({ + binaryPath, + connectionMode: 'augment', + }); + return env; + } + + async getInstalledMcp(projectPath?: string): Promise { + return this.stateReader.readInstalled(projectPath); + } + + async diagnoseMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout, stderr } = await execCli(binaryPath, ['mcp', 'list'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseMcpDiagnosticsOutput([stdout, stderr].filter(Boolean).join('\n')); + } +} + +export class MultimodelExtensionsAdapter implements ExtensionsRuntimeAdapter { + readonly flavor = 'agent_teams_orchestrator' as const; + + async buildManagementCliEnv(binaryPath: string): Promise { + const { env } = await buildProviderAwareCliEnv({ + binaryPath, + connectionMode: 'augment', + }); + return env; + } + + async getInstalledMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout } = await execCli(binaryPath, ['mcp', 'list', '--json'], { + timeout: MCP_LIST_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseInstalledMcpJsonOutput(stdout); + } + + async diagnoseMcp(projectPath?: string): Promise { + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error(CLI_NOT_FOUND_MESSAGE); + } + + const env = await this.buildManagementCliEnv(binaryPath); + const { stdout } = await execCli(binaryPath, ['mcp', 'diagnose', '--json'], { + timeout: MCP_DIAGNOSE_TIMEOUT_MS, + cwd: projectPath, + env, + }); + + return parseMcpDiagnosticsJsonOutput(stdout); + } +} + +class RuntimeSwitchingExtensionsAdapter implements ExtensionsRuntimeAdapter { + constructor( + private readonly claudeAdapter: ClaudeExtensionsAdapter, + private readonly multimodelAdapter: MultimodelExtensionsAdapter + ) {} + + private getActiveAdapter(): ExtensionsRuntimeAdapter { + return getConfiguredCliFlavor() === 'claude' ? this.claudeAdapter : this.multimodelAdapter; + } + + get flavor(): CliFlavor { + return this.getActiveAdapter().flavor; + } + + buildManagementCliEnv(binaryPath: string): Promise { + return this.getActiveAdapter().buildManagementCliEnv(binaryPath); + } + + getInstalledMcp(projectPath?: string): Promise { + return this.getActiveAdapter().getInstalledMcp(projectPath); + } + + diagnoseMcp(projectPath?: string): Promise { + return this.getActiveAdapter().diagnoseMcp(projectPath); + } +} + +export function createExtensionsRuntimeAdapter(): ExtensionsRuntimeAdapter { + return new RuntimeSwitchingExtensionsAdapter( + new ClaudeExtensionsAdapter(), + new MultimodelExtensionsAdapter() + ); +} diff --git a/src/main/services/extensions/runtime/McpConfigStateReader.ts b/src/main/services/extensions/runtime/McpConfigStateReader.ts new file mode 100644 index 00000000..a9302277 --- /dev/null +++ b/src/main/services/extensions/runtime/McpConfigStateReader.ts @@ -0,0 +1,101 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { getHomeDir } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; + +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +const logger = createLogger('Extensions:McpConfigStateReader'); + +export class McpConfigStateReader { + async readInstalled(projectPath?: string): Promise { + const entries: InstalledMcpEntry[] = []; + const claudeConfig = await this.readClaudeConfig(); + + entries.push(...this.readUserMcpServers(claudeConfig)); + + if (projectPath) { + entries.push(...this.readLocalMcpServers(claudeConfig, projectPath)); + entries.push(...(await this.readProjectMcpServers(projectPath))); + } + + return entries; + } + + private async readClaudeConfig(): Promise | null> { + const configPath = path.join(getHomeDir(), '.claude.json'); + try { + const raw = await fs.readFile(configPath, 'utf-8'); + return JSON.parse(raw) as Record; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.error(`Failed to read MCP servers from ${configPath}:`, err); + return null; + } + } + + private readUserMcpServers(config: Record | null): InstalledMcpEntry[] { + return this.readMcpServersFromConfig(config?.mcpServers, 'user'); + } + + private readLocalMcpServers( + config: Record | null, + projectPath: string + ): InstalledMcpEntry[] { + const projects = + config && typeof config.projects === 'object' && config.projects + ? (config.projects as Record) + : null; + const projectConfig = + projects && typeof projects[projectPath] === 'object' && projects[projectPath] + ? (projects[projectPath] as Record) + : null; + return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local'); + } + + private async readProjectMcpServers(projectPath: string): Promise { + const configPath = path.join(projectPath, '.mcp.json'); + return this.readMcpServersFromFile(configPath, 'project'); + } + + private readMcpServersFromConfig( + value: unknown, + scope: 'user' | 'project' | 'local' + ): InstalledMcpEntry[] { + const mcpServers = + value && typeof value === 'object' + ? (value as Record) + : null; + if (!mcpServers) { + return []; + } + + return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => { + let transport: string | undefined; + if (config.command) transport = 'stdio'; + else if (config.url) transport = 'http'; + + return { name, scope, transport }; + }); + } + + private async readMcpServersFromFile( + filePath: string, + scope: 'user' | 'project' + ): Promise { + try { + const raw = await fs.readFile(filePath, 'utf-8'); + const json = JSON.parse(raw) as Record; + return this.readMcpServersFromConfig(json.mcpServers, scope); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + logger.error(`Failed to read MCP servers from ${filePath}:`, err); + return []; + } + } +} diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts new file mode 100644 index 00000000..5ab55bed --- /dev/null +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -0,0 +1,126 @@ +import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; + +interface McpDiagnoseJsonEntry { + name?: string; + target?: string; + status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout'; + statusLabel?: string; +} + +interface McpDiagnoseJsonPayload { + checkedAt?: string; + diagnostics?: McpDiagnoseJsonEntry[]; +} + +function extractJsonObject(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); + } +} + +function parseStatusChunk(statusChunk: string): { + status: McpServerHealthStatus; + statusLabel: string; +} { + const symbol = statusChunk[0]; + const label = statusChunk.slice(1).trim() || 'Unknown'; + + switch (symbol) { + case '✓': + return { status: 'connected', statusLabel: label }; + case '!': + return { status: 'needs-authentication', statusLabel: label }; + case '✗': + return { status: 'failed', statusLabel: label }; + default: + return { status: 'unknown', statusLabel: statusChunk }; + } +} + +function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { + const statusSeparatorIdx = line.lastIndexOf(' - '); + if (statusSeparatorIdx === -1) { + return null; + } + + const descriptor = line.slice(0, statusSeparatorIdx).trim(); + const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); + + const nameSeparatorIdx = descriptor.indexOf(': '); + if (nameSeparatorIdx === -1) { + return null; + } + + const name = descriptor.slice(0, nameSeparatorIdx).trim(); + const target = descriptor.slice(nameSeparatorIdx + 2).trim(); + if (!name || !target) { + return null; + } + + const { status, statusLabel } = parseStatusChunk(statusChunk); + + return { + name, + target, + status, + statusLabel, + rawLine: line, + checkedAt, + }; +} + +export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { + const checkedAt = Date.now(); + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) + .map((line) => parseDiagnosticLine(line, checkedAt)) + .filter((entry): entry is McpServerDiagnostic => entry !== null); +} + +export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnostic[] { + const parsed = extractJsonObject(output); + const checkedAtValue = parsed.checkedAt ? Date.parse(parsed.checkedAt) : Number.NaN; + const checkedAt = Number.isFinite(checkedAtValue) ? checkedAtValue : Date.now(); + + return (parsed.diagnostics ?? []).flatMap((entry) => { + if ( + typeof entry.name !== 'string' || + typeof entry.target !== 'string' || + typeof entry.statusLabel !== 'string' + ) { + return []; + } + + const normalizedStatus: McpServerHealthStatus = + entry.status === 'connected' + ? 'connected' + : entry.status === 'needs-authentication' + ? 'needs-authentication' + : entry.status === 'failed' || entry.status === 'timeout' + ? 'failed' + : 'unknown'; + + const rawLine = `${entry.name}: ${entry.target} - ${entry.statusLabel}`; + return [ + { + name: entry.name, + target: entry.target, + status: normalizedStatus, + statusLabel: entry.statusLabel, + rawLine, + checkedAt, + }, + ]; + }); +} diff --git a/src/main/services/extensions/runtime/mcpRuntimeJson.ts b/src/main/services/extensions/runtime/mcpRuntimeJson.ts new file mode 100644 index 00000000..f6a52f45 --- /dev/null +++ b/src/main/services/extensions/runtime/mcpRuntimeJson.ts @@ -0,0 +1,47 @@ +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +interface McpListJsonServer { + name?: string; + scope?: string; + transport?: string; +} + +interface McpListJsonPayload { + servers?: McpListJsonServer[]; +} + +function extractJsonObject(raw: string): T { + const trimmed = raw.trim(); + try { + return JSON.parse(trimmed) as T; + } catch { + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as T; + } + throw new Error('No JSON object found in CLI output'); + } +} + +function isSupportedScope(scope: unknown): scope is InstalledMcpEntry['scope'] { + return scope === 'user' || scope === 'project' || scope === 'local'; +} + +export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] { + const parsed = extractJsonObject(output); + + return (parsed.servers ?? []).flatMap((entry) => { + if (typeof entry.name !== 'string' || !isSupportedScope(entry.scope)) { + return []; + } + + return [ + { + name: entry.name, + scope: entry.scope, + transport: typeof entry.transport === 'string' ? entry.transport : undefined, + }, + ]; + }); +} diff --git a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts index 1000adc9..c926cc62 100644 --- a/src/main/services/extensions/state/McpHealthDiagnosticsService.ts +++ b/src/main/services/extensions/state/McpHealthDiagnosticsService.ts @@ -1,97 +1,27 @@ /** - * Runs `claude mcp list` and parses per-server health statuses. + * Resolves MCP diagnostics through the active runtime adapter. + * + * Direct Claude mode parses `claude mcp list` text output. + * Multimodel mode uses the structured `mcp diagnose --json` runtime contract. */ -import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; -import { execCli } from '@main/utils/childProcess'; -import { buildEnrichedEnv } from '@main/utils/cliEnv'; -import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; -import { createLogger } from '@shared/utils/logger'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import { + parseMcpDiagnosticsJsonOutput, + parseMcpDiagnosticsOutput, +} from '../runtime/mcpDiagnosticsParser'; -import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import type { McpServerDiagnostic } from '@shared/types/extensions'; -const logger = createLogger('Extensions:McpHealthDiagnostics'); - -const TIMEOUT_MS = 30_000; +export { parseMcpDiagnosticsJsonOutput, parseMcpDiagnosticsOutput }; export class McpHealthDiagnosticsService { - async diagnose(): Promise { - const claudeBinary = await ClaudeBinaryResolver.resolve(); - if (!claudeBinary) { - throw new Error(CLI_NOT_FOUND_MESSAGE); - } + constructor( + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} - const { stdout, stderr } = await execCli(claudeBinary, ['mcp', 'list'], { - timeout: TIMEOUT_MS, - env: buildEnrichedEnv(claudeBinary), - }); - - const output = [stdout, stderr].filter(Boolean).join('\n'); - const diagnostics = parseMcpDiagnosticsOutput(output); - - logger.info(`Parsed ${diagnostics.length} MCP diagnostic entries`); - return diagnostics; - } -} - -export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] { - const checkedAt = Date.now(); - - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith('Checking MCP server health')) - .map((line) => parseDiagnosticLine(line, checkedAt)) - .filter((entry): entry is McpServerDiagnostic => entry !== null); -} - -function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { - const statusSeparatorIdx = line.lastIndexOf(' - '); - if (statusSeparatorIdx === -1) { - return null; - } - - const descriptor = line.slice(0, statusSeparatorIdx).trim(); - const statusChunk = line.slice(statusSeparatorIdx + 3).trim(); - - const nameSeparatorIdx = descriptor.indexOf(': '); - if (nameSeparatorIdx === -1) { - return null; - } - - const name = descriptor.slice(0, nameSeparatorIdx).trim(); - const target = descriptor.slice(nameSeparatorIdx + 2).trim(); - if (!name || !target) { - return null; - } - - const { status, statusLabel } = parseStatusChunk(statusChunk); - - return { - name, - target, - status, - statusLabel, - rawLine: line, - checkedAt, - }; -} - -function parseStatusChunk(statusChunk: string): { - status: McpServerHealthStatus; - statusLabel: string; -} { - const symbol = statusChunk[0]; - const label = statusChunk.slice(1).trim() || 'Unknown'; - - switch (symbol) { - case '✓': - return { status: 'connected', statusLabel: label }; - case '!': - return { status: 'needs-authentication', statusLabel: label }; - case '✗': - return { status: 'failed', statusLabel: label }; - default: - return { status: 'unknown', statusLabel: statusChunk }; + async diagnose(projectPath?: string): Promise { + return this.runtimeAdapter.diagnoseMcp(projectPath); } } diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index a550237b..5aee9f75 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -1,24 +1,15 @@ /** - * Reads installed MCP server state from the filesystem. + * Resolves installed MCP server state through the active runtime adapter. * - * Sources: - * - User scope: ~/.claude.json → mcpServers - * - Local scope: ~/.claude.json → projects[projectPath].mcpServers - * - Project scope: .mcp.json in project root - * - * Both files are managed by the Claude CLI. This service is read-only. + * Direct Claude mode reads CLI-managed config files. + * Multimodel mode uses the structured `mcp list --json` runtime contract. */ -import * as fs from 'node:fs/promises'; -import * as path from 'node:path'; - -import { getHomeDir } from '@main/utils/pathDecoder'; -import { createLogger } from '@shared/utils/logger'; +import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; +import type { ExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; import type { InstalledMcpEntry } from '@shared/types/extensions'; -const logger = createLogger('Extensions:McpState'); - const CACHE_TTL_MS = 10_000; // 10 seconds interface TimedCache { @@ -29,113 +20,23 @@ interface TimedCache { export class McpInstallationStateService { private cache = new Map>(); - /** - * Get all installed MCP servers across user, local, and project scopes. - */ + constructor( + private readonly runtimeAdapter: ExtensionsRuntimeAdapter = createExtensionsRuntimeAdapter() + ) {} + async getInstalled(projectPath?: string): Promise { - const cacheKey = projectPath ?? '__user__'; + const cacheKey = `${this.runtimeAdapter.flavor}:${projectPath ?? '__user__'}`; const cached = this.cache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return cached.data; } - const entries: InstalledMcpEntry[] = []; - const claudeConfig = await this.readClaudeConfig(); - - // User scope: ~/.claude.json - entries.push(...this.readUserMcpServers(claudeConfig)); - - if (projectPath) { - entries.push(...this.readLocalMcpServers(claudeConfig, projectPath)); - entries.push(...(await this.readProjectMcpServers(projectPath))); - } - + const entries = await this.runtimeAdapter.getInstalledMcp(projectPath); this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } - /** - * Invalidate cache. Call after install/uninstall operations. - */ invalidateCache(): void { this.cache.clear(); } - - // ── Private ──────────────────────────────────────────────────────────── - - private async readClaudeConfig(): Promise | null> { - const configPath = path.join(getHomeDir(), '.claude.json'); - try { - const raw = await fs.readFile(configPath, 'utf-8'); - return JSON.parse(raw) as Record; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - logger.error(`Failed to read MCP servers from ${configPath}:`, err); - return null; - } - } - - private readUserMcpServers(config: Record | null): InstalledMcpEntry[] { - return this.readMcpServersFromConfig(config?.mcpServers, 'user'); - } - - private readLocalMcpServers( - config: Record | null, - projectPath: string - ): InstalledMcpEntry[] { - const projects = - config && typeof config.projects === 'object' && config.projects - ? (config.projects as Record) - : null; - const projectConfig = - projects && typeof projects[projectPath] === 'object' && projects[projectPath] - ? (projects[projectPath] as Record) - : null; - return this.readMcpServersFromConfig(projectConfig?.mcpServers, 'local'); - } - - private async readProjectMcpServers(projectPath: string): Promise { - const configPath = path.join(projectPath, '.mcp.json'); - return this.readMcpServersFromFile(configPath, 'project'); - } - - private readMcpServersFromConfig( - value: unknown, - scope: 'user' | 'project' | 'local' - ): InstalledMcpEntry[] { - const mcpServers = - value && typeof value === 'object' - ? (value as Record) - : null; - if (!mcpServers) { - return []; - } - - return Object.entries(mcpServers).map(([name, config]): InstalledMcpEntry => { - let transport: string | undefined; - if (config.command) transport = 'stdio'; - else if (config.url) transport = 'http'; - - return { name, scope, transport }; - }); - } - - private async readMcpServersFromFile( - filePath: string, - scope: 'user' | 'project' - ): Promise { - try { - const raw = await fs.readFile(filePath, 'utf-8'); - const json = JSON.parse(raw) as Record; - return this.readMcpServersFromConfig(json.mcpServers, scope); - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return []; - } - logger.error(`Failed to read MCP servers from ${filePath}:`, err); - return []; - } - } } diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 3f472c2e..b0700b48 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -30,6 +30,7 @@ import { } from '@main/utils/shellEnv'; import { getErrorMessage } from '@shared/utils/errorHandling'; import { createLogger } from '@shared/utils/logger'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { createHash } from 'crypto'; import { createWriteStream, existsSync, promises as fsp } from 'fs'; import http from 'http'; @@ -145,7 +146,13 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat ...provider, modelVerificationState: provider.modelVerificationState ?? 'idle', modelAvailability: provider.modelAvailability?.map((item) => ({ ...item })) ?? [], - capabilities: { ...provider.capabilities }, + capabilities: { + ...provider.capabilities, + extensions: { + ...createDefaultCliExtensionCapabilities(), + ...provider.capabilities.extensions, + }, + }, selectedBackendId: provider.selectedBackendId ?? null, resolvedBackendId: provider.resolvedBackendId ?? null, availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [], @@ -467,6 +474,7 @@ export class CliInstallerService { capabilities: { teamLaunch: false, oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), }, backend: null, })) @@ -528,7 +536,13 @@ export class CliInstallerService { authMethod: provider.authMethod, selectedBackendId: provider.selectedBackendId ?? null, resolvedBackendId: provider.resolvedBackendId ?? null, - capabilities: { ...provider.capabilities }, + capabilities: { + ...provider.capabilities, + extensions: { + ...createDefaultCliExtensionCapabilities(), + ...provider.capabilities.extensions, + }, + }, backend: provider.backend ? { ...provider.backend } : null, }, }; diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 28d99435..98b6063f 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,6 +1,7 @@ import { execCli } from '@main/utils/childProcess'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; @@ -13,6 +14,19 @@ const logger = createLogger('ClaudeMultimodelBridgeService'); const PROVIDER_STATUS_TIMEOUT_MS = 10_000; const PROVIDER_MODELS_TIMEOUT_MS = 10_000; +interface RuntimeExtensionCapabilityResponse { + status?: 'supported' | 'read-only' | 'unsupported'; + ownership?: 'shared' | 'provider-scoped'; + reason?: string | null; +} + +interface RuntimeExtensionCapabilitiesResponse { + plugins?: RuntimeExtensionCapabilityResponse; + mcp?: RuntimeExtensionCapabilityResponse; + skills?: RuntimeExtensionCapabilityResponse; + apiKeys?: RuntimeExtensionCapabilityResponse; +} + interface ProviderStatusCommandResponse { schemaVersion?: number; providers?: Record< @@ -27,6 +41,7 @@ interface ProviderStatusCommandResponse { capabilities?: { teamLaunch?: boolean; oneShot?: boolean; + extensions?: RuntimeExtensionCapabilitiesResponse; }; backend?: { kind?: string; @@ -84,6 +99,7 @@ interface UnifiedRuntimeStatusResponse { capabilities?: { teamLaunch?: boolean; oneShot?: boolean; + extensions?: RuntimeExtensionCapabilitiesResponse; }; backend?: { kind?: string; @@ -129,6 +145,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat capabilities: { teamLaunch: false, oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, @@ -139,6 +156,39 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat }; } +function mapRuntimeExtensionCapabilities( + capabilities?: RuntimeExtensionCapabilitiesResponse +): CliProviderStatus['capabilities']['extensions'] { + const defaults = createDefaultCliExtensionCapabilities(); + + return { + plugins: { + ...defaults.plugins, + status: capabilities?.plugins?.status ?? defaults.plugins.status, + ownership: capabilities?.plugins?.ownership ?? defaults.plugins.ownership, + reason: capabilities?.plugins?.reason ?? defaults.plugins.reason, + }, + mcp: { + ...defaults.mcp, + status: capabilities?.mcp?.status ?? defaults.mcp.status, + ownership: capabilities?.mcp?.ownership ?? defaults.mcp.ownership, + reason: capabilities?.mcp?.reason ?? defaults.mcp.reason, + }, + skills: { + ...defaults.skills, + status: capabilities?.skills?.status ?? defaults.skills.status, + ownership: capabilities?.skills?.ownership ?? defaults.skills.ownership, + reason: capabilities?.skills?.reason ?? defaults.skills.reason, + }, + apiKeys: { + ...defaults.apiKeys, + status: capabilities?.apiKeys?.status ?? defaults.apiKeys.status, + ownership: capabilities?.apiKeys?.ownership ?? defaults.apiKeys.ownership, + reason: capabilities?.apiKeys?.reason ?? defaults.apiKeys.reason, + }, + }; +} + function extractModelIds( models: (string | { id?: string; label?: string; description?: string })[] | undefined ): string[] { @@ -203,6 +253,7 @@ export class ClaudeMultimodelBridgeService { capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, oneShot: runtimeStatus.capabilities?.oneShot === true, + extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions), }, selectedBackendId: runtimeStatus.selectedBackendId ?? null, resolvedBackendId: runtimeStatus.resolvedBackendId ?? null, @@ -325,6 +376,7 @@ export class ClaudeMultimodelBridgeService { provider.capabilities = { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }; } } catch (error) { @@ -428,6 +480,7 @@ export class ClaudeMultimodelBridgeService { capabilities: { teamLaunch: runtimeStatus.capabilities?.teamLaunch === true, oneShot: runtimeStatus.capabilities?.oneShot === true, + extensions: mapRuntimeExtensionCapabilities(runtimeStatus.capabilities?.extensions), }, backend: runtimeStatus.backend?.kind ? { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c390a9e..eb2ffbf3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1578,7 +1578,8 @@ const electronAPI: ElectronAPI = { invokeIpcWithResult(MCP_REGISTRY_GET_BY_ID, registryId), getInstalled: (projectPath?: string) => invokeIpcWithResult(MCP_REGISTRY_GET_INSTALLED, projectPath), - diagnose: () => invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE), + diagnose: (projectPath?: string) => + invokeIpcWithResult(MCP_REGISTRY_DIAGNOSE, projectPath), install: (request: McpInstallRequest) => invokeIpcWithResult(MCP_REGISTRY_INSTALL, request), installCustom: (request: McpCustomInstallRequest) => diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index b1a56b2e..6f066468 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -4,6 +4,7 @@ import { api } from '@renderer/api'; import { createLogger } from '@shared/utils/logger'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import type { AppState } from '../types'; import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types'; @@ -36,6 +37,7 @@ export function createLoadingMultimodelCliStatus(): CliInstallationStatus { capabilities: { teamLaunch: false, oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), }, backend: null, })); diff --git a/src/shared/types/cliInstaller.ts b/src/shared/types/cliInstaller.ts index a68947e0..9ccbb235 100644 --- a/src/shared/types/cliInstaller.ts +++ b/src/shared/types/cliInstaller.ts @@ -57,6 +57,22 @@ export interface CliExternalRuntimeDiagnostic { detailMessage?: string | null; } +export type CliExtensionCapabilityStatus = 'supported' | 'read-only' | 'unsupported'; +export type CliExtensionOwnership = 'shared' | 'provider-scoped'; + +export interface CliExtensionCapability { + status: CliExtensionCapabilityStatus; + ownership: CliExtensionOwnership; + reason?: string | null; +} + +export interface CliExtensionCapabilities { + plugins: CliExtensionCapability; + mcp: CliExtensionCapability; + skills: CliExtensionCapability; + apiKeys: CliExtensionCapability; +} + export type CliProviderModelAvailabilityStatus = | 'checking' | 'available' @@ -85,6 +101,7 @@ export interface CliProviderStatus { capabilities: { teamLaunch: boolean; oneShot: boolean; + extensions: CliExtensionCapabilities; }; selectedBackendId?: string | null; resolvedBackendId?: string | null; diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts index f6d7927a..c72935c1 100644 --- a/src/shared/types/extensions/api.ts +++ b/src/shared/types/extensions/api.ts @@ -52,7 +52,7 @@ export interface McpCatalogAPI { ) => Promise<{ servers: McpCatalogItem[]; nextCursor?: string }>; getById: (registryId: string) => Promise; getInstalled: (projectPath?: string) => Promise; - diagnose: () => Promise; + diagnose: (projectPath?: string) => Promise; install: (request: McpInstallRequest) => Promise; installCustom: (request: McpCustomInstallRequest) => Promise; uninstall: (name: string, scope?: string, projectPath?: string) => Promise; diff --git a/src/shared/utils/providerExtensionCapabilities.ts b/src/shared/utils/providerExtensionCapabilities.ts new file mode 100644 index 00000000..c6d03b49 --- /dev/null +++ b/src/shared/utils/providerExtensionCapabilities.ts @@ -0,0 +1,48 @@ +import type { + CliExtensionCapabilities, + CliExtensionCapability, + CliProviderStatus, +} from '@shared/types'; + +const SUPPORTED_SHARED_CAPABILITY: CliExtensionCapability = { + status: 'supported', + ownership: 'shared', + reason: null, +}; + +export function createDefaultCliExtensionCapabilities( + overrides?: Partial +): CliExtensionCapabilities { + return { + plugins: { ...SUPPORTED_SHARED_CAPABILITY }, + mcp: { ...SUPPORTED_SHARED_CAPABILITY }, + skills: { ...SUPPORTED_SHARED_CAPABILITY }, + apiKeys: { ...SUPPORTED_SHARED_CAPABILITY }, + ...overrides, + }; +} + +export function getCliProviderExtensionCapabilities( + provider: Pick +): CliExtensionCapabilities { + return provider.capabilities.extensions ?? createDefaultCliExtensionCapabilities(); +} + +export function getCliProviderExtensionCapability( + provider: Pick, + section: keyof CliExtensionCapabilities +): CliExtensionCapability { + return getCliProviderExtensionCapabilities(provider)[section]; +} + +export function isCliExtensionCapabilityAvailable( + capability: Pick +): boolean { + return capability.status === 'supported' || capability.status === 'read-only'; +} + +export function isCliExtensionCapabilityMutable( + capability: Pick +): boolean { + return capability.status === 'supported'; +} diff --git a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts index bb239d68..afb7b4eb 100644 --- a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts +++ b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { parseMcpDiagnosticsOutput } from '@main/services/extensions/state/McpHealthDiagnosticsService'; +import { + McpHealthDiagnosticsService, + parseMcpDiagnosticsJsonOutput, + parseMcpDiagnosticsOutput, +} from '@main/services/extensions/state/McpHealthDiagnosticsService'; describe('parseMcpDiagnosticsOutput', () => { it('parses mixed MCP health lines from claude mcp list', () => { @@ -40,4 +44,65 @@ another log line`); expect(diagnostics).toEqual([]); }); + + it('parses structured multimodel MCP diagnostics JSON', () => { + const diagnostics = parseMcpDiagnosticsJsonOutput( + JSON.stringify({ + checkedAt: '2026-04-17T10:00:00.000Z', + diagnostics: [ + { + name: 'context7', + target: 'npx -y @upstash/context7-mcp', + status: 'connected', + statusLabel: 'Connected', + }, + { + name: 'tavily', + target: 'https://mcp.tavily.com/mcp', + status: 'timeout', + statusLabel: 'Timed out', + }, + ], + }) + ); + + expect(diagnostics).toEqual([ + expect.objectContaining({ + name: 'context7', + status: 'connected', + statusLabel: 'Connected', + }), + expect.objectContaining({ + name: 'tavily', + status: 'failed', + statusLabel: 'Timed out', + }), + ]); + }); +}); + +describe('McpHealthDiagnosticsService', () => { + it('delegates diagnostics to the active runtime adapter', async () => { + const diagnoseMcp = vi.fn().mockResolvedValue([ + { + name: 'context7', + target: 'npx -y @upstash/context7-mcp', + status: 'connected', + statusLabel: 'Connected', + rawLine: 'context7: npx -y @upstash/context7-mcp - Connected', + checkedAt: 1, + }, + ]); + const service = new McpHealthDiagnosticsService({ + flavor: 'agent_teams_orchestrator', + buildManagementCliEnv: vi.fn(), + getInstalledMcp: vi.fn(), + diagnoseMcp, + }); + + await expect(service.diagnose('/tmp/project-a')).resolves.toEqual([ + expect.objectContaining({ name: 'context7' }), + ]); + expect(diagnoseMcp).toHaveBeenCalledWith('/tmp/project-a'); + }); }); diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts index 61bfb566..c1a1c5d1 100644 --- a/test/main/services/extensions/McpInstallationStateService.test.ts +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -1,10 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs/promises'; +import { ClaudeExtensionsAdapter } from '@main/services/extensions/runtime/ExtensionsRuntimeAdapter'; +import { McpConfigStateReader } from '@main/services/extensions/runtime/McpConfigStateReader'; import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService'; vi.mock('@main/utils/pathDecoder', () => ({ getHomeDir: () => '/tmp/mock-home', + getClaudeBasePath: () => '/tmp/mock-home/.claude', + setClaudeBasePathOverride: vi.fn(), })); vi.mock('node:fs/promises'); @@ -14,7 +18,9 @@ describe('McpInstallationStateService', () => { const mockedFs = vi.mocked(fs); beforeEach(() => { - service = new McpInstallationStateService(); + service = new McpInstallationStateService( + new ClaudeExtensionsAdapter(new McpConfigStateReader()) + ); vi.clearAllMocks(); }); @@ -146,5 +152,28 @@ describe('McpInstallationStateService', () => { ]); expect(mockedFs.readFile).toHaveBeenCalledTimes(4); }); + + it('supports multimodel MCP state through the runtime adapter contract', async () => { + const getInstalledMcp = vi + .fn() + .mockResolvedValueOnce([{ name: 'context7', scope: 'user', transport: 'stdio' }]) + .mockResolvedValueOnce([{ name: 'repo-mcp', scope: 'project', transport: 'http' }]); + service = new McpInstallationStateService({ + flavor: 'agent_teams_orchestrator', + buildManagementCliEnv: vi.fn(), + diagnoseMcp: vi.fn(), + getInstalledMcp, + }); + + await expect(service.getInstalled('/tmp/project-a')).resolves.toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + ]); + await expect(service.getInstalled('/tmp/project-b')).resolves.toEqual([ + { name: 'repo-mcp', scope: 'project', transport: 'http' }, + ]); + expect(getInstalledMcp).toHaveBeenCalledTimes(2); + expect(getInstalledMcp).toHaveBeenNthCalledWith(1, '/tmp/project-a'); + expect(getInstalledMcp).toHaveBeenNthCalledWith(2, '/tmp/project-b'); + }); }); }); diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 32b40601..d6cde5c7 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -93,7 +93,16 @@ describe('ClaudeMultimodelBridgeService', () => { authMethod: 'oauth_token', verificationState: 'verified', canLoginFromUi: true, - capabilities: { teamLaunch: true, oneShot: true }, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, backend: { kind: 'anthropic', label: 'Anthropic' }, }, codex: { @@ -102,7 +111,20 @@ describe('ClaudeMultimodelBridgeService', () => { verificationState: 'verified', canLoginFromUi: true, statusMessage: 'Not connected', - capabilities: { teamLaunch: true, oneShot: true }, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { + status: 'unsupported', + ownership: 'shared', + reason: 'Anthropic only', + }, + mcp: { status: 'supported', ownership: 'shared', reason: null }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, backend: { kind: 'openai', label: 'OpenAI' }, }, }, @@ -166,6 +188,15 @@ describe('ClaudeMultimodelBridgeService', () => { authenticated: false, models: ['gpt-5-codex'], statusMessage: 'Not connected', + capabilities: { + extensions: { + plugins: { + status: 'unsupported', + ownership: 'shared', + reason: 'Anthropic only', + }, + }, + }, }); expect(providers[2]).toMatchObject({ providerId: 'gemini', diff --git a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts index a498882d..ab0634fe 100644 --- a/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts +++ b/test/main/services/runtime/CliProviderModelAvailabilityService.test.ts @@ -17,6 +17,7 @@ import { CliProviderModelAvailabilityService, type ProviderModelAvailabilityContext, } from '@main/services/runtime/CliProviderModelAvailabilityService'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; function createContext(models: string[]): ProviderModelAvailabilityContext { return { @@ -33,6 +34,7 @@ function createContext(models: string[]): ProviderModelAvailabilityContext { capabilities: { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }, backend: { kind: 'openai', @@ -78,12 +80,12 @@ describe('CliProviderModelAvailabilityService', () => { connectionIssues: {}, }); execCliMock.mockRejectedValue( - new Error("The 'gpt-5.2-codex' model is not supported when using Codex with a ChatGPT account.") + new Error("The 'gpt-5.4' model is not supported when using Codex with a ChatGPT account.") ); const onUpdate = vi.fn(); const service = new CliProviderModelAvailabilityService(onUpdate); - service.getSnapshot(createContext(['gpt-5.2-codex'])); + service.getSnapshot(createContext(['gpt-5.4'])); await vi.waitFor(() => { expect(onUpdate).toHaveBeenCalledWith( @@ -92,7 +94,7 @@ describe('CliProviderModelAvailabilityService', () => { expect.objectContaining({ modelAvailability: [ expect.objectContaining({ - modelId: 'gpt-5.2-codex', + modelId: 'gpt-5.4', status: 'unavailable', reason: 'Not available with Codex ChatGPT subscription', }), diff --git a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts index a8ceb766..6e82d4cc 100644 --- a/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts +++ b/test/renderer/components/runtime/ProviderRuntimeSettingsDialog.test.ts @@ -149,6 +149,7 @@ vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({ })); import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; function createCodexProvider( overrides?: Partial & { @@ -169,6 +170,7 @@ function createCodexProvider( capabilities: { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }, selectedBackendId: 'auto', resolvedBackendId: 'adapter', @@ -210,6 +212,7 @@ function createAnthropicProvider( capabilities: { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, @@ -241,6 +244,7 @@ function createGeminiProvider(): CliProviderStatus { capabilities: { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }, selectedBackendId: 'auto', resolvedBackendId: 'api', diff --git a/test/renderer/components/runtime/providerConnectionUi.test.ts b/test/renderer/components/runtime/providerConnectionUi.test.ts index a3632a44..6bdd87d0 100644 --- a/test/renderer/components/runtime/providerConnectionUi.test.ts +++ b/test/renderer/components/runtime/providerConnectionUi.test.ts @@ -5,6 +5,7 @@ import { getProviderCredentialSummary, getProviderCurrentRuntimeSummary, } from '@renderer/components/runtime/providerConnectionUi'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import type { CliProviderStatus } from '@shared/types'; @@ -27,6 +28,7 @@ function createAnthropicProvider( capabilities: { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, @@ -64,6 +66,7 @@ function createCodexProvider( capabilities: { teamLaunch: true, oneShot: true, + extensions: createDefaultCliExtensionCapabilities(), }, selectedBackendId: 'auto', resolvedBackendId: 'adapter', diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 32a5cf15..39500110 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -51,6 +51,7 @@ vi.mock('@renderer/api', () => ({ import { api } from '@renderer/api'; import { useStore } from '@renderer/store'; +import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; import type { CliInstallationStatus } from '@shared/types'; @@ -196,7 +197,11 @@ describe('cliInstallerSlice', () => { statusMessage: 'Runtime found, but startup health check failed.', models: [], canLoginFromUi: false, - capabilities: { teamLaunch: false, oneShot: false }, + capabilities: { + teamLaunch: false, + oneShot: false, + extensions: createDefaultCliExtensionCapabilities(), + }, backend: null, }, ],