From 096437b2fd689e5ca56c4b0d1c67b3b696ae9872 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 10:08:13 +0300 Subject: [PATCH 01/42] feat(extensions): add provider-aware runtime adapters --- src/main/index.ts | 13 +- src/main/ipc/extensions.ts | 9 +- src/main/services/extensions/index.ts | 5 + .../extensions/install/McpInstallService.ts | 18 ++- .../install/PluginInstallService.ts | 15 +- .../runtime/ExtensionsRuntimeAdapter.ts | 134 ++++++++++++++++++ .../runtime/McpConfigStateReader.ts | 101 +++++++++++++ .../runtime/mcpDiagnosticsParser.ts | 126 ++++++++++++++++ .../extensions/runtime/mcpRuntimeJson.ts | 47 ++++++ .../state/McpHealthDiagnosticsService.ts | 104 +++----------- .../state/McpInstallationStateService.ts | 121 ++-------------- .../infrastructure/CliInstallerService.ts | 18 ++- .../runtime/ClaudeMultimodelBridgeService.ts | 53 +++++++ src/preload/index.ts | 3 +- .../store/slices/cliInstallerSlice.ts | 2 + src/shared/types/cliInstaller.ts | 17 +++ src/shared/types/extensions/api.ts | 2 +- .../utils/providerExtensionCapabilities.ts | 48 +++++++ .../McpHealthDiagnosticsService.test.ts | 69 ++++++++- .../McpInstallationStateService.test.ts | 31 +++- .../ClaudeMultimodelBridgeService.test.ts | 35 ++++- ...liProviderModelAvailabilityService.test.ts | 8 +- .../ProviderRuntimeSettingsDialog.test.ts | 4 + .../runtime/providerConnectionUi.test.ts | 3 + test/renderer/store/cliInstallerSlice.test.ts | 7 +- 25 files changed, 768 insertions(+), 225 deletions(-) create mode 100644 src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts create mode 100644 src/main/services/extensions/runtime/McpConfigStateReader.ts create mode 100644 src/main/services/extensions/runtime/mcpDiagnosticsParser.ts create mode 100644 src/main/services/extensions/runtime/mcpRuntimeJson.ts create mode 100644 src/shared/utils/providerExtensionCapabilities.ts 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, }, ], From b3427a64ab6dcde4c63e290fc12fc140cb81168d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 10:08:33 +0300 Subject: [PATCH 02/42] feat(extensions): surface provider-aware capabilities in UI --- .../extensions/ExtensionStoreView.tsx | 86 +++++++++++-- .../extensions/apikeys/ApiKeysPanel.tsx | 95 ++++++++++++-- .../extensions/common/InstallButton.tsx | 3 + .../extensions/mcp/McpServerCard.tsx | 1 + .../extensions/mcp/McpServerDetailDialog.tsx | 1 + .../extensions/mcp/McpServersPanel.tsx | 22 ++-- .../extensions/plugins/PluginCard.tsx | 1 + .../extensions/plugins/PluginDetailDialog.tsx | 1 + .../extensions/plugins/PluginsPanel.tsx | 23 +++- .../extensions/skills/SkillsPanel.tsx | 7 ++ src/renderer/store/slices/extensionsSlice.ts | 24 ++-- src/shared/utils/extensionNormalizers.ts | 67 ++++++++-- .../plugins/PluginDetailDialog.test.ts | 8 +- test/renderer/store/extensionsSlice.test.ts | 70 +++++++++++ .../shared/utils/extensionNormalizers.test.ts | 117 ++++++++++++++++-- 15 files changed, 469 insertions(+), 57 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 7f1331c3..1f9ea351 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; +import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs'; import { @@ -164,14 +165,20 @@ export const ExtensionStoreView = (): React.JSX.Element => { const isRefreshing = cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; const cliStatusBanner = useMemo(() => { + const providers = cliStatus?.providers ?? []; + const isMultimodel = cliStatus?.flavor === 'agent_teams_orchestrator' && providers.length > 0; + if (cliStatusLoading || cliStatus === null) { return (
-

Checking Claude CLI availability

+

+ Checking extensions runtime availability +

- Extensions need Claude CLI to install plugins, run MCP servers, and validate auth. + Extensions need the configured runtime to manage plugins, MCP servers, skills, and + provider connections.

@@ -186,13 +193,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {

{cliLaunchIssue - ? 'Claude CLI was found but failed to start' - : 'Claude CLI is not available'} + ? 'The configured runtime was found but failed to start' + : 'The configured runtime is not available'}

{cliLaunchIssue - ? 'Plugin installs are disabled until Claude CLI passes its startup health check. Open the Dashboard to repair or reinstall it.' - : 'Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to install it and retry.'} + ? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.' + : 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}

{cliLaunchIssue && cliStatus.launchError && (

@@ -207,7 +214,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); } - if (!cliStatus.authLoggedIn) { + if (!isMultimodel && !cliStatus.authLoggedIn) { return (

@@ -226,6 +233,68 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); } + if (isMultimodel) { + return ( +
+
+ +
+

Multimodel runtime capabilities

+

+ Provider support can differ by section. Plugins are shown only where the runtime + explicitly declares support. +

+
+
+
+ {providers.map((provider) => { + const statusTone = provider.authenticated + ? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300' + : provider.supported + ? 'border-amber-500/30 bg-amber-500/5 text-amber-300' + : 'border-border bg-surface-raised text-text-muted'; + const statusLabel = provider.authenticated + ? 'Connected' + : provider.supported + ? 'Needs setup' + : 'Unsupported'; + const pluginStatus = provider.capabilities.extensions.plugins.status; + + return ( +
+
+
+

{provider.displayName}

+

+ {provider.statusMessage ?? provider.backend?.label ?? 'Ready to configure'} +

+
+ + {statusLabel} + +
+
+ + Plugins: {pluginStatus === 'supported' ? 'supported' : 'limited'} + + + MCP: {provider.capabilities.extensions.mcp.status} + + + Skills: {provider.capabilities.extensions.skills.ownership} + +
+
+ ); + })} +
+
+ ); + } + return (
@@ -280,7 +349,8 @@ export const ExtensionStoreView = (): React.JSX.Element => { {!cliInstalled && (
- Claude CLI is required to install or uninstall extensions. Install it from Settings. + The configured runtime is required to install or uninstall extensions. Install or + repair it from the Dashboard.
)} {/* Active sessions warning */} diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index 951aecb0..a45b0e58 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -2,7 +2,7 @@ * ApiKeysPanel — grid of saved API keys with add button and empty state. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -16,15 +16,17 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; export const ApiKeysPanel = (): React.JSX.Element => { - const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus } = useStore( - useShallow((s) => ({ - apiKeys: s.apiKeys, - apiKeysLoading: s.apiKeysLoading, - apiKeysError: s.apiKeysError, - storageStatus: s.apiKeyStorageStatus, - fetchStorageStatus: s.fetchApiKeyStorageStatus, - })) - ); + const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } = + useStore( + useShallow((s) => ({ + apiKeys: s.apiKeys, + apiKeysLoading: s.apiKeysLoading, + apiKeysError: s.apiKeysError, + storageStatus: s.apiKeyStorageStatus, + fetchStorageStatus: s.fetchApiKeyStorageStatus, + cliStatus: s.cliStatus, + })) + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingKey, setEditingKey] = useState(null); @@ -49,9 +51,82 @@ export const ApiKeysPanel = (): React.JSX.Element => { }; const isOsKeychain = storageStatus?.encryptionMethod === 'os-keychain'; + const providerKeyCards = useMemo(() => { + if (!cliStatus?.providers?.length) { + return []; + } + + return ( + [ + { + providerId: 'anthropic', + label: 'Anthropic runtime', + envVar: 'ANTHROPIC_API_KEY', + }, + { + providerId: 'codex', + label: 'Codex runtime', + envVar: 'OPENAI_API_KEY', + }, + ] as const + ).flatMap((item) => { + const provider = cliStatus.providers.find((entry) => entry.providerId === item.providerId); + if (!provider) { + return []; + } + + return [ + { + ...item, + authenticated: provider.authenticated, + apiKeyConfigured: provider.connection?.apiKeyConfigured ?? false, + sourceLabel: provider.connection?.apiKeySourceLabel ?? null, + statusMessage: provider.statusMessage ?? null, + }, + ]; + }); + }, [cliStatus]); return (
+ {providerKeyCards.length > 0 && ( +
+ {providerKeyCards.map((provider) => ( +
+
+
+

{provider.label}

+

{provider.envVar}

+
+ + {provider.authenticated + ? 'Connected' + : provider.apiKeyConfigured + ? 'Key configured' + : 'Key missing'} + +
+

+ {provider.sourceLabel + ? `Current source: ${provider.sourceLabel}.` + : 'No stored or environment key detected for this provider.'} + {provider.statusMessage ? ` ${provider.statusMessage}` : ''} +

+
+ ))} +
+ )} {/* Header row */}

diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index 2bc48112..78930ad7 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -24,6 +24,7 @@ interface InstallButtonProps { isInstalled: boolean; onInstall: () => void; onUninstall: () => void; + section?: 'plugins' | 'mcp'; disabled?: boolean; size?: 'sm' | 'default'; errorMessage?: string; @@ -34,6 +35,7 @@ export const InstallButton = ({ isInstalled, onInstall, onUninstall, + section = 'plugins', disabled, size = 'sm', errorMessage, @@ -48,6 +50,7 @@ export const InstallButton = ({ isInstalled, cliStatus, cliStatusLoading, + section, }); const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index afae2142..4f58b343 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -258,6 +258,7 @@ export const McpServerCard = ({ installMcpServer({ registryId: server.id, diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index d16e0885..412be5a8 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -528,6 +528,7 @@ export const McpServerDetailDialog = ({ ({ browseCatalog: s.mcpBrowseCatalog, @@ -105,6 +106,7 @@ export const McpServersPanel = ({ mcpDiagnosticsError: s.mcpDiagnosticsError, mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt, runMcpDiagnostics: s.runMcpDiagnostics, + cliStatus: s.cliStatus, })) ); @@ -118,8 +120,8 @@ export const McpServersPanel = ({ }, [browseCatalog.length, browseError, browseLoading, mcpBrowse]); useEffect(() => { - void runMcpDiagnostics(); - }, [runMcpDiagnostics]); + void runMcpDiagnostics(projectPath ?? undefined); + }, [projectPath, runMcpDiagnostics]); // Fetch GitHub stars after catalog loads (fire-and-forget) useEffect(() => { @@ -185,6 +187,12 @@ export const McpServersPanel = ({ // Sort displayed servers const displayServers = useMemo(() => sortMcpServers(rawServers, mcpSort), [rawServers, mcpSort]); + const runtimeLabel = + cliStatus?.flavor === 'agent_teams_orchestrator' ? 'multimodel runtime' : 'Claude CLI'; + const diagnosticsCommand = + cliStatus?.flavor === 'agent_teams_orchestrator' + ? 'claude-multimodel mcp diagnose' + : 'claude mcp list'; // Find selected server (search in both lists to avoid losing selection during search toggle) const selectedServer = useMemo(() => { @@ -205,14 +213,12 @@ export const McpServersPanel = ({

MCP Health Status

{mcpDiagnosticsLoading ? ( - <> - Checking installed MCP servers via Claude CLI (claude mcp list) ... - + <>Checking installed MCP servers via {runtimeLabel} ... ) : mcpDiagnosticsLastCheckedAt ? ( `Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}` ) : ( <> - Run diagnostics (claude mcp list) to verify installed MCP + Run diagnostics ({diagnosticsCommand}) to verify installed MCP connectivity. )} @@ -221,7 +227,7 @@ export const McpServersPanel = ({

@@ -338,6 +362,8 @@ export const SkillsPanel = ({ ['all', 'All skills'], ['project', 'Project'], ['personal', 'Personal'], + ['shared', 'Shared'], + ['codex-only', 'Codex only'], ['needs-attention', 'Needs attention'], ['has-scripts', 'Has scripts'], ] as [SkillsQuickFilter, string][] @@ -449,7 +475,10 @@ export const SkillsPanel = ({
- Stored in {formatRootKind(skill.rootKind)} + Stored in {formatSkillRootKind(skill.rootKind)} + + + {getSkillAudienceLabel(skill.rootKind)} {skill.flags.hasScripts && ( @@ -532,7 +561,10 @@ export const SkillsPanel = ({
- Stored in {formatRootKind(skill.rootKind)} + Stored in {formatSkillRootKind(skill.rootKind)} + + + {getSkillAudienceLabel(skill.rootKind)} {skill.flags.hasScripts && ( diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index eff3ceea..c461aa94 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -237,7 +237,8 @@ export const MessageComposer = ({ buildSlashCommandSuggestions( getSuggestedSlashCommandsForProvider(leadProviderId), projectSkills, - userSkills + userSkills, + leadProviderId ), [leadProviderId, projectSkills, userSkills] ); diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts index 370df4e9..63bc0b43 100644 --- a/src/renderer/utils/skillCommandSuggestions.ts +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -1,13 +1,41 @@ +import { getSkillAudienceLabel, isSkillAvailableForProvider } from '@shared/utils/skillRoots'; import { isSupportedSlashCommandName } from '@shared/utils/slashCommands'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { SkillCatalogItem } from '@shared/types/extensions'; +import type { TeamProviderId } from '@shared/types'; import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; +function orderSkillsForProvider( + projectSkills: readonly SkillCatalogItem[], + userSkills: readonly SkillCatalogItem[], + providerId?: TeamProviderId +): SkillCatalogItem[] { + const visibleProjectSkills = projectSkills.filter((skill) => + isSkillAvailableForProvider(skill.rootKind, providerId) + ); + const visibleUserSkills = userSkills.filter((skill) => + isSkillAvailableForProvider(skill.rootKind, providerId) + ); + + if (providerId !== 'codex') { + return [...visibleProjectSkills, ...visibleUserSkills]; + } + + const isCodexOnly = (skill: SkillCatalogItem) => skill.rootKind === 'codex'; + return [ + ...visibleProjectSkills.filter(isCodexOnly), + ...visibleProjectSkills.filter((skill) => !isCodexOnly(skill)), + ...visibleUserSkills.filter(isCodexOnly), + ...visibleUserSkills.filter((skill) => !isCodexOnly(skill)), + ]; +} + export function buildSlashCommandSuggestions( builtIns: readonly KnownSlashCommandDefinition[], projectSkills: readonly SkillCatalogItem[], - userSkills: readonly SkillCatalogItem[] + userSkills: readonly SkillCatalogItem[], + providerId?: TeamProviderId ): MentionSuggestion[] { const builtInNames = new Set(builtIns.map((command) => command.name.trim().toLowerCase())); const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ @@ -21,7 +49,7 @@ export function buildSlashCommandSuggestions( const seenSkillNames = new Set(); const skillSuggestions: MentionSuggestion[] = []; - for (const skill of [...projectSkills, ...userSkills]) { + for (const skill of orderSkillsForProvider(projectSkills, userSkills, providerId)) { const normalizedFolderName = skill.folderName.trim().toLowerCase(); if ( !skill.isValid || @@ -39,7 +67,7 @@ export function buildSlashCommandSuggestions( name: skill.folderName, command: `/${normalizedFolderName}`, description: skill.description, - subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill', + subtitle: `${skill.scope === 'project' ? 'Project skill' : 'Personal skill'} - ${getSkillAudienceLabel(skill.rootKind)}`, searchText: `${skill.name} ${skill.folderName}`, type: 'skill', }); diff --git a/src/shared/types/extensions/skill.ts b/src/shared/types/extensions/skill.ts index 5921acc7..9aca8729 100644 --- a/src/shared/types/extensions/skill.ts +++ b/src/shared/types/extensions/skill.ts @@ -4,7 +4,7 @@ export type SkillScope = 'user' | 'project'; -export type SkillRootKind = 'claude' | 'cursor' | 'agents'; +export type SkillRootKind = 'claude' | 'cursor' | 'agents' | 'codex'; export type SkillSourceType = 'filesystem'; diff --git a/src/shared/utils/skillRoots.ts b/src/shared/utils/skillRoots.ts new file mode 100644 index 00000000..e2f84a42 --- /dev/null +++ b/src/shared/utils/skillRoots.ts @@ -0,0 +1,61 @@ +import type { TeamProviderId } from '@shared/types'; +import type { SkillRootKind } from '@shared/types/extensions'; + +export type SkillAudience = 'shared' | 'codex'; + +export interface SkillRootDefinition { + rootKind: SkillRootKind; + directoryName: `.${string}`; + segments: [string, 'skills']; + audience: SkillAudience; +} + +export const SKILL_ROOT_DEFINITIONS: readonly SkillRootDefinition[] = [ + { + rootKind: 'claude', + directoryName: '.claude', + segments: ['.claude', 'skills'], + audience: 'shared', + }, + { + rootKind: 'cursor', + directoryName: '.cursor', + segments: ['.cursor', 'skills'], + audience: 'shared', + }, + { + rootKind: 'agents', + directoryName: '.agents', + segments: ['.agents', 'skills'], + audience: 'shared', + }, + { + rootKind: 'codex', + directoryName: '.codex', + segments: ['.codex', 'skills'], + audience: 'codex', + }, +] as const; + +export function getSkillRootDefinition(rootKind: SkillRootKind): SkillRootDefinition { + return SKILL_ROOT_DEFINITIONS.find((definition) => definition.rootKind === rootKind)!; +} + +export function formatSkillRootKind(rootKind: SkillRootKind): string { + return getSkillRootDefinition(rootKind).directoryName; +} + +export function getSkillAudience(rootKind: SkillRootKind): SkillAudience { + return getSkillRootDefinition(rootKind).audience; +} + +export function getSkillAudienceLabel(rootKind: SkillRootKind): string { + return getSkillAudience(rootKind) === 'codex' ? 'Codex only' : 'Shared'; +} + +export function isSkillAvailableForProvider( + rootKind: SkillRootKind, + providerId?: TeamProviderId +): boolean { + return getSkillAudience(rootKind) === 'shared' || providerId === 'codex'; +} diff --git a/test/main/services/extensions/SkillRootsResolver.test.ts b/test/main/services/extensions/SkillRootsResolver.test.ts index 2c87294a..db7f60ca 100644 --- a/test/main/services/extensions/SkillRootsResolver.test.ts +++ b/test/main/services/extensions/SkillRootsResolver.test.ts @@ -8,9 +8,9 @@ describe('SkillRootsResolver', () => { const roots = resolver.resolve(); - expect(roots).toHaveLength(3); + expect(roots).toHaveLength(4); expect(roots.every((root) => root.scope === 'user')).toBe(true); - expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents']); + expect(roots.map((root) => root.rootKind)).toEqual(['claude', 'cursor', 'agents', 'codex']); }); it('returns project and user roots when project path is provided', () => { @@ -18,8 +18,8 @@ describe('SkillRootsResolver', () => { const roots = resolver.resolve('/tmp/demo-project'); - expect(roots).toHaveLength(6); - expect(roots.filter((root) => root.scope === 'project')).toHaveLength(3); - expect(roots.filter((root) => root.scope === 'user')).toHaveLength(3); + expect(roots).toHaveLength(8); + expect(roots.filter((root) => root.scope === 'project')).toHaveLength(4); + expect(roots.filter((root) => root.scope === 'user')).toHaveLength(4); }); }); diff --git a/test/main/services/extensions/SkillValidator.test.ts b/test/main/services/extensions/SkillValidator.test.ts index 513f6959..0fc22594 100644 --- a/test/main/services/extensions/SkillValidator.test.ts +++ b/test/main/services/extensions/SkillValidator.test.ts @@ -40,6 +40,18 @@ describe('SkillValidator', () => { expect(result[1].issues.map((issue) => issue.code)).toContain('duplicate-name'); }); + it('does not warn when shared and codex-only overlays reuse the same skill name', () => { + const validator = new SkillValidator(); + + const result = validator.annotateCatalog([ + makeSkill({ id: '/a', scope: 'project', rootKind: 'claude' }), + makeSkill({ id: '/b', scope: 'project', rootKind: 'codex' }), + ]); + + expect(result[0].issues.map((issue) => issue.code)).not.toContain('duplicate-name'); + expect(result[1].issues.map((issue) => issue.code)).not.toContain('duplicate-name'); + }); + it('sorts by validity, scope, root precedence, then name', () => { const validator = new SkillValidator(); @@ -47,6 +59,7 @@ describe('SkillValidator', () => { makeSkill({ id: '/3', name: 'z-user', scope: 'user', rootKind: 'claude' }), makeSkill({ id: '/2', name: 'b-project-cursor', scope: 'project', rootKind: 'cursor' }), makeSkill({ id: '/1', name: 'a-project-claude', scope: 'project', rootKind: 'claude' }), + makeSkill({ id: '/5', name: 'c-project-codex', scope: 'project', rootKind: 'codex' }), makeSkill({ id: '/4', name: 'invalid', @@ -55,6 +68,6 @@ describe('SkillValidator', () => { }), ]); - expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/3', '/4']); + expect(result.map((item) => item.id)).toEqual(['/1', '/2', '/5', '/3', '/4']); }); }); diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts index 528a2f2d..43384c6e 100644 --- a/test/renderer/utils/skillCommandSuggestions.test.ts +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -41,7 +41,7 @@ describe('buildSlashCommandSuggestions', () => { { name: 'review-skill', command: '/review-skill', - subtitle: 'Project skill', + subtitle: 'Project skill - Shared', type: 'skill', } ); @@ -76,6 +76,46 @@ describe('buildSlashCommandSuggestions', () => { ); }); + it('hides codex-only skills when the provider is not codex', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [createSkill({ id: 'codex-project', folderName: 'codex-skill', rootKind: 'codex' })], + [], + 'anthropic' + ); + + expect(suggestions.find((suggestion) => suggestion.id === 'skill:codex-project')).toBeUndefined(); + }); + + it('prefers codex-only overlays ahead of shared skills for codex teams', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [ + createSkill({ + id: 'shared-project', + folderName: 'review-skill', + rootKind: 'claude', + scope: 'project', + }), + createSkill({ + id: 'codex-project', + folderName: 'review-skill', + rootKind: 'codex', + scope: 'project', + }), + ], + [], + 'codex' + ); + + expect(suggestions.filter((suggestion) => suggestion.command === '/review-skill')).toHaveLength( + 1 + ); + expect(suggestions.find((suggestion) => suggestion.command === '/review-skill')?.id).toBe( + 'skill:codex-project' + ); + }); + it('uses the provided built-in set when filtering skill collisions', () => { const suggestions = buildSlashCommandSuggestions( [ From 22209ba95870df74dc3d1824d4d79a08316d645f Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 13:23:30 +0300 Subject: [PATCH 06/42] feat(extensions): support multimodel global mcp scope --- .../extensions/install/McpInstallService.ts | 9 ++-- .../extensions/runtime/mcpRuntimeJson.ts | 8 ++- .../extensions/mcp/CustomMcpServerDialog.tsx | 40 ++++++++------ .../extensions/mcp/McpServerCard.tsx | 19 ++++--- .../extensions/mcp/McpServerDetailDialog.tsx | 52 ++++++++++++------- src/renderer/store/slices/extensionsSlice.ts | 4 +- src/shared/types/extensions/common.ts | 2 +- src/shared/types/extensions/mcp.ts | 4 +- src/shared/utils/extensionNormalizers.ts | 2 + src/shared/utils/mcpScopes.ts | 32 ++++++++++++ .../mcp/CustomMcpServerDialog.test.ts | 28 ++++++++++ .../extensions/mcp/McpServerCard.test.ts | 35 +++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 34 ++++++++++++ .../shared/utils/extensionNormalizers.test.ts | 4 ++ 14 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 src/shared/utils/mcpScopes.ts diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 70daa65e..31e89219 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -11,6 +11,7 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver'; import { execCli } from '@main/utils/childProcess'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { createLogger } from '@shared/utils/logger'; +import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import path from 'path'; import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter'; @@ -29,7 +30,7 @@ const logger = createLogger('Extensions:McpInstall'); const SERVER_NAME_RE = /^[\w.-]{1,100}$/; /** Allowed scope values (prevent command injection) */ -const VALID_SCOPES = new Set(['local', 'user', 'project']); +const VALID_SCOPES = new Set(['local', 'user', 'project', 'global']); /** Env var key must be safe shell identifier */ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; @@ -40,7 +41,7 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/; const TIMEOUT_MS = 30_000; function scopeRequiresProjectPath(scope?: string): boolean { - return scope === 'local' || scope === 'project'; + return isProjectScopedMcpScope(scope); } export class McpInstallService { @@ -64,7 +65,7 @@ export class McpInstallService { if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', - error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, }; } @@ -337,7 +338,7 @@ export class McpInstallService { if (scope && !VALID_SCOPES.has(scope)) { return { state: 'error', - error: `Invalid scope: "${scope}". Must be one of: local, user, project.`, + error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`, }; } diff --git a/src/main/services/extensions/runtime/mcpRuntimeJson.ts b/src/main/services/extensions/runtime/mcpRuntimeJson.ts index f6a52f45..858f532c 100644 --- a/src/main/services/extensions/runtime/mcpRuntimeJson.ts +++ b/src/main/services/extensions/runtime/mcpRuntimeJson.ts @@ -1,3 +1,5 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + import type { InstalledMcpEntry } from '@shared/types/extensions'; interface McpListJsonServer { @@ -24,15 +26,11 @@ function extractJsonObject(raw: string): T { } } -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)) { + if (typeof entry.name !== 'string' || !isInstalledMcpScope(entry.scope)) { return []; } diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 7cb8e740..59c28f6f 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -24,6 +24,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, +} from '@shared/utils/mcpScopes'; import { Plus, Server, Trash2 } from 'lucide-react'; import type { @@ -42,13 +47,7 @@ interface CustomMcpServerDialogProps { type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user' | 'project'; - -const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ - { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, - { value: 'local', label: 'Local' }, -]; +type Scope = 'local' | 'user' | 'project' | 'global'; const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [ { value: 'streamable-http', label: 'Streamable HTTP' }, @@ -67,11 +66,18 @@ export const CustomMcpServerDialog = ({ projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); + const cliStatus = useStore((s) => s.cliStatus); + const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const scopeOptions: { value: Scope; label: string }[] = [ + { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, + { value: 'project', label: 'Project' }, + { value: 'local', label: 'Local' }, + ]; // Form state const [serverName, setServerName] = useState(''); const [transportMode, setTransportMode] = useState('stdio'); - const [scope, setScope] = useState('user'); + const [scope, setScope] = useState(defaultSharedScope); // Stdio fields const [npmPackage, setNpmPackage] = useState(''); @@ -92,7 +98,7 @@ export const CustomMcpServerDialog = ({ if (open) { setServerName(''); setTransportMode('stdio'); - setScope('user'); + setScope(defaultSharedScope); setNpmPackage(''); setNpmVersion(''); setHttpUrl(''); @@ -102,13 +108,13 @@ export const CustomMcpServerDialog = ({ setError(null); setInstalling(false); } - }, [open]); + }, [defaultSharedScope, open]); useEffect(() => { - if (open && scope !== 'user' && !projectPath) { - setScope('user'); + if (open && isProjectScopedMcpScope(scope) && !projectPath) { + setScope(defaultSharedScope); } - }, [open, projectPath, scope]); + }, [defaultSharedScope, open, projectPath, scope]); // Auto-fill env vars from saved API keys useEffect(() => { @@ -177,7 +183,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, - projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, + projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -207,7 +213,7 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && - !(scope !== 'user' && !projectPath) && + !(isProjectScopedMcpScope(scope) && !projectPath) && !installing; return ( @@ -382,11 +388,11 @@ export const CustomMcpServerDialog = ({ - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index 4f58b343..90a34c4c 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -11,6 +11,7 @@ import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { useStore } from '@renderer/store'; import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters'; +import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, getMcpOperationKey, @@ -47,7 +48,9 @@ export const McpServerCard = ({ diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { - const operationKey = getMcpOperationKey(server.id, 'user'); + const cliStatus = useStore((s) => s.cliStatus); + const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const operationKey = getMcpOperationKey(server.id, sharedScope); const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle'); const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); @@ -67,13 +70,13 @@ export const McpServerCard = ({ server.requiresAuth || (server.authHeaders?.length ?? 0) > 0; const defaultServerName = sanitizeMcpServerName(server.name); - const userInstallEntry = - normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null; + const sharedInstallEntry = + normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); const supportsDirectInstalledAction = isInstalled && normalizedInstalledEntries.length === 1 && - userInstallEntry?.name === defaultServerName && + sharedInstallEntry?.name === defaultServerName && !requiresConfiguration; const shouldShowDirectInstallButton = canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); @@ -263,13 +266,17 @@ export const McpServerCard = ({ installMcpServer({ registryId: server.id, serverName: defaultServerName, - scope: 'user', + scope: sharedScope, envValues: {}, headers: [], }) } onUninstall={() => - uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user') + uninstallMcpServer( + server.id, + sharedInstallEntry?.name ?? defaultServerName, + sharedScope + ) } size="sm" errorMessage={installError} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 412be5a8..c6515f8d 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -25,6 +25,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { + getDefaultMcpSharedScope, + getMcpScopeLabel, + isProjectScopedMcpScope, +} from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, getMcpOperationKey, @@ -55,13 +60,7 @@ interface McpServerDetailDialogProps { onClose: () => void; } -type Scope = 'local' | 'user' | 'project'; - -const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ - { value: 'user', label: 'User (global)' }, - { value: 'project', label: 'Project' }, - { value: 'local', label: 'Local' }, -]; +type Scope = 'local' | 'user' | 'project' | 'global'; export const McpServerDetailDialog = ({ server, @@ -74,7 +73,9 @@ export const McpServerDetailDialog = ({ open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { - const [scope, setScope] = useState('user'); + const cliStatus = useStore((s) => s.cliStatus); + const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const [scope, setScope] = useState(defaultSharedScope); const operationKey = server ? getMcpOperationKey(server.id, scope) : null; const installProgress = useStore( (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' @@ -96,6 +97,15 @@ export const McpServerDetailDialog = ({ : installedEntry ? [installedEntry] : []; + const scopeOptions: { value: Scope; label: string }[] = [ + { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, + ...(defaultSharedScope !== 'user' && + normalizedInstalledEntries.some((entry) => entry.scope === 'user') + ? [{ value: 'user' as const, label: getMcpScopeLabel('user', cliStatus?.flavor) }] + : []), + { value: 'project', label: 'Project' }, + { value: 'local', label: 'Local' }, + ]; const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries); const selectedInstalledEntry = normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; @@ -120,10 +130,16 @@ export const McpServerDetailDialog = ({ })) ); setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(preferredInstalledEntry?.scope ?? 'user'); + setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope); setImgError(false); setAutoFilledFields(new Set()); - }, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]); + }, [ + defaultSharedScope, + open, + preferredInstalledEntry?.name, + preferredInstalledEntry?.scope, + server?.id, + ]); useEffect(() => { if (!server || !open) { @@ -134,10 +150,10 @@ export const McpServerDetailDialog = ({ }, [open, scope, selectedInstalledEntry?.name, server]); useEffect(() => { - if (open && scope !== 'user' && !projectPath) { - setScope('user'); + if (open && isProjectScopedMcpScope(scope) && !projectPath) { + setScope(defaultSharedScope); } - }, [open, projectPath, scope]); + }, [defaultSharedScope, open, projectPath, scope]); // Auto-fill env values from saved API keys useEffect(() => { @@ -181,7 +197,7 @@ export const McpServerDetailDialog = ({ const isInstalledForScope = selectedInstalledEntry !== null; const uninstallServerName = selectedInstalledEntry?.name ?? serverName; const uninstallScope = selectedInstalledEntry?.scope ?? scope; - const scopeRequiresProjectPath = scope !== 'user' && !projectPath; + const scopeRequiresProjectPath = isProjectScopedMcpScope(scope) && !projectPath; const installDisabled = !serverName.trim() || missingRequiredEnvVars || @@ -201,7 +217,7 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, - projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, + projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined, envValues, headers, }); @@ -212,7 +228,7 @@ export const McpServerDetailDialog = ({ server.id, uninstallServerName, uninstallScope, - uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined + isProjectScopedMcpScope(uninstallScope) ? (projectPath ?? undefined) : undefined ); }; @@ -415,11 +431,11 @@ export const McpServerDetailDialog = ({ - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index e1ed7a2d..c73b6b70 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -5,6 +5,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; +import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -1034,7 +1035,8 @@ export const createExtensionsSlice: StateCreator { - const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user'; + const operationScope: InstallScope = + scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user'; const operationKey = getMcpOperationKey(registryId, operationScope); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); diff --git a/src/shared/types/extensions/common.ts b/src/shared/types/extensions/common.ts index b5fd304f..23f31ee8 100644 --- a/src/shared/types/extensions/common.ts +++ b/src/shared/types/extensions/common.ts @@ -6,7 +6,7 @@ export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error'; /** Installation scope — where the extension is installed */ -export type InstallScope = 'local' | 'user' | 'project'; +export type InstallScope = 'local' | 'user' | 'project' | 'global'; /** Result of a mutation operation */ export interface OperationResult { diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index cc971734..ec92a6b9 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -83,7 +83,7 @@ export interface McpHeaderDef { export interface InstalledMcpEntry { name: string; - scope: 'local' | 'user' | 'project'; + scope: 'local' | 'user' | 'project' | 'global'; transport?: string; } @@ -100,7 +100,7 @@ export interface McpServerDiagnostic { // ── Install request (renderer → main, minimal trusted data) ──────────────── -export type McpInstallScope = 'local' | 'user' | 'project'; +export type McpInstallScope = 'local' | 'user' | 'project' | 'global'; export interface McpInstallRequest { registryId: string; // server ID from registry (NOT full catalog item) diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 4bb90652..b0cdb96b 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -160,6 +160,7 @@ export function getInstallationSummaryLabel( const MCP_SCOPE_PRIORITY: Record = { local: 0, project: 1, + global: 2, user: 2, }; @@ -195,6 +196,7 @@ export function getMcpInstallationSummaryLabel( } switch (scopes[0]) { + case 'global': case 'user': return 'Installed globally'; case 'project': diff --git a/src/shared/utils/mcpScopes.ts b/src/shared/utils/mcpScopes.ts new file mode 100644 index 00000000..44903816 --- /dev/null +++ b/src/shared/utils/mcpScopes.ts @@ -0,0 +1,32 @@ +import type { CliFlavor } from '@shared/types'; +import type { InstalledMcpEntry } from '@shared/types/extensions'; + +export type McpInstalledScope = InstalledMcpEntry['scope']; +export type McpSharedScope = Extract; + +export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedScope { + return flavor === 'agent_teams_orchestrator' ? 'global' : 'user'; +} + +export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' { + return scope === 'project' || scope === 'local'; +} + +export function isInstalledMcpScope(scope: unknown): scope is McpInstalledScope { + return scope === 'user' || scope === 'global' || scope === 'project' || scope === 'local'; +} + +export function getMcpScopeLabel(scope: McpInstalledScope, flavor?: CliFlavor | null): string { + switch (scope) { + case 'global': + return 'Global'; + case 'user': + return flavor === 'agent_teams_orchestrator' ? 'User (legacy)' : 'User (global)'; + case 'project': + return 'Project'; + case 'local': + return 'Local'; + default: + return scope; + } +} diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 31ef3d9f..9947c28d 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { installCustomMcpServer: ReturnType; + cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; } const storeState = {} as StoreState; @@ -116,6 +117,7 @@ describe('CustomMcpServerDialog project scope', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); + storeState.cliStatus = null; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); @@ -152,6 +154,32 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('defaults to global scope in multimodel mode', async () => { + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('global'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes projectPath for project-scoped custom installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 3a5509ec..85ccf6d5 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -11,6 +11,7 @@ interface StoreState { uninstallMcpServer: ReturnType; installErrors: Record; mcpGitHubStars: Record; + cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; } const storeState = {} as StoreState; @@ -127,6 +128,7 @@ describe('McpServerCard direct action safety', () => { storeState.uninstallMcpServer = vi.fn(); storeState.installErrors = {}; storeState.mcpGitHubStars = {}; + storeState.cliStatus = null; }); afterEach(() => { @@ -285,4 +287,37 @@ describe('McpServerCard direct action safety', () => { await Promise.resolve(); }); }); + + it('keeps direct actions for standard global installs in multimodel mode', async () => { + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'global', + }; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry, + installedEntries: [installedEntry], + diagnostic: null, + diagnosticsLoading: false, + onClick: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index c6f4c204..a5e9ea00 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -11,6 +11,7 @@ interface StoreState { uninstallMcpServer: ReturnType; installErrors: Record; mcpGitHubStars: Record; + cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; } const storeState = {} as StoreState; @@ -172,6 +173,7 @@ describe('McpServerDetailDialog installed entry handling', () => { storeState.uninstallMcpServer = vi.fn(); storeState.installErrors = {}; storeState.mcpGitHubStars = {}; + storeState.cliStatus = null; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); @@ -276,6 +278,38 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('defaults to global scope in multimodel mode', async () => { + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: null, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('global'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes project path for project-scoped installs and uninstalls', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 39ba92fc..de60b2ed 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -241,6 +241,10 @@ describe('getMcpInstallationSummaryLabel', () => { expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally'); }); + it('describes a single global MCP installation', () => { + expect(getMcpInstallationSummaryLabel([{ scope: 'global' }])).toBe('Installed globally'); + }); + it('summarizes multiple MCP scopes', () => { expect( getMcpInstallationSummaryLabel([ From fd4fd135a1efd7ae2ec1a063229be60c21e5d8ec Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 13:53:39 +0300 Subject: [PATCH 07/42] fix(extensions): hide gemini runtime card --- .../components/extensions/ExtensionStoreView.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 1f9ea351..516727d3 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; +import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { Tabs, TabsContent, TabsList } from '@renderer/components/ui/tabs'; @@ -166,7 +167,9 @@ export const ExtensionStoreView = (): React.JSX.Element => { cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; - const isMultimodel = cliStatus?.flavor === 'agent_teams_orchestrator' && providers.length > 0; + const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); + const isMultimodel = + cliStatus?.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0; if (cliStatusLoading || cliStatus === null) { return ( @@ -247,7 +250,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
- {providers.map((provider) => { + {visibleProviders.map((provider) => { const statusTone = provider.authenticated ? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300' : provider.supported @@ -267,7 +270,13 @@ export const ExtensionStoreView = (): React.JSX.Element => { >
-

{provider.displayName}

+

+ + {provider.displayName} +

{provider.statusMessage ?? provider.backend?.label ?? 'Ready to configure'}

From 81c59440bfac4c1ff3f249116336ecc61e44d482 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:21:17 +0300 Subject: [PATCH 08/42] fix(extensions): harden mcp diagnostics output --- .../runtime/ExtensionsRuntimeAdapter.ts | 2 +- .../runtime/mcpDiagnosticsParser.ts | 51 +++++++++++++++++-- .../extensions/mcp/McpServersPanel.tsx | 23 +++++++-- src/renderer/store/slices/extensionsSlice.ts | 10 +++- src/shared/types/extensions/mcp.ts | 2 + src/shared/utils/extensionNormalizers.ts | 8 +++ .../McpHealthDiagnosticsService.test.ts | 9 +++- test/renderer/store/extensionsSlice.test.ts | 35 +++++++++++++ 8 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts index 66c21893..0b53f392 100644 --- a/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts +++ b/src/main/services/extensions/runtime/ExtensionsRuntimeAdapter.ts @@ -12,7 +12,7 @@ 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; +const MCP_DIAGNOSE_TIMEOUT_MS = 60_000; export interface ExtensionsRuntimeAdapter { readonly flavor: CliFlavor; diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts index 5ab55bed..911b054f 100644 --- a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -3,6 +3,8 @@ import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/e interface McpDiagnoseJsonEntry { name?: string; target?: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; + transport?: string; status?: 'connected' | 'needs-authentication' | 'failed' | 'timeout'; statusLabel?: string; } @@ -12,6 +14,10 @@ interface McpDiagnoseJsonPayload { diagnostics?: McpDiagnoseJsonEntry[]; } +const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi; +const SENSITIVE_FLAG_VALUE_PATTERN = + /(--(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|client[-_]?secret))(?:=([^\s]+)|\s+([^\s]+))/gi; + function extractJsonObject(raw: string): T { const trimmed = raw.trim(); try { @@ -45,6 +51,42 @@ function parseStatusChunk(statusChunk: string): { } } +function redactHttpUrl(urlString: string): string { + try { + const parsed = new URL(urlString); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return urlString; + } + + if (!parsed.username && !parsed.password && !parsed.search && !parsed.hash) { + return urlString; + } + + if (parsed.username) parsed.username = '***'; + if (parsed.password) parsed.password = '***'; + + for (const key of new Set(parsed.searchParams.keys())) { + parsed.searchParams.set(key, 'REDACTED'); + } + + if (parsed.hash) { + parsed.hash = 'REDACTED'; + } + + return parsed.toString(); + } catch { + return urlString; + } +} + +function redactDiagnosticTarget(target: string): string { + return target + .replace(EMBEDDED_HTTP_URL_PATTERN, (match) => redactHttpUrl(match)) + .replace(SENSITIVE_FLAG_VALUE_PATTERN, (_match, flag: string, inlineValue?: string) => + inlineValue ? `${flag}=REDACTED` : `${flag} REDACTED` + ); +} + function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnostic | null { const statusSeparatorIdx = line.lastIndexOf(' - '); if (statusSeparatorIdx === -1) { @@ -60,7 +102,7 @@ function parseDiagnosticLine(line: string, checkedAt: number): McpServerDiagnost } const name = descriptor.slice(0, nameSeparatorIdx).trim(); - const target = descriptor.slice(nameSeparatorIdx + 2).trim(); + const target = redactDiagnosticTarget(descriptor.slice(nameSeparatorIdx + 2).trim()); if (!name || !target) { return null; } @@ -102,6 +144,7 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost return []; } + const redactedTarget = redactDiagnosticTarget(entry.target); const normalizedStatus: McpServerHealthStatus = entry.status === 'connected' ? 'connected' @@ -111,11 +154,13 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost ? 'failed' : 'unknown'; - const rawLine = `${entry.name}: ${entry.target} - ${entry.statusLabel}`; + const rawLine = `${entry.name}: ${redactedTarget} - ${entry.statusLabel}`; return [ { name: entry.name, - target: entry.target, + target: redactedTarget, + scope: entry.scope, + transport: entry.transport, status: normalizedStatus, statusLabel: entry.statusLabel, rawLine, diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index e321a1bd..c799433a 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -17,6 +17,7 @@ import { useStore } from '@renderer/store'; import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { + getMcpDiagnosticKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -164,7 +165,12 @@ export const McpServersPanel = ({ const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => { const installedEntry = getInstalledEntry(server); - return installedEntry ? (mcpDiagnostics[installedEntry.name] ?? null) : null; + return installedEntry + ? (mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name, installedEntry.scope)] ?? + mcpDiagnostics[getMcpDiagnosticKey(installedEntry.name)] ?? + mcpDiagnostics[installedEntry.name] ?? + null) + : null; }; const allDiagnostics = useMemo( @@ -250,11 +256,18 @@ export const McpServersPanel = ({
{allDiagnostics.map((diagnostic) => (
-

{diagnostic.name}

+
+

{diagnostic.name}

+ {diagnostic.scope && ( + + {diagnostic.scope} + + )} +

) : ( -

Waiting for `claude mcp list` results...

+

+ Waiting for {diagnosticsCommand} results... +

)}
)} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index c73b6b70..15fb4556 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -6,7 +6,11 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; -import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers'; +import { + getMcpDiagnosticKey, + getMcpOperationKey, + getPluginOperationKey, +} from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -555,7 +559,9 @@ export const createExtensionsSlice: StateCreator [entry.name, entry] as const) + diagnostics.map( + (entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const + ) ), mcpDiagnosticsLoading: false, mcpDiagnosticsLastCheckedAt: Date.now(), diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index ec92a6b9..dcd720db 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -92,6 +92,8 @@ export type McpServerHealthStatus = 'connected' | 'needs-authentication' | 'fail export interface McpServerDiagnostic { name: string; target: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; + transport?: string; status: McpServerHealthStatus; statusLabel: string; rawLine: string; diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index b0cdb96b..5601f944 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -120,6 +120,14 @@ export function getMcpOperationKey(registryId: string, scope: InstallScope): str return `mcp:${registryId}:${scope}`; } +/** + * Namespaced lookup key for MCP diagnostics. Scope is included when available + * so the same server name can coexist across global/project/local installs. + */ +export function getMcpDiagnosticKey(name: string, scope?: string | null): string { + return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts index afb7b4eb..c3db915f 100644 --- a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts +++ b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts @@ -25,7 +25,7 @@ alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`); }); expect(diagnostics[3]).toMatchObject({ name: 'tavily-remote-mcp', - target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test', + target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=REDACTED', status: 'failed', statusLabel: 'Failed to connect', }); @@ -58,7 +58,9 @@ another log line`); }, { name: 'tavily', - target: 'https://mcp.tavily.com/mcp', + target: 'https://mcp.tavily.com/mcp?token=secret', + scope: 'global', + transport: 'http', status: 'timeout', statusLabel: 'Timed out', }, @@ -74,6 +76,9 @@ another log line`); }), expect.objectContaining({ name: 'tavily', + target: 'https://mcp.tavily.com/mcp?token=REDACTED', + scope: 'global', + transport: 'http', status: 'failed', statusLabel: 'Timed out', }), diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index ceca353d..c9a5337c 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -55,6 +55,7 @@ vi.mock('../../../src/renderer/api', () => ({ import { api } from '../../../src/renderer/api'; import { + getMcpDiagnosticKey, getMcpOperationKey, getPluginOperationKey, } from '../../../src/shared/utils/extensionNormalizers'; @@ -833,6 +834,40 @@ describe('extensionsSlice', () => { expect(api.cliInstaller!.getStatus).toHaveBeenCalled(); expect(store.getState().apiKeys).toEqual([]); }); + + it('keys MCP diagnostics by scope when the same server exists in multiple scopes', async () => { + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([ + { + name: 'context7', + scope: 'global', + target: 'npx -y @upstash/context7-mcp', + status: 'connected', + statusLabel: 'Connected', + rawLine: 'context7: npx -y @upstash/context7-mcp - Connected', + checkedAt: 1, + }, + { + name: 'context7', + scope: 'project', + target: 'uvx context7-project', + status: 'failed', + statusLabel: 'Failed to connect', + rawLine: 'context7: uvx context7-project - Failed to connect', + checkedAt: 1, + }, + ]); + + await store.getState().runMcpDiagnostics('/tmp/project-a'); + + expect(store.getState().mcpDiagnostics).toMatchObject({ + [getMcpDiagnosticKey('context7', 'global')]: expect.objectContaining({ + target: 'npx -y @upstash/context7-mcp', + }), + [getMcpDiagnosticKey('context7', 'project')]: expect.objectContaining({ + target: 'uvx context7-project', + }), + }); + }); }); describe('skills state hardening', () => { From 489e3eb96787b02a0d4066d7c3eb67e0455dd9dd Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:28:25 +0300 Subject: [PATCH 09/42] fix(extensions): scope mcp renderer state by project --- .../extensions/mcp/McpServerDetailDialog.tsx | 2 +- .../extensions/mcp/McpServersPanel.tsx | 44 ++++-- src/renderer/store/slices/extensionsSlice.ts | 124 +++++++++++++---- src/shared/utils/extensionNormalizers.ts | 16 ++- .../extensions/mcp/McpServerCard.test.ts | 5 +- .../mcp/McpServerDetailDialog.test.ts | 5 +- .../extensions/mcp/McpServersPanel.test.ts | 13 ++ test/renderer/store/extensionsSlice.test.ts | 125 +++++++++++++++--- .../shared/utils/extensionNormalizers.test.ts | 4 +- 9 files changed, 277 insertions(+), 61 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index c6515f8d..87ef0a08 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -76,7 +76,7 @@ export const McpServerDetailDialog = ({ const cliStatus = useStore((s) => s.cliStatus); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const [scope, setScope] = useState(defaultSharedScope); - const operationKey = server ? getMcpOperationKey(server.id, scope) : null; + const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null; const installProgress = useStore( (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' ); diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index c799433a..816ba2b0 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -18,6 +18,7 @@ import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -79,18 +80,24 @@ export const McpServersPanel = ({ selectedMcpServerId, setSelectedMcpServerId, }: McpServersPanelProps): React.JSX.Element => { + const projectStateKey = getMcpProjectStateKey(projectPath); const { browseCatalog, browseNextCursor, browseLoading, browseError, mcpBrowse, - installedServers, + installedServersByProjectPath, + installedServersFallback, fetchMcpGitHubStars, - mcpDiagnostics, - mcpDiagnosticsLoading, - mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback, + mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback, + mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback, + mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback, runMcpDiagnostics, cliStatus, } = useStore( @@ -100,16 +107,33 @@ export const McpServersPanel = ({ browseLoading: s.mcpBrowseLoading, browseError: s.mcpBrowseError, mcpBrowse: s.mcpBrowse, - installedServers: s.mcpInstalledServers, + installedServersByProjectPath: s.mcpInstalledServersByProjectPath, + installedServersFallback: s.mcpInstalledServers, fetchMcpGitHubStars: s.fetchMcpGitHubStars, - mcpDiagnostics: s.mcpDiagnostics, - mcpDiagnosticsLoading: s.mcpDiagnosticsLoading, - mcpDiagnosticsError: s.mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath: s.mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback: s.mcpDiagnostics, + mcpDiagnosticsLoadingByProjectPath: s.mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback: s.mcpDiagnosticsLoading, + mcpDiagnosticsErrorByProjectPath: s.mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback: s.mcpDiagnosticsError, + mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt, runMcpDiagnostics: s.runMcpDiagnostics, cliStatus: s.cliStatus, })) ); + const installedServers = + installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? []; + const mcpDiagnostics = + mcpDiagnosticsByProjectPath?.[projectStateKey] ?? mcpDiagnosticsFallback ?? {}; + const mcpDiagnosticsLoading = + mcpDiagnosticsLoadingByProjectPath?.[projectStateKey] ?? mcpDiagnosticsLoadingFallback ?? false; + const mcpDiagnosticsError = + mcpDiagnosticsErrorByProjectPath?.[projectStateKey] ?? mcpDiagnosticsErrorFallback ?? null; + const mcpDiagnosticsLastCheckedAt = + mcpDiagnosticsLastCheckedAtByProjectPath?.[projectStateKey] ?? + mcpDiagnosticsLastCheckedAtFallback ?? + null; const [mcpSort, setMcpSort] = useState('name-asc'); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 15fb4556..20dbae3b 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -8,6 +8,7 @@ import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '@shared/utils/extensionNormalizers'; @@ -56,11 +57,16 @@ export interface ExtensionsSlice { mcpBrowseLoading: boolean; mcpBrowseError: string | null; mcpInstalledServers: InstalledMcpEntry[]; + mcpInstalledServersByProjectPath: Record; mcpInstalledProjectPath: string | null; mcpDiagnostics: Record; + mcpDiagnosticsByProjectPath: Record>; mcpDiagnosticsLoading: boolean; + mcpDiagnosticsLoadingByProjectPath: Record; mcpDiagnosticsError: string | null; + mcpDiagnosticsErrorByProjectPath: Record; mcpDiagnosticsLastCheckedAt: number | null; + mcpDiagnosticsLastCheckedAtByProjectPath: Record; // ── Install progress ── pluginInstallProgress: Record; @@ -137,7 +143,7 @@ let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; const pluginSuccessResetTimers = new Map>(); const mcpSuccessResetTimers = new Map>(); -let mcpDiagnosticsInFlight: Promise | null = null; +const mcpDiagnosticsInFlightByKey = new Map>(); let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; const latestSkillsCatalogRequestByKey = new Map(); @@ -227,10 +233,26 @@ function schedulePluginSuccessReset( pluginSuccessResetTimers.set(operationKey, timer); } -function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string { +function getCustomMcpOperationKey( + serverName: string, + scope: InstallScope, + projectPath?: string | null +): string { + if (scope === 'project' || scope === 'local') { + return `mcp-custom:${serverName}:${scope}:${getMcpProjectStateKey(projectPath)}`; + } return `mcp-custom:${serverName}:${scope}`; } +function isProjectScopedMcpOperationKey(operationKey: string): boolean { + return ( + operationKey.includes(':project:') || + operationKey.endsWith(':project') || + operationKey.includes(':local:') || + operationKey.endsWith(':local') + ); +} + function clearMcpSuccessResetTimer(operationKey: string): void { const timer = mcpSuccessResetTimers.get(operationKey); if (!timer) { @@ -274,7 +296,7 @@ function clearMcpProjectScopedOperationState( for (const operationKey of Object.keys(nextMcpInstallProgress)) { if ( (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && - (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + isProjectScopedMcpOperationKey(operationKey) ) { delete nextMcpInstallProgress[operationKey]; } @@ -283,7 +305,7 @@ function clearMcpProjectScopedOperationState( for (const operationKey of Object.keys(nextInstallErrors)) { if ( (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && - (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + isProjectScopedMcpOperationKey(operationKey) ) { delete nextInstallErrors[operationKey]; } @@ -297,7 +319,7 @@ function clearMcpProjectScopedOperationState( function clearMcpProjectScopedSuccessResetTimers(): void { for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) { - if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) { + if (isProjectScopedMcpOperationKey(operationKey)) { clearMcpSuccessResetTimer(operationKey); } } @@ -336,11 +358,16 @@ export const createExtensionsSlice: StateCreator { const nextProjectPath = projectPath ?? null; + const stateKey = getMcpProjectStateKey(nextProjectPath); const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath; const nextOperationState = isSameProjectContext ? { @@ -533,6 +561,10 @@ export const createExtensionsSlice: StateCreator { const mcpRegistry = api.mcpRegistry; if (!mcpRegistry) return; + const projectStateKey = getMcpProjectStateKey(projectPath); - if (mcpDiagnosticsInFlight) { - await mcpDiagnosticsInFlight; + const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey); + if (existing) { + await existing; return; } - set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null }); + set((prev) => ({ + mcpDiagnosticsLoading: true, + mcpDiagnosticsError: null, + mcpDiagnosticsLoadingByProjectPath: { + ...prev.mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: true, + }, + mcpDiagnosticsErrorByProjectPath: { + ...prev.mcpDiagnosticsErrorByProjectPath, + [projectStateKey]: null, + }, + })); const promise = (async () => { try { const diagnostics = await mcpRegistry.diagnose(projectPath); + const diagnosticsRecord = Object.fromEntries( + diagnostics.map((entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const) + ); + const checkedAt = Date.now(); set({ - mcpDiagnostics: Object.fromEntries( - diagnostics.map( - (entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const - ) - ), + mcpDiagnostics: diagnosticsRecord, mcpDiagnosticsLoading: false, - mcpDiagnosticsLastCheckedAt: Date.now(), + mcpDiagnosticsByProjectPath: { + ...get().mcpDiagnosticsByProjectPath, + [projectStateKey]: diagnosticsRecord, + }, + mcpDiagnosticsLoadingByProjectPath: { + ...get().mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: false, + }, + mcpDiagnosticsLastCheckedAt: checkedAt, + mcpDiagnosticsLastCheckedAtByProjectPath: { + ...get().mcpDiagnosticsLastCheckedAtByProjectPath, + [projectStateKey]: checkedAt, + }, }); } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to check MCP server health'; set({ mcpDiagnosticsLoading: false, - mcpDiagnosticsError: - err instanceof Error ? err.message : 'Failed to check MCP server health', + mcpDiagnosticsError: errorMessage, + mcpDiagnosticsLoadingByProjectPath: { + ...get().mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: false, + }, + mcpDiagnosticsErrorByProjectPath: { + ...get().mcpDiagnosticsErrorByProjectPath, + [projectStateKey]: errorMessage, + }, }); } finally { - mcpDiagnosticsInFlight = null; + mcpDiagnosticsInFlightByKey.delete(projectStateKey); } })(); - mcpDiagnosticsInFlight = promise; + mcpDiagnosticsInFlightByKey.set(projectStateKey, promise); await promise; }, @@ -935,7 +1001,7 @@ export const createExtensionsSlice: StateCreator { - const operationKey = getMcpOperationKey(request.registryId, request.scope); + const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); set((prev) => ({ @@ -967,8 +1033,8 @@ export const createExtensionsSlice: StateCreator ({ @@ -989,7 +1055,11 @@ export const createExtensionsSlice: StateCreator { const operationScope = request.scope; - const progressKey = getCustomMcpOperationKey(request.serverName, operationScope); + const progressKey = getCustomMcpOperationKey( + request.serverName, + operationScope, + request.projectPath + ); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(progressKey); set((prev) => ({ @@ -1015,8 +1085,8 @@ export const createExtensionsSlice: StateCreator ({ @@ -1043,7 +1113,7 @@ export const createExtensionsSlice: StateCreator { const operationScope: InstallScope = scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user'; - const operationKey = getMcpOperationKey(registryId, operationScope); + const operationKey = getMcpOperationKey(registryId, operationScope, projectPath); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); set((prev) => ({ @@ -1072,8 +1142,8 @@ export const createExtensionsSlice: StateCreator ({ diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 5601f944..356946bf 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -116,7 +116,14 @@ export function getPluginOperationKey(pluginId: string, scope: InstallScope): st /** * Namespaced operation-state key for MCP install/uninstall UI state. */ -export function getMcpOperationKey(registryId: string, scope: InstallScope): string { +export function getMcpOperationKey( + registryId: string, + scope: InstallScope, + projectPath?: string | null +): string { + if (scope === 'project' || scope === 'local') { + return `mcp:${registryId}:${scope}:${getMcpProjectStateKey(projectPath)}`; + } return `mcp:${registryId}:${scope}`; } @@ -128,6 +135,13 @@ export function getMcpDiagnosticKey(name: string, scope?: string | null): string return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`; } +/** + * Stable project-aware cache key for MCP installed/diagnostics state. + */ +export function getMcpProjectStateKey(projectPath?: string | null): string { + return projectPath ?? '__global__'; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 85ccf6d5..db2adef6 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -255,11 +255,12 @@ describe('McpServerCard direct action safety', () => { }; storeState.mcpInstallProgress = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'error', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending', }; storeState.installErrors = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: + 'Project failed', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed', }; diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index a5e9ea00..7e1b32e9 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -581,10 +581,11 @@ describe('McpServerDetailDialog installed entry handling', () => { const root = createRoot(host); storeState.mcpInstallProgress = { [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success', - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'error', }; storeState.installErrors = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: + 'Project failed', }; await act(async () => { diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 0d9451df..85a2281d 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -18,11 +18,19 @@ interface StoreState { mcpBrowseError: string | null; mcpBrowse: ReturnType; mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; + mcpInstalledServersByProjectPath?: Record< + string, + Array<{ name: string; scope: 'local' | 'user' | 'project' }> + >; fetchMcpGitHubStars: ReturnType; mcpDiagnostics: Record; + mcpDiagnosticsByProjectPath?: Record>; mcpDiagnosticsLoading: boolean; + mcpDiagnosticsLoadingByProjectPath?: Record; mcpDiagnosticsError: string | null; + mcpDiagnosticsErrorByProjectPath?: Record; mcpDiagnosticsLastCheckedAt: number | null; + mcpDiagnosticsLastCheckedAtByProjectPath?: Record; runMcpDiagnostics: ReturnType; } @@ -121,11 +129,16 @@ describe('McpServersPanel initial browse loading', () => { storeState.mcpBrowseError = null; storeState.mcpBrowse = vi.fn(); storeState.mcpInstalledServers = []; + storeState.mcpInstalledServersByProjectPath = undefined; storeState.fetchMcpGitHubStars = vi.fn(); storeState.mcpDiagnostics = {}; + storeState.mcpDiagnosticsByProjectPath = undefined; storeState.mcpDiagnosticsLoading = false; + storeState.mcpDiagnosticsLoadingByProjectPath = undefined; storeState.mcpDiagnosticsError = null; + storeState.mcpDiagnosticsErrorByProjectPath = undefined; storeState.mcpDiagnosticsLastCheckedAt = null; + storeState.mcpDiagnosticsLastCheckedAtByProjectPath = undefined; storeState.runMcpDiagnostics = vi.fn(); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index c9a5337c..350e074c 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -56,6 +56,7 @@ vi.mock('../../../src/renderer/api', () => ({ import { api } from '../../../src/renderer/api'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '../../../src/shared/utils/extensionNormalizers'; @@ -154,8 +155,11 @@ const makeReadyCliStatus = () => ({ const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => getPluginOperationKey(pluginId, scope); -const mcpOperationKey = (registryId: string, scope: 'user' | 'project' | 'local' = 'user') => - getMcpOperationKey(registryId, scope); +const mcpOperationKey = ( + registryId: string, + scope: 'user' | 'project' | 'local' | 'global' = 'user', + projectPath?: string +) => getMcpOperationKey(registryId, scope, projectPath); describe('extensionsSlice', () => { let store: TestStore; @@ -393,20 +397,34 @@ describe('extensionsSlice', () => { expect(store.getState().mcpInstalledServers).toEqual(installed); }); + it('stores installed MCP servers independently per project context', async () => { + (api.mcpRegistry!.getInstalled as ReturnType) + .mockResolvedValueOnce([{ name: 'global-server', scope: 'global' as const }]) + .mockResolvedValueOnce([{ name: 'project-server', scope: 'project' as const }]); + + await store.getState().mcpFetchInstalled(); + await store.getState().mcpFetchInstalled('/tmp/project-a'); + + expect(store.getState().mcpInstalledServersByProjectPath).toMatchObject({ + [getMcpProjectStateKey()]: [{ name: 'global-server', scope: 'global' }], + [getMcpProjectStateKey('/tmp/project-a')]: [{ name: 'project-server', scope: 'project' }], + }); + }); + it('clears stale project- and local-scoped MCP operation state when project changes', async () => { store.setState({ mcpInstalledProjectPath: '/tmp/project-a', mcpInstallProgress: { - [mcpOperationKey('project-server', 'project')]: 'error', - [mcpOperationKey('local-server', 'local')]: 'success', + [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'error', + [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'success', [mcpOperationKey('user-server', 'user')]: 'pending', }, installErrors: { - [mcpOperationKey('project-server', 'project')]: 'Project failed', - [mcpOperationKey('local-server', 'local')]: 'Local failed', + [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'Project failed', + [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'Local failed', [mcpOperationKey('user-server', 'user')]: 'Keep user state', 'plugin:test@marketplace:user': 'Keep plugin state', - 'mcp-custom:custom-server:project': 'Clear custom project state', + 'mcp-custom:custom-server:project:/tmp/project-a': 'Clear custom project state', }, }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); @@ -414,13 +432,21 @@ describe('extensionsSlice', () => { await store.getState().mcpFetchInstalled('/tmp/project-b'); expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b'); - expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined(); - expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project', '/tmp/project-a')] + ).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local', '/tmp/project-a')] + ).toBeUndefined(); expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe( 'pending', ); - expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined(); - expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect( + store.getState().installErrors[mcpOperationKey('project-server', 'project', '/tmp/project-a')] + ).toBeUndefined(); + expect( + store.getState().installErrors[mcpOperationKey('local-server', 'local', '/tmp/project-a')] + ).toBeUndefined(); expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe( 'Keep user state', ); @@ -749,16 +775,20 @@ describe('extensionsSlice', () => { headers: [], }); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe( - 'success', - ); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBe('success'); await store.getState().mcpFetchInstalled('/tmp/project-b'); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBeUndefined(); }); }); @@ -868,6 +898,69 @@ describe('extensionsSlice', () => { }), }); }); + + it('stores MCP diagnostics independently per project context', async () => { + (api.mcpRegistry!.diagnose as ReturnType) + .mockResolvedValueOnce([ + { + name: 'global-server', + scope: 'global', + target: 'npx global-server', + status: 'connected', + statusLabel: 'Connected', + rawLine: 'global-server: npx global-server - Connected', + checkedAt: 1, + }, + ]) + .mockResolvedValueOnce([ + { + name: 'project-server', + scope: 'project', + target: 'uvx project-server', + status: 'failed', + statusLabel: 'Failed to connect', + rawLine: 'project-server: uvx project-server - Failed to connect', + checkedAt: 2, + }, + ]); + + await store.getState().runMcpDiagnostics(); + await store.getState().runMcpDiagnostics('/tmp/project-a'); + + expect(store.getState().mcpDiagnosticsByProjectPath).toMatchObject({ + [getMcpProjectStateKey()]: { + [getMcpDiagnosticKey('global-server', 'global')]: expect.objectContaining({ + target: 'npx global-server', + }), + }, + [getMcpProjectStateKey('/tmp/project-a')]: { + [getMcpDiagnosticKey('project-server', 'project')]: expect.objectContaining({ + target: 'uvx project-server', + }), + }, + }); + }); + + it('refreshes MCP install state using the operation project context instead of the last viewed tab', async () => { + store.setState({ + mcpInstalledProjectPath: '/tmp/project-b', + }); + (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); + (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); + + await store.getState().installMcpServer({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'project', + projectPath: '/tmp/project-a', + envValues: {}, + headers: [], + }); + + expect(api.mcpRegistry!.getInstalled).toHaveBeenLastCalledWith('/tmp/project-a'); + expect(api.mcpRegistry!.diagnose).toHaveBeenLastCalledWith('/tmp/project-a'); + }); }); describe('skills state hardening', () => { diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index de60b2ed..979c318d 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -166,8 +166,8 @@ describe('getPluginOperationKey', () => { describe('getMcpOperationKey', () => { it('namespaces MCP operation keys by scope', () => { - expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe( - 'mcp:io.github.upstash/context7:project', + expect(getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')).toBe( + 'mcp:io.github.upstash/context7:project:/tmp/project', ); }); }); From 33917a3161ea44d5f4994004af2c4fa68664c55d Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:34:46 +0300 Subject: [PATCH 10/42] fix(extensions): support project-scoped api keys --- src/main/ipc/extensions.ts | 8 +- .../extensions/apikeys/ApiKeyService.ts | 61 +++++++-- src/preload/index.ts | 4 +- .../extensions/ExtensionStoreView.tsx | 2 +- .../extensions/apikeys/ApiKeyCard.tsx | 6 + .../extensions/apikeys/ApiKeyFormDialog.tsx | 46 ++++++- .../extensions/apikeys/ApiKeysPanel.tsx | 18 ++- .../extensions/mcp/CustomMcpServerDialog.tsx | 11 +- .../extensions/mcp/McpServerDetailDialog.tsx | 9 +- .../runtime/ProviderRuntimeSettingsDialog.tsx | 2 +- src/shared/types/extensions/api.ts | 2 +- src/shared/types/extensions/apikey.ts | 2 + .../services/extensions/ApiKeyService.test.ts | 120 ++++++++++++++++++ .../mcp/CustomMcpServerDialog.test.ts | 41 ++++++ .../mcp/McpServerDetailDialog.test.ts | 34 ++++- 15 files changed, 334 insertions(+), 32 deletions(-) create mode 100644 test/main/services/extensions/ApiKeyService.test.ts diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 7577c112..3a3cccc0 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -421,11 +421,15 @@ async function handleApiKeysDelete( async function handleApiKeysLookup( _event: IpcMainInvokeEvent, - envVarNames?: string[] + envVarNames?: string[], + projectPath?: string ): Promise> { return wrapHandler('apiKeysLookup', () => { if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required'); - return getApiKeyService().lookup(envVarNames); + return getApiKeyService().lookup( + envVarNames, + typeof projectPath === 'string' ? projectPath : undefined + ); }); } diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index 35760292..19c4500a 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -39,6 +39,7 @@ interface StoredApiKey { encrypted?: boolean; encryptionMethod?: EncryptionMethod; scope: 'user' | 'project'; + projectPath?: string; createdAt: string; updatedAt?: string; } @@ -73,6 +74,7 @@ export class ApiKeyService { envVarName: k.envVarName, maskedValue: this.mask(this.decrypt(k)), scope: k.scope, + projectPath: k.projectPath, createdAt: k.createdAt, })); } @@ -86,6 +88,9 @@ export class ApiKeyService { ); } if (!request.value) throw new Error('Key value is required'); + if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) { + throw new Error('Project-scoped API keys require a project path'); + } const keys = await this.readStore(); const now = new Date().toISOString(); @@ -101,6 +106,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, updatedAt: now, }; delete keys[idx].encrypted; @@ -112,6 +118,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, createdAt: now, }); } @@ -124,6 +131,7 @@ export class ApiKeyService { envVarName: saved.envVarName, maskedValue: this.mask(request.value), scope: saved.scope, + projectPath: saved.projectPath, createdAt: saved.createdAt, }; } @@ -135,25 +143,36 @@ export class ApiKeyService { await this.writeStore(filtered); } - async lookup(envVarNames: string[]): Promise { + async lookup(envVarNames: string[], projectPath?: string): Promise { if (!envVarNames.length) return []; const keys = await this.readStore(); - const nameSet = new Set(envVarNames); - return keys - .filter((k) => nameSet.has(k.envVarName)) - .map((k) => ({ - envVarName: k.envVarName, - value: this.decrypt(k), - })); + return Array.from(new Set(envVarNames)).flatMap((envVarName) => { + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); + if (!preferred) { + return []; + } + + return [ + { + envVarName: preferred.envVarName, + value: this.decrypt(preferred), + }, + ]; + }); } - async lookupPreferred(envVarName: string): Promise { + async lookupPreferred( + envVarName: string, + projectPath?: string + ): Promise { const keys = await this.readStore(); - const matching = keys.filter((key) => key.envVarName === envVarName); - const preferred = - matching.find((key) => key.scope === 'user') ?? - matching.find((key) => key.scope === 'project') ?? - null; + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); if (!preferred) { return null; @@ -280,6 +299,20 @@ export class ApiKeyService { return stored.encrypted ? 'safeStorage' : 'base64'; } + private pickPreferredKey(matching: StoredApiKey[], projectPath?: string): StoredApiKey | null { + const normalizedProjectPath = projectPath?.trim(); + if (normalizedProjectPath) { + const projectMatch = matching.find( + (key) => key.scope === 'project' && key.projectPath === normalizedProjectPath + ); + if (projectMatch) { + return projectMatch; + } + } + + return matching.find((key) => key.scope === 'user') ?? null; + } + // ── AES-256-GCM local encryption ─────────────────────────────────────── /** diff --git a/src/preload/index.ts b/src/preload/index.ts index eb2ffbf3..772422e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1623,8 +1623,8 @@ const electronAPI: ElectronAPI = { list: () => invokeIpcWithResult(API_KEYS_LIST), save: (request: ApiKeySaveRequest) => invokeIpcWithResult(API_KEYS_SAVE, request), delete: (id: string) => invokeIpcWithResult(API_KEYS_DELETE, id), - lookup: (envVarNames: string[]) => - invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames), + lookup: (envVarNames: string[], projectPath?: string) => + invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames, projectPath), getStorageStatus: () => invokeIpcWithResult(API_KEYS_STORAGE_STATUS), }, diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 516727d3..396271ab 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -431,7 +431,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { - + diff --git a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx index 653f0fa2..a946adec 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx @@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
+ {apiKey.scope === 'project' && apiKey.projectPath && ( +

+ {apiKey.projectPath} +

+ )} + {/* Env var name */}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx index 72e07225..f2b53396 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx @@ -32,6 +32,8 @@ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; interface ApiKeyFormDialogProps { open: boolean; editingKey: ApiKeyEntry | null; + currentProjectPath: string | null; + currentProjectLabel: string | null; onClose: () => void; } @@ -45,6 +47,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ export const ApiKeyFormDialog = ({ open, editingKey, + currentProjectPath, + currentProjectLabel, onClose, }: ApiKeyFormDialogProps): React.JSX.Element => { const saveApiKey = useStore((s) => s.saveApiKey); @@ -57,6 +61,14 @@ export const ApiKeyFormDialog = ({ const [scope, setScope] = useState('user'); const [error, setError] = useState(null); const [envVarError, setEnvVarError] = useState(null); + const editingProjectPath = + editingKey?.scope === 'project' ? (editingKey.projectPath ?? null) : null; + const effectiveProjectPath = editingProjectPath ?? currentProjectPath; + const effectiveProjectLabel = + effectiveProjectPath && effectiveProjectPath === currentProjectPath + ? currentProjectLabel + : effectiveProjectPath; + const canUseProjectScope = Boolean(effectiveProjectPath); // Reset form when dialog opens/closes or editing key changes useEffect(() => { @@ -77,6 +89,12 @@ export const ApiKeyFormDialog = ({ } }, [open, editingKey]); + useEffect(() => { + if (open && scope === 'project' && !canUseProjectScope) { + setScope('user'); + } + }, [canUseProjectScope, open, scope]); + const validateEnvVar = (v: string) => { if (!v.trim()) { setEnvVarError(null); @@ -109,6 +127,10 @@ export const ApiKeyFormDialog = ({ setError('Key value is required'); return; } + if (scope === 'project' && !effectiveProjectPath) { + setError('Project-scoped API keys require an active project'); + return; + } try { await saveApiKey({ @@ -117,6 +139,7 @@ export const ApiKeyFormDialog = ({ envVarName: envVarName.trim(), value, scope, + projectPath: scope === 'project' ? (effectiveProjectPath ?? undefined) : undefined, }); onClose(); } catch (err) { @@ -125,7 +148,13 @@ export const ApiKeyFormDialog = ({ }; const isEdit = editingKey !== null; - const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving; + const canSubmit = + name.trim() && + envVarName.trim() && + value && + !envVarError && + !apiKeySaving && + (scope !== 'project' || canUseProjectScope); return ( !o && onClose()}> @@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - - {opt.label} + + {opt.value === 'project' + ? effectiveProjectPath + ? `Project: ${effectiveProjectLabel}` + : 'Project unavailable' + : opt.label} ))} + {scope === 'project' && effectiveProjectPath && ( +

Bound to {effectiveProjectPath}

+ )}
{/* Error display */} diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index a45b0e58..3352fb3c 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -15,7 +15,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; -export const ApiKeysPanel = (): React.JSX.Element => { +interface ApiKeysPanelProps { + projectPath: string | null; + projectLabel: string | null; +} + +export const ApiKeysPanel = ({ + projectPath, + projectLabel, +}: ApiKeysPanelProps): React.JSX.Element => { const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } = useStore( useShallow((s) => ({ @@ -213,7 +221,13 @@ export const ApiKeysPanel = (): React.JSX.Element => { )} {/* Form dialog */} - +
); }; diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 59c28f6f..bba98439 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -92,6 +92,11 @@ export const CustomMcpServerDialog = ({ const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); + const envVarLookupNames = envVars + .map((entry) => entry.key.trim()) + .filter(Boolean) + .sort() + .join('\0'); // Reset on open useEffect(() => { @@ -120,10 +125,10 @@ export const CustomMcpServerDialog = ({ useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; - const envVarNames = envVars.map((e) => e.key).filter(Boolean); + const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean); if (envVarNames.length === 0) return; - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( (results) => { if (results.length === 0) return; const lookup = new Map(results.map((r) => [r.envVarName, r.value])); @@ -135,7 +140,7 @@ export const CustomMcpServerDialog = ({ // Silently fail } ); - }, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps + }, [envVarLookupNames, envVars, open, projectPath]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 87ef0a08..8ac44ee7 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -110,6 +110,11 @@ export const McpServerDetailDialog = ({ const selectedInstalledEntry = normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); + const envVarLookupNames = + server?.envVars + .map((entry) => entry.name) + .sort() + .join('\0') ?? ''; // Initialize form when dialog opens or server changes useEffect(() => { @@ -160,7 +165,7 @@ export const McpServerDetailDialog = ({ if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; const envVarNames = server.envVars.map((e) => e.name); - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( (results) => { if (results.length === 0) return; const filled = new Set(); @@ -176,7 +181,7 @@ export const McpServerDetailDialog = ({ // Silently fail — auto-fill is supplementary } ); - }, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps + }, [envVarLookupNames, open, projectPath, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!server) return <>; diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 62f80d62..dcdb4855 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -105,7 +105,7 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null { const matches = apiKeys.filter((entry) => entry.envVarName === envVarName); - return matches.find((entry) => entry.scope === 'user') ?? matches[0] ?? null; + return matches.find((entry) => entry.scope === 'user') ?? null; } function getConnectionDescription(provider: CliProviderStatus): string { diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts index c72935c1..e6d9735e 100644 --- a/src/shared/types/extensions/api.ts +++ b/src/shared/types/extensions/api.ts @@ -80,6 +80,6 @@ export interface ApiKeysAPI { list: () => Promise; save: (request: ApiKeySaveRequest) => Promise; delete: (id: string) => Promise; - lookup: (envVarNames: string[]) => Promise; + lookup: (envVarNames: string[], projectPath?: string) => Promise; getStorageStatus: () => Promise; } diff --git a/src/shared/types/extensions/apikey.ts b/src/shared/types/extensions/apikey.ts index c36f11b9..d69af4f6 100644 --- a/src/shared/types/extensions/apikey.ts +++ b/src/shared/types/extensions/apikey.ts @@ -9,6 +9,7 @@ export interface ApiKeyEntry { envVarName: string; maskedValue: string; scope: 'user' | 'project'; + projectPath?: string; createdAt: string; } @@ -19,6 +20,7 @@ export interface ApiKeySaveRequest { envVarName: string; value: string; scope: 'user' | 'project'; + projectPath?: string; } /** Decrypted key lookup result (for auto-fill) */ diff --git a/test/main/services/extensions/ApiKeyService.test.ts b/test/main/services/extensions/ApiKeyService.test.ts new file mode 100644 index 00000000..71e27ec9 --- /dev/null +++ b/test/main/services/extensions/ApiKeyService.test.ts @@ -0,0 +1,120 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn(() => false), + getSelectedStorageBackend: vi.fn(() => 'basic_text'), + encryptString: vi.fn(), + decryptString: vi.fn(), + }, +})); + +import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService'; + +describe('ApiKeyService', () => { + let tempDir: string; + let service: ApiKeyService; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-')); + service = new ApiKeyService(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('persists projectPath for project-scoped API keys', async () => { + const saved = await service.save({ + name: 'Project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + expect(saved.scope).toBe('project'); + expect(saved.projectPath).toBe('/tmp/project-a'); + + await expect(service.list()).resolves.toEqual([ + expect.objectContaining({ + scope: 'project', + projectPath: '/tmp/project-a', + }), + ]); + }); + + it('rejects project-scoped keys without a project path', async () => { + await expect( + service.save({ + name: 'Broken key', + envVarName: 'TAVILY_API_KEY', + value: 'secret', + scope: 'project', + }) + ).rejects.toThrow('project path'); + }); + + it('prefers exact project matches over user keys during lookup', async () => { + await service.save({ + name: 'Shared Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + scope: 'user', + }); + await service.save({ + name: 'Project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([ + { + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + }, + ]); + }); + + it('falls back to user keys when project-specific matches do not exist', async () => { + await service.save({ + name: 'Shared Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + scope: 'user', + }); + await service.save({ + name: 'Other project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-b', + }); + + await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([ + { + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + }, + ]); + }); + + it('does not leak project-scoped keys without project context', async () => { + await service.save({ + name: 'Project only key', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]); + await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull(); + }); +}); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 9947c28d..29e23faa 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -180,6 +180,47 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('passes projectPath into API key lookup for project-aware autofill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: '/tmp/custom-mcp-project', + }) + ); + await Promise.resolve(); + }); + + const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Add') + ) as HTMLButtonElement; + await act(async () => { + addEnvButton.click(); + await Promise.resolve(); + }); + + const envKeyInput = host.querySelector( + 'input[placeholder="ENV_VAR_NAME"]' + ) as HTMLInputElement; + await act(async () => { + setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes projectPath for project-scoped custom installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 7e1b32e9..18c78c6d 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -266,7 +266,7 @@ describe('McpServerDetailDialog installed entry handling', () => { }); expect(lookupMock).toHaveBeenCalledTimes(1); - expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; expect(projectOption.disabled).toBe(true); @@ -278,6 +278,38 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('passes projectPath into API key lookup for project-aware autofill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project-context7', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('defaults to global scope in multimodel mode', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; const host = document.createElement('div'); From 24782411f35320c997022ccb84fd5ffaa5e05930 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:39:26 +0300 Subject: [PATCH 11/42] fix(extensions): scope plugin operation state by project --- .../extensions/plugins/PluginDetailDialog.tsx | 4 +- src/renderer/store/slices/extensionsSlice.ts | 43 ++++++++++----- src/shared/utils/extensionNormalizers.ts | 9 +++- .../plugins/PluginDetailDialog.test.ts | 52 ++++++++++++++++++ test/renderer/store/extensionsSlice.test.ts | 54 ++++++++++++++++++- .../shared/utils/extensionNormalizers.test.ts | 12 +++-- 6 files changed, 155 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 298788c7..e7aedff4 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -91,7 +91,9 @@ export const PluginDetailDialog = ({ } }, [projectScopeAvailable, scope]); - const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null; + const operationKey = plugin + ? getPluginOperationKey(plugin.pluginId, scope, scope !== 'user' ? projectPath : undefined) + : null; const installProgress = useStore( (s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle' ); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 20dbae3b..4ae2855a 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -163,8 +163,8 @@ function buildPluginIdSet(catalog: EnrichedPlugin[]): Set { return new Set(catalog.map((plugin) => plugin.pluginId)); } -function buildPluginOperationKeys(pluginId: string): string[] { - return PLUGIN_OPERATION_SCOPES.map((scope) => getPluginOperationKey(pluginId, scope)); +function isPluginOperationKeyForPlugin(operationKey: string, pluginId: string): boolean { + return operationKey.startsWith(`plugin:${pluginId}:`); } function clearPluginOperationState( @@ -181,10 +181,16 @@ function clearPluginOperationState( const nextPluginInstallProgress = { ...pluginInstallProgress }; const nextInstallErrors = { ...installErrors }; + const pluginIdsList = Array.from(pluginIds); - for (const pluginId of pluginIds) { - for (const operationKey of buildPluginOperationKeys(pluginId)) { + for (const operationKey of Object.keys(nextPluginInstallProgress)) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { delete nextPluginInstallProgress[operationKey]; + } + } + + for (const operationKey of Object.keys(nextInstallErrors)) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { delete nextInstallErrors[operationKey]; } } @@ -206,8 +212,9 @@ function clearPluginSuccessResetTimer(operationKey: string): void { } function clearPluginSuccessResetTimers(pluginIds: Set): void { - for (const pluginId of pluginIds) { - for (const operationKey of buildPluginOperationKeys(pluginId)) { + const pluginIdsList = Array.from(pluginIds); + for (const operationKey of Array.from(pluginSuccessResetTimers.keys())) { + if (pluginIdsList.some((pluginId) => isPluginOperationKeyForPlugin(operationKey, pluginId))) { clearPluginSuccessResetTimer(operationKey); } } @@ -339,8 +346,6 @@ const CLI_STATUS_UNKNOWN_MESSAGE = 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; -const PLUGIN_OPERATION_SCOPES: InstallScope[] = ['user', 'project', 'local']; - export const createExtensionsSlice: StateCreator = ( set, get @@ -865,11 +870,16 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined; const effectiveProjectPath = request.scope !== 'user' ? (request.projectPath ?? get().pluginCatalogProjectPath ?? undefined) : request.projectPath; - const operationKey = getPluginOperationKey(request.pluginId, request.scope); + const operationKey = getPluginOperationKey( + request.pluginId, + request.scope, + effectiveProjectPath + ); const effectiveRequest = effectiveProjectPath === request.projectPath ? request @@ -931,7 +941,12 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const catalogProjectPathAtOperationStart = get().pluginCatalogProjectPath ?? undefined; const effectiveScope = scope ?? 'user'; - const operationKey = getPluginOperationKey(pluginId, effectiveScope); const effectiveProjectPath = effectiveScope !== 'user' ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) : projectPath; + const operationKey = getPluginOperationKey(pluginId, effectiveScope, effectiveProjectPath); if (effectiveScope !== 'user' && !effectiveProjectPath) { clearPluginSuccessResetTimer(operationKey); set((prev) => ({ @@ -986,7 +1002,10 @@ export const createExtensionsSlice: StateCreator ({ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ InstallButton: ({ + state, + errorMessage, isInstalled, onInstall, onUninstall, }: { + state?: string; + errorMessage?: string; isInstalled: boolean; onInstall: () => void; onUninstall: () => void; @@ -124,6 +128,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ { type: 'button', 'data-testid': 'install-button', + 'data-state': state, + 'data-error-message': errorMessage, onClick: () => (isInstalled ? onUninstall() : onInstall()), }, isInstalled ? 'Uninstall' : 'Install' @@ -150,6 +156,7 @@ vi.mock('lucide-react', () => { }); import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog'; +import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; const makePlugin = (): EnrichedPlugin => ({ pluginId: 'context7@claude-plugins-official', @@ -270,4 +277,49 @@ describe('PluginDetailDialog project context', () => { await Promise.resolve(); }); }); + + it('reads project-scope action state from the current tab project path', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const plugin = makePlugin(); + + storeState.pluginInstallProgress = { + [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/tab-project')]: 'pending', + }; + storeState.installErrors = { + [getPluginOperationKey(plugin.pluginId, 'project', '/tmp/other-project')]: 'Wrong project', + }; + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin, + open: true, + onClose: vi.fn(), + projectPath: '/tmp/tab-project', + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(scopeSelect).not.toBeNull(); + expect(installButton).not.toBeNull(); + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + expect(installButton.getAttribute('data-state')).toBe('pending'); + expect(installButton.getAttribute('data-error-message')).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 350e074c..065b6555 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -153,8 +153,11 @@ const makeReadyCliStatus = () => ({ providers: [], }); -const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => - getPluginOperationKey(pluginId, scope); +const pluginOperationKey = ( + pluginId: string, + scope: 'user' | 'project' | 'local' = 'user', + projectPath?: string +) => getPluginOperationKey(pluginId, scope, projectPath); const mcpOperationKey = ( registryId: string, scope: 'user' | 'project' | 'local' | 'global' = 'user', @@ -574,6 +577,33 @@ describe('extensionsSlice', () => { }); }); + it('keys project-scope install state by project path and refreshes that same project context', async () => { + store.setState({ + cliStatus: makeReadyCliStatus(), + pluginCatalogProjectPath: '/tmp/project-b', + }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.install as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().installPlugin({ + pluginId: 'project@m', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-a') + ] + ).toBe('success'); + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-b') + ] + ).toBeUndefined(); + expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); + }); + it('fails fast for project scope when there is no active project path', async () => { store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); @@ -673,6 +703,26 @@ describe('extensionsSlice', () => { expect(api.plugins!.uninstall).toHaveBeenCalledWith('project@m', 'project', '/tmp/project-a'); }); + it('keys project-scope uninstall state by project path and refreshes that same project context', async () => { + store.setState({ pluginCatalogProjectPath: '/tmp/project-b' }); + (api.plugins!.getAll as ReturnType).mockResolvedValue([]); + (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); + + await store.getState().uninstallPlugin('project@m', 'project', '/tmp/project-a'); + + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-a') + ] + ).toBe('success'); + expect( + store.getState().pluginInstallProgress[ + pluginOperationKey('project@m', 'project', '/tmp/project-b') + ] + ).toBeUndefined(); + expect(api.plugins!.getAll).toHaveBeenLastCalledWith('/tmp/project-a', true); + }); + it('fails fast for project uninstall when there is no active project path', async () => { store.setState({ pluginCatalogProjectPath: null }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 979c318d..7e37218e 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -157,11 +157,17 @@ describe('buildPluginId', () => { }); describe('getPluginOperationKey', () => { - it('namespaces plugin operation keys by scope', () => { - expect(getPluginOperationKey('context7@claude-plugins-official', 'local')).toBe( - 'plugin:context7@claude-plugins-official:local', + it('namespaces user-scope plugin operation keys without a project suffix', () => { + expect(getPluginOperationKey('context7@claude-plugins-official', 'user')).toBe( + 'plugin:context7@claude-plugins-official:user', ); }); + + it('namespaces repo-scoped plugin operation keys by project path', () => { + expect( + getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project'), + ).toBe('plugin:context7@claude-plugins-official:local:/tmp/project'); + }); }); describe('getMcpOperationKey', () => { From 5007f3eebb6b062ae2c10c677fac40a8198366e8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:44:05 +0300 Subject: [PATCH 12/42] fix(extensions): scope mcp api key autofill by install target --- .../extensions/mcp/CustomMcpServerDialog.tsx | 50 +++++++++-- .../extensions/mcp/McpServerDetailDialog.tsx | 40 ++++++--- .../mcp/CustomMcpServerDialog.test.ts | 85 ++++++++++++++++++- .../mcp/McpServerDetailDialog.test.ts | 78 ++++++++++++++++- 4 files changed, 232 insertions(+), 21 deletions(-) diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index bba98439..66fc266d 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -3,7 +3,7 @@ * Supports stdio (npm package) and HTTP/SSE transports. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; @@ -92,11 +92,15 @@ export const CustomMcpServerDialog = ({ const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); + const autoFilledValuesRef = useRef>({}); const envVarLookupNames = envVars .map((entry) => entry.key.trim()) .filter(Boolean) .sort() .join('\0'); + const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) + ? (projectPath ?? undefined) + : undefined; // Reset on open useEffect(() => { @@ -112,6 +116,7 @@ export const CustomMcpServerDialog = ({ setEnvVars([]); setError(null); setInstalling(false); + autoFilledValuesRef.current = {}; } }, [defaultSharedScope, open]); @@ -128,19 +133,50 @@ export const CustomMcpServerDialog = ({ const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean); if (envVarNames.length === 0) return; - void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( + void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then( (results) => { - if (results.length === 0) return; - const lookup = new Map(results.map((r) => [r.envVarName, r.value])); - setEnvVars((prev) => - prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e)) + const previousAutoFilledValues = autoFilledValuesRef.current; + const nextAutoFilledValues = Object.fromEntries( + results.map((result) => [result.envVarName, result.value]) ); + setEnvVars((prev) => { + let changed = false; + const next = prev.map((entry) => { + const envVarName = entry.key.trim(); + if (!envVarName) { + return entry; + } + + const previousValue = previousAutoFilledValues[envVarName]; + const nextValue = nextAutoFilledValues[envVarName]; + + if (!nextValue) { + if (previousValue && entry.value === previousValue) { + changed = true; + return { ...entry, value: '' }; + } + return entry; + } + + if (!entry.value || entry.value === previousValue) { + if (entry.value !== nextValue) { + changed = true; + return { ...entry, value: nextValue }; + } + } + + return entry; + }); + + return changed ? next : prev; + }); + autoFilledValuesRef.current = nextAutoFilledValues; }, () => { // Silently fail } ); - }, [envVarLookupNames, envVars, open, projectPath]); // eslint-disable-line react-hooks/exhaustive-deps + }, [apiKeyLookupProjectPath, envVarLookupNames, open]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 8ac44ee7..10fb5579 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -3,7 +3,7 @@ * Uses Radix UI Kit for all form elements. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Badge } from '@renderer/components/ui/badge'; @@ -92,6 +92,7 @@ export const McpServerDetailDialog = ({ const [headers, setHeaders] = useState([]); const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); + const autoFilledValuesRef = useRef>({}); const normalizedInstalledEntries = installedEntries.length ? installedEntries : installedEntry @@ -115,6 +116,9 @@ export const McpServerDetailDialog = ({ .map((entry) => entry.name) .sort() .join('\0') ?? ''; + const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) + ? (projectPath ?? undefined) + : undefined; // Initialize form when dialog opens or server changes useEffect(() => { @@ -138,6 +142,7 @@ export const McpServerDetailDialog = ({ setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope); setImgError(false); setAutoFilledFields(new Set()); + autoFilledValuesRef.current = {}; }, [ defaultSharedScope, open, @@ -165,23 +170,38 @@ export const McpServerDetailDialog = ({ if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; const envVarNames = server.envVars.map((e) => e.name); - void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( + void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then( (results) => { - if (results.length === 0) return; - const filled = new Set(); - const values: Record = {}; + const previousAutoFilledValues = autoFilledValuesRef.current; + const nextAutoFilledValues: Record = {}; for (const r of results) { - values[r.envVarName] = r.value; - filled.add(r.envVarName); + nextAutoFilledValues[r.envVarName] = r.value; } - setEnvValues((prev) => ({ ...prev, ...values })); - setAutoFilledFields(filled); + setEnvValues((prev) => { + const next = { ...prev }; + + for (const [envVarName, previousValue] of Object.entries(previousAutoFilledValues)) { + if (!(envVarName in nextAutoFilledValues) && next[envVarName] === previousValue) { + next[envVarName] = ''; + } + } + + for (const [envVarName, nextValue] of Object.entries(nextAutoFilledValues)) { + if (!next[envVarName] || next[envVarName] === previousAutoFilledValues[envVarName]) { + next[envVarName] = nextValue; + } + } + + return next; + }); + setAutoFilledFields(new Set(Object.keys(nextAutoFilledValues))); + autoFilledValuesRef.current = nextAutoFilledValues; }, () => { // Silently fail — auto-fill is supplementary } ); - }, [envVarLookupNames, open, projectPath, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps + }, [apiKeyLookupProjectPath, envVarLookupNames, open, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!server) return <>; diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 29e23faa..177ffa11 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -180,7 +180,7 @@ describe('CustomMcpServerDialog project scope', () => { }); }); - it('passes projectPath into API key lookup for project-aware autofill', async () => { + it('looks up project-scoped API keys only when project scope is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -213,7 +213,88 @@ describe('CustomMcpServerDialog project scope', () => { await Promise.resolve(); }); - expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + setNativeValue(scopeSelect, 'project', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); + + await act(async () => { + setNativeValue(scopeSelect, 'user', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears stale project auto-filled values when switching back to user scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + lookupMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }]) + .mockResolvedValueOnce([]); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: '/tmp/custom-mcp-project', + }) + ); + await Promise.resolve(); + }); + + const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Add') + ) as HTMLButtonElement; + await act(async () => { + addEnvButton.click(); + await Promise.resolve(); + }); + + const envKeyInput = host.querySelector( + 'input[placeholder="ENV_VAR_NAME"]' + ) as HTMLInputElement; + const envValueInput = host.querySelector( + 'input[placeholder="value"]' + ) as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input'); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + setNativeValue(scopeSelect, 'project', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe('project-secret'); + + await act(async () => { + setNativeValue(scopeSelect, 'user', 'change'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe(''); await act(async () => { root.unmount(); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 18c78c6d..14fe2b8c 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -278,7 +278,7 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); - it('passes projectPath into API key lookup for project-aware autofill', async () => { + it('looks up project-scoped API keys only when project scope is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -302,7 +302,81 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); - expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); + + await act(async () => { + scopeSelect.value = 'user'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears stale project auto-filled values when switching back to user scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + lookupMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }]) + .mockResolvedValueOnce([]); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project-context7', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement; + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe('project-secret'); + + await act(async () => { + scopeSelect.value = 'user'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(envValueInput.value).toBe(''); await act(async () => { root.unmount(); From 8423656b9729501910b262a53034b232dd029ddd Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:46:43 +0300 Subject: [PATCH 13/42] fix(extensions): use safe legacy multimodel capability fallback --- .../runtime/ClaudeMultimodelBridgeService.ts | 11 +++-- .../utils/providerExtensionCapabilities.ts | 35 ++++++++++++++- .../ClaudeMultimodelBridgeService.test.ts | 39 ++++++++++++++++ .../providerExtensionCapabilities.test.ts | 44 +++++++++++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 test/shared/utils/providerExtensionCapabilities.test.ts diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 98b6063f..53a6bb17 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1,7 +1,10 @@ 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 { + createDefaultCliExtensionCapabilities, + createLegacyRuntimeFallbackCliExtensionCapabilities, +} from '@shared/utils/providerExtensionCapabilities'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { buildProviderAwareCliEnv } from './providerAwareCliEnv'; @@ -145,7 +148,7 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat capabilities: { teamLaunch: false, oneShot: false, - extensions: createDefaultCliExtensionCapabilities(), + extensions: createLegacyRuntimeFallbackCliExtensionCapabilities(), }, selectedBackendId: null, resolvedBackendId: null, @@ -159,7 +162,9 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat function mapRuntimeExtensionCapabilities( capabilities?: RuntimeExtensionCapabilitiesResponse ): CliProviderStatus['capabilities']['extensions'] { - const defaults = createDefaultCliExtensionCapabilities(); + const defaults = capabilities + ? createDefaultCliExtensionCapabilities() + : createLegacyRuntimeFallbackCliExtensionCapabilities(); return { plugins: { diff --git a/src/shared/utils/providerExtensionCapabilities.ts b/src/shared/utils/providerExtensionCapabilities.ts index c6d03b49..4d5d0c90 100644 --- a/src/shared/utils/providerExtensionCapabilities.ts +++ b/src/shared/utils/providerExtensionCapabilities.ts @@ -10,6 +10,27 @@ const SUPPORTED_SHARED_CAPABILITY: CliExtensionCapability = { reason: null, }; +const LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES: CliExtensionCapabilities = { + plugins: { + status: 'unsupported', + ownership: 'shared', + reason: + 'This runtime does not declare plugin capability support. Upgrade the runtime to manage plugins here.', + }, + mcp: { + status: 'read-only', + ownership: 'shared', + reason: + 'This runtime does not declare MCP management support. Upgrade the runtime to install or remove MCP servers here.', + }, + skills: { + ...SUPPORTED_SHARED_CAPABILITY, + }, + apiKeys: { + ...SUPPORTED_SHARED_CAPABILITY, + }, +}; + export function createDefaultCliExtensionCapabilities( overrides?: Partial ): CliExtensionCapabilities { @@ -22,10 +43,22 @@ export function createDefaultCliExtensionCapabilities( }; } +export function createLegacyRuntimeFallbackCliExtensionCapabilities( + overrides?: Partial +): CliExtensionCapabilities { + return { + plugins: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.plugins }, + mcp: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.mcp }, + skills: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.skills }, + apiKeys: { ...LEGACY_MULTIMODEL_FALLBACK_CAPABILITIES.apiKeys }, + ...overrides, + }; +} + export function getCliProviderExtensionCapabilities( provider: Pick ): CliExtensionCapabilities { - return provider.capabilities.extensions ?? createDefaultCliExtensionCapabilities(); + return provider.capabilities.extensions ?? createLegacyRuntimeFallbackCliExtensionCapabilities(); } export function getCliProviderExtensionCapability( diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index d6cde5c7..d69ea045 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -254,4 +254,43 @@ describe('ClaudeMultimodelBridgeService', () => { }); expect(provider.statusMessage).toContain('ANTHROPIC_API_KEY'); }); + + it('falls back conservatively when the runtime omits extension capability metadata', async () => { + execCliMock.mockResolvedValue({ + stdout: JSON.stringify({ + providers: { + codex: { + supported: true, + authenticated: true, + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const provider = await service.getProviderStatus('/mock/agent_teams_orchestrator', 'codex'); + + expect(provider).toMatchObject({ + providerId: 'codex', + capabilities: { + extensions: { + plugins: { status: 'unsupported' }, + mcp: { status: 'read-only' }, + skills: { status: 'supported' }, + apiKeys: { status: 'supported' }, + }, + }, + }); + }); }); diff --git a/test/shared/utils/providerExtensionCapabilities.test.ts b/test/shared/utils/providerExtensionCapabilities.test.ts new file mode 100644 index 00000000..da8dd4a6 --- /dev/null +++ b/test/shared/utils/providerExtensionCapabilities.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { + createLegacyRuntimeFallbackCliExtensionCapabilities, + getCliProviderExtensionCapabilities, +} from '@shared/utils/providerExtensionCapabilities'; + +import type { CliProviderStatus } from '@shared/types'; + +function makeProvider( + overrides?: Partial +): Pick { + return { + capabilities: { + teamLaunch: false, + oneShot: false, + ...(overrides?.capabilities ?? {}), + } as CliProviderStatus['capabilities'], + }; +} + +describe('providerExtensionCapabilities', () => { + it('returns conservative fallback capabilities when runtime omits extension metadata', () => { + const capabilities = getCliProviderExtensionCapabilities( + makeProvider({ + capabilities: { + teamLaunch: true, + oneShot: true, + } as CliProviderStatus['capabilities'], + }) + ); + + expect(capabilities).toEqual(createLegacyRuntimeFallbackCliExtensionCapabilities()); + }); + + it('keeps plugins unsupported and mcp read-only in the legacy multimodel fallback', () => { + const capabilities = createLegacyRuntimeFallbackCliExtensionCapabilities(); + + expect(capabilities.plugins.status).toBe('unsupported'); + expect(capabilities.mcp.status).toBe('read-only'); + expect(capabilities.skills.status).toBe('supported'); + expect(capabilities.apiKeys.status).toBe('supported'); + }); +}); From 14a38212c23aec287c8711e926c60df32dc4cd0c Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:51:58 +0300 Subject: [PATCH 14/42] fix(extensions): gate codex skill overlays by runtime --- .../extensions/skills/SkillEditorDialog.tsx | 20 ++++- .../extensions/skills/SkillImportDialog.tsx | 14 ++- .../extensions/skills/SkillsPanel.tsx | 27 ++++-- src/shared/utils/skillRoots.ts | 23 +++++ .../skills/SkillEditorDialog.test.ts | 88 +++++++++++++++++++ .../skills/SkillImportDialog.test.ts | 38 ++++++++ .../extensions/skills/SkillsPanel.test.ts | 65 +++++++++++++- 7 files changed, 264 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx index 6375f148..b377ac16 100644 --- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx @@ -52,6 +52,7 @@ interface SkillEditorDialogProps { mode: EditorMode; projectPath: string | null; projectLabel: string | null; + allowCodexRootKind: boolean; detail: SkillDetail | null; onClose: () => void; onSaved: (skillId: string | null) => void; @@ -70,6 +71,7 @@ export const SkillEditorDialog = ({ mode, projectPath, projectLabel, + allowCodexRootKind, detail, onClose, onSaved, @@ -220,7 +222,7 @@ export const SkillEditorDialog = ({ setReviewLoading(false); setSaveLoading(false); setMutationError(null); - }, [detail, mode, open, projectPath]); + }, [allowCodexRootKind, detail, mode, open, projectPath]); useEffect(() => { if (open) { @@ -240,6 +242,12 @@ export const SkillEditorDialog = ({ } }, [mode, open, projectPath, scope]); + useEffect(() => { + if (open && mode === 'create' && rootKind === 'codex' && !allowCodexRootKind) { + setRootKind('claude'); + } + }, [allowCodexRootKind, mode, open, rootKind]); + useEffect(() => { rawContentRef.current = rawContent; }, [rawContent]); @@ -291,6 +299,14 @@ export const SkillEditorDialog = ({ ); const canUseProjectScope = Boolean(projectPath); + const visibleRootDefinitions = useMemo( + () => + SKILL_ROOT_DEFINITIONS.filter( + (definition) => + definition.rootKind !== 'codex' || allowCodexRootKind || detail?.item.rootKind === 'codex' + ), + [allowCodexRootKind, detail?.item.rootKind] + ); const instructionsLocked = manualRawEdit || customMarkdownDetected; const title = mode === 'create' ? 'Create skill' : 'Edit skill'; const descriptionText = @@ -436,7 +452,7 @@ export const SkillEditorDialog = ({ - {SKILL_ROOT_DEFINITIONS.map((definition) => ( + {visibleRootDefinitions.map((definition) => ( {definition.directoryName} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index 942cbdd9..5270af35 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -56,6 +56,7 @@ interface SkillImportDialogProps { open: boolean; projectPath: string | null; projectLabel: string | null; + allowCodexRootKind: boolean; onClose: () => void; onImported: (skillId: string | null) => void; } @@ -64,6 +65,7 @@ export const SkillImportDialog = ({ open, projectPath, projectLabel, + allowCodexRootKind, onClose, onImported, }: SkillImportDialogProps): React.JSX.Element => { @@ -120,6 +122,16 @@ export const SkillImportDialog = ({ } }, [open, projectPath, scope]); + useEffect(() => { + if (open && rootKind === 'codex' && !allowCodexRootKind) { + setRootKind('claude'); + } + }, [allowCodexRootKind, open, rootKind]); + + const visibleRootDefinitions = SKILL_ROOT_DEFINITIONS.filter( + (definition) => definition.rootKind !== 'codex' || allowCodexRootKind + ); + async function handleChooseFolder(): Promise { const selected = await api.config.selectFolders(); const first = selected[0]; @@ -280,7 +292,7 @@ export const SkillImportDialog = ({ - {SKILL_ROOT_DEFINITIONS.map((definition) => ( + {visibleRootDefinitions.map((definition) => ( {definition.directoryName} {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'} diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx index 474c08d4..cd9fabe5 100644 --- a/src/renderer/components/extensions/skills/SkillsPanel.tsx +++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx @@ -10,6 +10,7 @@ import { formatSkillRootKind, getSkillAudience, getSkillAudienceLabel, + isCodexSkillOverlayAvailable, } from '@shared/utils/skillRoots'; import { AlertTriangle, @@ -126,11 +127,16 @@ export const SkillsPanel = ({ () => [...projectSkills, ...userSkills], [projectSkills, userSkills] ); + const codexSkillOverlayAvailable = useMemo( + () => isCodexSkillOverlayAvailable(cliStatus), + [cliStatus] + ); const codexOnlySkillsCount = useMemo( () => mergedSkills.filter((skill) => getSkillAudience(skill.rootKind) === 'codex').length, [mergedSkills] ); const sharedSkillsCount = mergedSkills.length - codexOnlySkillsCount; + const showCodexOnlyUi = codexSkillOverlayAvailable || codexOnlySkillsCount > 0; const selectedDetail = selectedSkillId ? (detailById[selectedSkillId] ?? null) : null; selectedSkillItemRef.current = selectedSkillId ? (selectedDetail?.item ?? mergedSkills.find((skill) => skill.id === selectedSkillId) ?? null) @@ -266,8 +272,10 @@ export const SkillsPanel = ({

Use personal skills for habits you want everywhere. Use project skills for workflows - that only make sense inside one codebase. Use `.codex` when a skill should stay - Codex-only. + that only make sense inside one codebase. + {codexSkillOverlayAvailable + ? ' Use `.codex` when a skill should stay Codex-only.' + : ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}

@@ -348,9 +356,11 @@ export const SkillsPanel = ({ {sharedSkillsCount} shared - - {codexOnlySkillsCount} Codex only - + {showCodexOnlyUi && ( + + {codexOnlySkillsCount} Codex only + + )}
@@ -363,7 +373,9 @@ export const SkillsPanel = ({ ['project', 'Project'], ['personal', 'Personal'], ['shared', 'Shared'], - ['codex-only', 'Codex only'], + ...(showCodexOnlyUi + ? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][]) + : []), ['needs-attention', 'Needs attention'], ['has-scripts', 'Has scripts'], ] as [SkillsQuickFilter, string][] @@ -616,6 +628,7 @@ export const SkillsPanel = ({ mode="create" projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} detail={null} onClose={() => setCreateOpen(false)} onSaved={(skillId) => { @@ -631,6 +644,7 @@ export const SkillsPanel = ({ mode="edit" projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} detail={editingDetail} onClose={() => { setEditOpen(false); @@ -648,6 +662,7 @@ export const SkillsPanel = ({ open={importOpen} projectPath={projectPath} projectLabel={projectLabel} + allowCodexRootKind={codexSkillOverlayAvailable} onClose={() => setImportOpen(false)} onImported={(skillId) => { setImportOpen(false); diff --git a/src/shared/utils/skillRoots.ts b/src/shared/utils/skillRoots.ts index e2f84a42..4a0e3ae6 100644 --- a/src/shared/utils/skillRoots.ts +++ b/src/shared/utils/skillRoots.ts @@ -1,4 +1,10 @@ +import { + getCliProviderExtensionCapability, + isCliExtensionCapabilityAvailable, +} from './providerExtensionCapabilities'; + import type { TeamProviderId } from '@shared/types'; +import type { CliInstallationStatus } from '@shared/types'; import type { SkillRootKind } from '@shared/types/extensions'; export type SkillAudience = 'shared' | 'codex'; @@ -59,3 +65,20 @@ export function isSkillAvailableForProvider( ): boolean { return getSkillAudience(rootKind) === 'shared' || providerId === 'codex'; } + +export function isCodexSkillOverlayAvailable( + cliStatus: Pick | null | undefined +): boolean { + if (cliStatus?.flavor !== 'agent_teams_orchestrator') { + return false; + } + + const codexProvider = cliStatus.providers.find((provider) => provider.providerId === 'codex'); + if (!codexProvider?.supported) { + return false; + } + + return isCliExtensionCapabilityAvailable( + getCliProviderExtensionCapability(codexProvider, 'skills') + ); +} diff --git a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts index fdf1f0a0..60cc722d 100644 --- a/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillEditorDialog.test.ts @@ -181,6 +181,20 @@ function makeDetail(rawContent: string): SkillDetail { }; } +function makeCodexDetail(rawContent: string): SkillDetail { + const detail = makeDetail(rawContent); + return { + ...detail, + item: { + ...detail.item, + rootKind: 'codex', + discoveryRoot: '/tmp/project/.codex/skills', + skillDir: '/tmp/project/.codex/skills/custom-skill', + skillFile: '/tmp/project/.codex/skills/custom-skill/SKILL.md', + }, + }; +} + describe('SkillEditorDialog', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -214,6 +228,7 @@ This file uses a freeform layout without generated sections. mode: 'edit', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail, onClose: vi.fn(), onSaved: vi.fn(), @@ -272,6 +287,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -298,6 +314,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -326,6 +343,7 @@ This file uses a freeform layout without generated sections. mode: 'create', projectPath: '/tmp/project', projectLabel: 'Project', + allowCodexRootKind: true, detail: null, onClose: vi.fn(), onSaved: vi.fn(), @@ -360,4 +378,74 @@ This file uses a freeform layout without generated sections. await Promise.resolve(); }); }); + + it('hides the codex root option in create mode when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'create', + projectPath: '/tmp/project', + projectLabel: 'Project', + allowCodexRootKind: false, + detail: null, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the codex root visible when editing an existing codex-only skill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const detail = makeCodexDetail(`--- +name: Codex Skill +description: Codex markdown skill +--- + +# Codex Skill +`); + + await act(async () => { + root.render( + React.createElement(SkillEditorDialog, { + open: true, + mode: 'edit', + projectPath: '/tmp/project', + projectLabel: 'Project', + allowCodexRootKind: false, + detail, + onClose: vi.fn(), + onSaved: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(rootSelect.value).toBe('codex'); + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts index 46a43035..5d572d23 100644 --- a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -132,6 +132,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -167,6 +168,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -232,6 +234,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -268,6 +271,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: '/tmp/project-a', projectLabel: 'Project A', + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -284,6 +288,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -332,6 +337,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -364,6 +370,7 @@ describe('SkillImportDialog', () => { open: false, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -390,6 +397,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -438,6 +446,7 @@ describe('SkillImportDialog', () => { open: true, projectPath: null, projectLabel: null, + allowCodexRootKind: true, onClose: vi.fn(), onImported: vi.fn(), }) @@ -471,4 +480,33 @@ describe('SkillImportDialog', () => { await Promise.resolve(); }); }); + + it('hides the codex root option when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + allowCodexRootKind: false, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const selects = host.querySelectorAll('select'); + const rootSelect = selects[1] as HTMLSelectElement; + expect(Array.from(rootSelect.options).some((option) => option.value === 'codex')).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/skills/SkillsPanel.test.ts b/test/renderer/components/extensions/skills/SkillsPanel.test.ts index b78e559d..d8864a79 100644 --- a/test/renderer/components/extensions/skills/SkillsPanel.test.ts +++ b/test/renderer/components/extensions/skills/SkillsPanel.test.ts @@ -2,6 +2,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CliInstallationStatus } from '@shared/types'; import type { SkillCatalogItem } from '@shared/types/extensions'; interface StoreState { @@ -12,6 +13,7 @@ interface StoreState { skillsDetailsById: Record; skillsUserCatalog: SkillCatalogItem[]; skillsProjectCatalogByProjectPath: Record; + cliStatus: CliInstallationStatus | null; } const storeState = {} as StoreState; @@ -107,11 +109,19 @@ vi.mock('@renderer/components/extensions/skills/SkillDetailDialog', () => ({ })); vi.mock('@renderer/components/extensions/skills/SkillEditorDialog', () => ({ - SkillEditorDialog: () => null, + SkillEditorDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => + React.createElement('div', { + 'data-testid': 'skill-editor-dialog', + 'data-allow-codex-root-kind': String(allowCodexRootKind), + }), })); vi.mock('@renderer/components/extensions/skills/SkillImportDialog', () => ({ - SkillImportDialog: () => null, + SkillImportDialog: ({ allowCodexRootKind }: { allowCodexRootKind: boolean }) => + React.createElement('div', { + 'data-testid': 'skill-import-dialog', + 'data-allow-codex-root-kind': String(allowCodexRootKind), + }), })); vi.mock('lucide-react', () => { @@ -170,6 +180,22 @@ describe('SkillsPanel', () => { storeState.skillsProjectCatalogByProjectPath = { '/tmp/project-a': [], }; + storeState.cliStatus = { + flavor: 'claude', + displayName: 'Claude CLI', + supportsSelfUpdate: true, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: 'oauth', + providers: [], + }; startWatchingMock.mockReset(); stopWatchingMock.mockReset(); onChangedMock.mockReset(); @@ -232,4 +258,39 @@ describe('SkillsPanel', () => { await Promise.resolve(); }); }); + + it('hides codex-only create and import affordances when codex runtime is unavailable', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(SkillsPanel, { + projectPath: '/tmp/project-a', + projectLabel: 'Project A', + skillsSearchQuery: '', + setSkillsSearchQuery: vi.fn(), + skillsSort: 'name-asc', + setSkillsSort: vi.fn(), + selectedSkillId: null, + setSelectedSkillId: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Codex only'); + for (const node of host.querySelectorAll('[data-testid="skill-editor-dialog"]')) { + expect(node.getAttribute('data-allow-codex-root-kind')).toBe('false'); + } + const importDialog = host.querySelector('[data-testid="skill-import-dialog"]'); + expect(importDialog?.getAttribute('data-allow-codex-root-kind')).toBe('false'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 8075ed10e7cd6c85068a9dfa93dc74d9440ea9e9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 14:58:09 +0300 Subject: [PATCH 15/42] fix(extensions): enforce runtime capability guards for mcp writes --- .../extensions/ExtensionStoreView.tsx | 39 +++-- .../extensions/mcp/CustomMcpServerDialog.tsx | 19 +++ src/renderer/store/slices/extensionsSlice.ts | 138 ++++++++++++++---- .../mcp/CustomMcpServerDialog.test.ts | 75 +++++++++- test/renderer/store/extensionsSlice.test.ts | 131 +++++++++++++++++ 5 files changed, 359 insertions(+), 43 deletions(-) diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 396271ab..4d76908b 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -21,6 +21,7 @@ import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; import { resolveProjectPathById } from '@renderer/utils/projectLookup'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -165,6 +166,16 @@ export const ExtensionStoreView = (): React.JSX.Element => { const isRefreshing = cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const mcpMutationDisableReason = useMemo( + () => + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }), + [cliStatus, cliStatusLoading] + ); const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); @@ -388,15 +399,25 @@ export const ExtensionStoreView = (): React.JSX.Element => { ))} {tabState.activeSubTab === 'mcp-servers' && ( - + + + + + + + {mcpMutationDisableReason && ( + {mcpMutationDisableReason} + )} + )}
diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 66fc266d..4bfa4c0b 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -24,6 +24,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { getDefaultMcpSharedScope, getMcpScopeLabel, @@ -67,6 +68,7 @@ export const CustomMcpServerDialog = ({ }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const scopeOptions: { value: Scope; label: string }[] = [ { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, @@ -101,6 +103,12 @@ export const CustomMcpServerDialog = ({ const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined; + const mutationDisableReason = getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }); // Reset on open useEffect(() => { @@ -181,6 +189,11 @@ export const CustomMcpServerDialog = ({ const handleInstall = async () => { setError(null); + if (mutationDisableReason) { + setError(mutationDisableReason); + return; + } + if (!serverName.trim()) { setError('Server name is required'); return; @@ -255,6 +268,7 @@ export const CustomMcpServerDialog = ({ serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && !(isProjectScopedMcpScope(scope) && !projectPath) && + !mutationDisableReason && !installing; return ( @@ -483,6 +497,11 @@ export const CustomMcpServerDialog = ({
{/* Error */} + {mutationDisableReason && ( +
+ {mutationDisableReason} +
+ )} {error && (
{error} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 4ae2855a..a0301d00 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -4,9 +4,9 @@ */ import { api } from '@renderer/api'; -import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { + getExtensionActionDisableReason, getMcpDiagnosticKey, getMcpProjectStateKey, getMcpOperationKey, @@ -338,12 +338,6 @@ function getSkillsCatalogKey(projectPath?: string): string { /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; -const CLI_AUTH_REQUIRED_MESSAGE = - 'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.'; -const CLI_HEALTHCHECK_FAILED_MESSAGE = - 'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.'; -const CLI_STATUS_UNKNOWN_MESSAGE = - 'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.'; const PROJECT_SCOPE_REQUIRED_MESSAGE = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( @@ -898,15 +892,12 @@ export const createExtensionsSlice: StateCreator ({ + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason }, + })); + return; + } + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, @@ -1033,6 +1048,30 @@ export const createExtensionsSlice: StateCreator ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: installDisableReason }, + })); + return; + } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, @@ -1079,28 +1118,38 @@ export const createExtensionsSlice: StateCreator ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, - installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, })); - return; - } - clearMcpSuccessResetTimer(progressKey); - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, - })); - - try { const result = await api.mcpRegistry.installCustom(request); if (result.state === 'error') { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, - installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' }, - })); - return; + throw new Error(result.error ?? 'Install failed'); } await Promise.all([ @@ -1120,6 +1169,7 @@ export const createExtensionsSlice: StateCreator ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason }, + })); + return; + } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 177ffa11..c8adaada 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { installCustomMcpServer: ReturnType; - cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; + cliStatus?: Record | null; + cliStatusLoading?: boolean; } const storeState = {} as StoreState; @@ -117,7 +118,15 @@ describe('CustomMcpServerDialog project scope', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); - storeState.cliStatus = null; + storeState.cliStatus = { + flavor: 'claude', + installed: true, + authLoggedIn: true, + binaryPath: '/usr/local/bin/claude', + launchError: null, + providers: [], + }; + storeState.cliStatusLoading = false; lookupMock.mockReset(); lookupMock.mockResolvedValue([]); }); @@ -180,6 +189,68 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('disables installation when the runtime declares MCP writes unavailable', async () => { + storeState.cliStatus = { + flavor: 'agent_teams_orchestrator', + installed: true, + authLoggedIn: true, + binaryPath: '/usr/local/bin/claude-multimodel', + launchError: null, + providers: [ + { + providerId: 'anthropic', + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'supported', ownership: 'shared', reason: null }, + mcp: { + status: 'read-only', + ownership: 'shared', + reason: 'MCP writes unavailable', + }, + skills: { status: 'supported', ownership: 'shared', reason: null }, + apiKeys: { status: 'supported', ownership: 'shared', reason: null }, + }, + }, + }, + ], + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('MCP writes unavailable'); + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Install') + ) as HTMLButtonElement | undefined; + expect(installButton).toBeDefined(); + expect(installButton?.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('looks up project-scoped API keys only when project scope is selected', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 065b6555..27790d32 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -22,6 +22,7 @@ vi.mock('../../../src/renderer/api', () => ({ getInstalled: vi.fn(), diagnose: vi.fn(), install: vi.fn(), + installCustom: vi.fn(), uninstall: vi.fn(), }, skills: { @@ -153,6 +154,52 @@ const makeReadyCliStatus = () => ({ providers: [], }); +const makeLimitedMultimodelCliStatus = (section: 'plugins' | 'mcp', reason: string) => ({ + flavor: 'agent_teams_orchestrator' as const, + displayName: 'Claude Multimodel', + supportsSelfUpdate: false, + showVersionDetails: true, + showBinaryPath: true, + installed: true, + installedVersion: '1.0.0', + binaryPath: '/usr/local/bin/claude-multimodel', + latestVersion: '1.0.0', + updateAvailable: false, + authLoggedIn: true, + authStatusChecking: false, + authMethod: null, + providers: [ + { + providerId: 'anthropic' as const, + displayName: 'Anthropic', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified' as const, + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { + status: section === 'plugins' ? 'unsupported' : 'supported', + ownership: 'shared' as const, + reason: section === 'plugins' ? reason : null, + }, + mcp: { + status: section === 'mcp' ? 'read-only' : 'supported', + ownership: 'shared' as const, + reason: section === 'mcp' ? reason : null, + }, + skills: { status: 'supported', ownership: 'shared' as const, reason: null }, + apiKeys: { status: 'supported', ownership: 'shared' as const, reason: null }, + }, + }, + }, + ], +}); + const pluginOperationKey = ( pluginId: string, scope: 'user' | 'project' | 'local' = 'user', @@ -618,6 +665,22 @@ describe('extensionsSlice', () => { ); }); + it('fails fast when multimodel runtime declares plugin installs unsupported', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('plugins', 'Plugin writes unavailable'), + }); + + await store.getState().installPlugin({ pluginId: 'unsupported@m', scope: 'user' }); + + expect(api.plugins!.install).not.toHaveBeenCalled(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('unsupported@m')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('unsupported@m')]).toContain( + 'Plugin writes unavailable', + ); + }); + it('fills missing projectPath for local scope from the active Extensions project context', async () => { store.setState({ cliStatus: makeReadyCliStatus(), @@ -682,6 +745,7 @@ describe('extensionsSlice', () => { describe('uninstallPlugin', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); @@ -785,6 +849,7 @@ describe('extensionsSlice', () => { describe('installMcpServer', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); @@ -840,10 +905,60 @@ describe('extensionsSlice', () => { store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] ).toBeUndefined(); }); + + it('fails fast when multimodel runtime exposes MCP as read-only', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), + }); + + await store.getState().installMcpServer({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'global', + envValues: {}, + headers: [], + }); + + expect(api.mcpRegistry!.install).not.toHaveBeenCalled(); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe( + 'error', + ); + expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain( + 'MCP writes unavailable', + ); + }); + }); + + describe('installCustomMcpServer', () => { + it('rejects and records an error when MCP writes are unavailable', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), + }); + + await expect( + store.getState().installCustomMcpServer({ + serverName: 'custom-server', + scope: 'global', + installSpec: { + type: 'stdio', + npmPackage: '@example/custom-mcp', + }, + envValues: {}, + headers: [], + }), + ).rejects.toThrow('MCP writes unavailable'); + + expect(api.mcpRegistry!.installCustom).not.toHaveBeenCalled(); + expect(store.getState().mcpInstallProgress['mcp-custom:custom-server:global']).toBe('error'); + expect(store.getState().installErrors['mcp-custom:custom-server:global']).toContain( + 'MCP writes unavailable', + ); + }); }); describe('uninstallMcpServer', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); @@ -859,6 +974,22 @@ describe('extensionsSlice', () => { 'success', ); }); + + it('fails fast when multimodel runtime exposes MCP as read-only', async () => { + store.setState({ + cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'), + }); + + await store.getState().uninstallMcpServer('test-id', 'test-server', 'global'); + + expect(api.mcpRegistry!.uninstall).not.toHaveBeenCalled(); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe( + 'error', + ); + expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain( + 'MCP writes unavailable', + ); + }); }); describe('provider-aware runtime refresh', () => { From 1b1c27e9c6ea0eb965ce41ac2559a037ced34d77 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 15:03:34 +0300 Subject: [PATCH 16/42] fix(extensions): preserve api key writes on refresh failures --- src/renderer/store/slices/extensionsSlice.ts | 40 +++++++-- test/renderer/store/extensionsSlice.test.ts | 91 ++++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index a0301d00..ca766aaf 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -336,6 +336,11 @@ function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } +function upsertApiKeyEntry(entries: ApiKeyEntry[], entry: ApiKeyEntry): ApiKeyEntry[] { + const nextEntries = entries.filter((candidate) => candidate.id !== entry.id); + return [entry, ...nextEntries]; +} + /** Duration to show "success" state before returning to idle */ const SUCCESS_DISPLAY_MS = 2_000; const PROJECT_SCOPE_REQUIRED_MESSAGE = @@ -1286,10 +1291,29 @@ export const createExtensionsSlice: StateCreator ({ + apiKeys: upsertApiKeyEntry(prev.apiKeys, savedKey), + })); + } + + await get().fetchCliStatus(); + const refreshError = get().cliStatusError; + if (refreshError) { + warnings.push(`API key saved, but failed to refresh provider status. ${refreshError}`); + } + set({ apiKeySaving: false, apiKeysError: warnings.length > 0 ? warnings.join(' ') : null }); } catch (err) { set({ apiKeySaving: false, @@ -1305,10 +1329,16 @@ export const createExtensionsSlice: StateCreator ({ apiKeys: prev.apiKeys.filter((k) => k.id !== id), })); + await get().fetchCliStatus(); + const refreshError = get().cliStatusError; + set({ + apiKeysError: refreshError + ? `API key deleted, but failed to refresh provider status. ${refreshError}` + : null, + }); } catch (err) { set({ apiKeysError: err instanceof Error ? err.message : 'Failed to delete API key', diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 27790d32..e994b3b9 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -1025,6 +1025,71 @@ describe('extensionsSlice', () => { expect(store.getState().apiKeys).toHaveLength(1); }); + it('keeps saved API keys updated when provider status refresh fails', async () => { + (api.apiKeys!.save as ReturnType).mockResolvedValue({ + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }); + (api.apiKeys!.list as ReturnType).mockResolvedValue([ + { + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }, + ]); + (api.cliInstaller!.getStatus as ReturnType).mockRejectedValue( + new Error('refresh boom') + ); + + await store.getState().saveApiKey({ + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + value: 'secret', + scope: 'user', + }); + + expect(store.getState().apiKeys).toHaveLength(1); + expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh provider status.'); + }); + + it('keeps local API key state updated when key list refresh fails after save', async () => { + (api.apiKeys!.save as ReturnType).mockResolvedValue({ + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }); + (api.apiKeys!.list as ReturnType).mockRejectedValue(new Error('list boom')); + + await store.getState().saveApiKey({ + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + value: 'secret', + scope: 'user', + }); + + expect(store.getState().apiKeys).toEqual([ + { + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }, + ]); + expect(store.getState().apiKeysError).toContain('API key saved, but failed to refresh key list.'); + }); + it('refreshes CLI status after deleting an API key', async () => { store.setState({ apiKeys: [ @@ -1046,6 +1111,32 @@ describe('extensionsSlice', () => { expect(store.getState().apiKeys).toEqual([]); }); + it('keeps local API key state updated when provider status refresh fails after delete', async () => { + store.setState({ + apiKeys: [ + { + id: 'k1', + name: 'Codex key', + envVarName: 'OPENAI_API_KEY', + maskedValue: '***', + scope: 'user', + createdAt: '2026-04-17T10:00:00.000Z', + }, + ], + }); + (api.apiKeys!.delete as ReturnType).mockResolvedValue(undefined); + (api.cliInstaller!.getStatus as ReturnType).mockRejectedValue( + new Error('refresh boom') + ); + + await store.getState().deleteApiKey('k1'); + + expect(store.getState().apiKeys).toEqual([]); + expect(store.getState().apiKeysError).toContain( + 'API key deleted, but failed to refresh provider status.' + ); + }); + it('keys MCP diagnostics by scope when the same server exists in multiple scopes', async () => { (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([ { From 88ac0acdf82369cce8c1839dcd733ccbfe170c15 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 15:06:36 +0300 Subject: [PATCH 17/42] fix(extensions): preserve mcp dialog state on runtime hydration --- .../extensions/mcp/CustomMcpServerDialog.tsx | 28 ++++++- .../extensions/mcp/McpServerDetailDialog.tsx | 35 +++++--- src/shared/utils/mcpScopes.ts | 4 + .../mcp/CustomMcpServerDialog.test.ts | 57 +++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 82 +++++++++++++++++++ 5 files changed, 195 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 4bfa4c0b..727d2603 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -29,6 +29,7 @@ import { getDefaultMcpSharedScope, getMcpScopeLabel, isProjectScopedMcpScope, + isSharedMcpScope, } from '@shared/utils/mcpScopes'; import { Plus, Server, Trash2 } from 'lucide-react'; @@ -95,6 +96,8 @@ export const CustomMcpServerDialog = ({ const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); const autoFilledValuesRef = useRef>({}); + const wasOpenRef = useRef(false); + const previousDefaultSharedScopeRef = useRef(defaultSharedScope); const envVarLookupNames = envVars .map((entry) => entry.key.trim()) .filter(Boolean) @@ -112,7 +115,8 @@ export const CustomMcpServerDialog = ({ // Reset on open useEffect(() => { - if (open) { + const justOpened = open && !wasOpenRef.current; + if (justOpened) { setServerName(''); setTransportMode('stdio'); setScope(defaultSharedScope); @@ -126,8 +130,30 @@ export const CustomMcpServerDialog = ({ setInstalling(false); autoFilledValuesRef.current = {}; } + wasOpenRef.current = open; + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; + } }, [defaultSharedScope, open]); + useEffect(() => { + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; + return; + } + + const previousDefaultSharedScope = previousDefaultSharedScopeRef.current; + if ( + previousDefaultSharedScope !== defaultSharedScope && + scope === previousDefaultSharedScope && + isSharedMcpScope(scope) + ) { + setScope(defaultSharedScope); + } + + previousDefaultSharedScopeRef.current = defaultSharedScope; + }, [defaultSharedScope, open, scope]); + useEffect(() => { if (open && isProjectScopedMcpScope(scope) && !projectPath) { setScope(defaultSharedScope); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 10fb5579..54d61b32 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -29,6 +29,7 @@ import { getDefaultMcpSharedScope, getMcpScopeLabel, isProjectScopedMcpScope, + isSharedMcpScope, } from '@shared/utils/mcpScopes'; import { getMcpInstallationSummaryLabel, @@ -93,6 +94,7 @@ export const McpServerDetailDialog = ({ const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); const autoFilledValuesRef = useRef>({}); + const previousDefaultSharedScopeRef = useRef(defaultSharedScope); const normalizedInstalledEntries = installedEntries.length ? installedEntries : installedEntry @@ -143,21 +145,34 @@ export const McpServerDetailDialog = ({ setImgError(false); setAutoFilledFields(new Set()); autoFilledValuesRef.current = {}; - }, [ - defaultSharedScope, - open, - preferredInstalledEntry?.name, - preferredInstalledEntry?.scope, - server?.id, - ]); + }, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]); useEffect(() => { - if (!server || !open) { + if (!open) { + previousDefaultSharedScopeRef.current = defaultSharedScope; return; } - setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); - }, [open, scope, selectedInstalledEntry?.name, server]); + const previousDefaultSharedScope = previousDefaultSharedScopeRef.current; + if ( + previousDefaultSharedScope !== defaultSharedScope && + !preferredInstalledEntry && + scope === previousDefaultSharedScope && + isSharedMcpScope(scope) + ) { + setScope(defaultSharedScope); + } + + previousDefaultSharedScopeRef.current = defaultSharedScope; + }, [defaultSharedScope, open, preferredInstalledEntry, scope]); + + useEffect(() => { + if (!server || !open || !selectedInstalledEntry) { + return; + } + + setServerName(selectedInstalledEntry.name); + }, [open, selectedInstalledEntry, server]); useEffect(() => { if (open && isProjectScopedMcpScope(scope) && !projectPath) { diff --git a/src/shared/utils/mcpScopes.ts b/src/shared/utils/mcpScopes.ts index 44903816..967357f3 100644 --- a/src/shared/utils/mcpScopes.ts +++ b/src/shared/utils/mcpScopes.ts @@ -8,6 +8,10 @@ export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedSc return flavor === 'agent_teams_orchestrator' ? 'global' : 'user'; } +export function isSharedMcpScope(scope?: string): scope is McpSharedScope { + return scope === 'user' || scope === 'global'; +} + export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' { return scope === 'project' || scope === 'local'; } diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index c8adaada..be0331ae 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -189,6 +189,63 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('preserves entered values when multimodel scope metadata loads after open', async () => { + storeState.cliStatus = null; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const nameInput = host.querySelector('#custom-name') as HTMLInputElement; + const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setNativeValue(nameInput, 'late-hydration-server', 'input'); + setNativeValue(packageInput, '@example/late-hydration', 'input'); + await Promise.resolve(); + }); + + expect(scopeSelect.value).toBe('user'); + + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + expect((host.querySelector('#custom-name') as HTMLInputElement).value).toBe( + 'late-hydration-server' + ); + expect((host.querySelector('#custom-npm') as HTMLInputElement).value).toBe( + '@example/late-hydration' + ); + expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe( + 'global' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('disables installation when the runtime declares MCP writes unavailable', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator', diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 14fe2b8c..d8fc52c0 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -165,6 +165,15 @@ function makeServer(): McpCatalogItem { }; } +function setInputValue(element: HTMLInputElement | HTMLSelectElement, value: string): void { + const prototype = + element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); +} + describe('McpServerDetailDialog installed entry handling', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -416,6 +425,79 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('preserves edited fields when multimodel scope metadata loads after open', async () => { + storeState.cliStatus = null; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: null, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; + const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setInputValue(serverNameInput, 'late-hydration-context7'); + setInputValue(envValueInput, 'secret'); + await Promise.resolve(); + }); + + expect(scopeSelect.value).toBe('user'); + + storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: null, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect((host.querySelector('#server-name') as HTMLInputElement).value).toBe( + 'late-hydration-context7' + ); + expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe( + 'secret' + ); + expect((host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement).value).toBe( + 'global' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes project path for project-scoped installs and uninstalls', async () => { const host = document.createElement('div'); document.body.appendChild(host); From 27a54f7f32483bd2be5dfe82db4c03bd1f505a22 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 19:47:41 +0300 Subject: [PATCH 18/42] fix(extensions): tighten plugin applicability copy --- src/renderer/components/extensions/plugins/PluginsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 0f7cf218..10859fde 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -192,7 +192,7 @@ export const PluginsPanel = ({ return (
- Plugins currently apply to Anthropic sessions in the multimodel runtime. + In the multimodel runtime, plugins currently apply only to Anthropic sessions. {capability.reason ? ` ${capability.reason}` : ''}
); From 12f6f907015de6dbab66681d236822f7d174a76f Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:07:35 +0300 Subject: [PATCH 19/42] fix(extensions): hide runtime-injected mcp diagnostics --- .../runtime/mcpDiagnosticsParser.ts | 44 +++++++++++++------ .../McpHealthDiagnosticsService.test.ts | 18 +++++--- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts index 911b054f..ebc95bf2 100644 --- a/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts +++ b/src/main/services/extensions/runtime/mcpDiagnosticsParser.ts @@ -1,3 +1,5 @@ +import { isInstalledMcpScope } from '@shared/utils/mcpScopes'; + import type { McpServerDiagnostic, McpServerHealthStatus } from '@shared/types/extensions'; interface McpDiagnoseJsonEntry { @@ -18,6 +20,21 @@ const EMBEDDED_HTTP_URL_PATTERN = /https?:\/\/[^\s"'`]+/gi; const SENSITIVE_FLAG_VALUE_PATTERN = /(--(?:api[-_]?key|access[-_]?token|auth[-_]?token|token|secret|password|client[-_]?secret))(?:=([^\s]+)|\s+([^\s]+))/gi; +function isPluginInjectedDiagnosticName(name: string): boolean { + return name.startsWith('plugin:'); +} + +function isExtensionsManagedDiagnosticEntry(entry: { + name: string; + scope?: 'local' | 'user' | 'project' | 'global' | 'dynamic' | 'managed'; +}): boolean { + if (isPluginInjectedDiagnosticName(entry.name)) { + return false; + } + + return entry.scope === undefined || isInstalledMcpScope(entry.scope); +} + function extractJsonObject(raw: string): T { const trimmed = raw.trim(); try { @@ -127,7 +144,8 @@ export function parseMcpDiagnosticsOutput(output: string): McpServerDiagnostic[] .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); + .filter((entry): entry is McpServerDiagnostic => entry !== null) + .filter((entry) => isExtensionsManagedDiagnosticEntry(entry)); } export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnostic[] { @@ -155,17 +173,17 @@ export function parseMcpDiagnosticsJsonOutput(output: string): McpServerDiagnost : 'unknown'; const rawLine = `${entry.name}: ${redactedTarget} - ${entry.statusLabel}`; - return [ - { - name: entry.name, - target: redactedTarget, - scope: entry.scope, - transport: entry.transport, - status: normalizedStatus, - statusLabel: entry.statusLabel, - rawLine, - checkedAt, - }, - ]; + const diagnostic = { + name: entry.name, + target: redactedTarget, + scope: entry.scope, + transport: entry.transport, + status: normalizedStatus, + statusLabel: entry.statusLabel, + rawLine, + checkedAt, + } satisfies McpServerDiagnostic; + + return isExtensionsManagedDiagnosticEntry(diagnostic) ? [diagnostic] : []; }); } diff --git a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts index c3db915f..706d508f 100644 --- a/test/main/services/extensions/McpHealthDiagnosticsService.test.ts +++ b/test/main/services/extensions/McpHealthDiagnosticsService.test.ts @@ -16,20 +16,20 @@ browsermcp: npx @browsermcp/mcp@latest - ✓ Connected tavily-remote-mcp: npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=test - ✗ Failed to connect alpic: https://mcp.alpic.ai (HTTP) - ! Needs authentication`); - expect(diagnostics).toHaveLength(5); + expect(diagnostics).toHaveLength(3); expect(diagnostics[0]).toMatchObject({ - name: 'plugin:context7:context7', - target: 'npx -y @upstash/context7-mcp', + name: 'browsermcp', + target: 'npx @browsermcp/mcp@latest', status: 'connected', statusLabel: 'Connected', }); - expect(diagnostics[3]).toMatchObject({ + expect(diagnostics[1]).toMatchObject({ name: 'tavily-remote-mcp', target: 'npx -y mcp-remote https://mcp.tavily.com/mcp/?tavilyApiKey=REDACTED', status: 'failed', statusLabel: 'Failed to connect', }); - expect(diagnostics[4]).toMatchObject({ + expect(diagnostics[2]).toMatchObject({ name: 'alpic', target: 'https://mcp.alpic.ai (HTTP)', status: 'needs-authentication', @@ -64,6 +64,14 @@ another log line`); status: 'timeout', statusLabel: 'Timed out', }, + { + name: 'plugin:context7:context7', + target: 'npx -y @upstash/context7-mcp', + scope: 'dynamic', + transport: 'stdio', + status: 'connected', + statusLabel: 'Connected', + }, ], }) ); From 3446ef01004a52d1bff6eb9705860f81afee7fb6 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:20:27 +0300 Subject: [PATCH 20/42] fix(extensions): harden hidden provider runtime handling --- .../components/dashboard/CliStatusBanner.tsx | 9 +- .../extensions/ExtensionStoreView.tsx | 113 ++++++++++-------- .../utils/multimodelProviderVisibility.ts | 32 +++++ .../cli/CliStatusVisibility.test.ts | 66 +++++++--- .../multimodelProviderVisibility.test.ts | 65 ++++++++++ 5 files changed, 218 insertions(+), 67 deletions(-) create mode 100644 src/renderer/utils/multimodelProviderVisibility.ts create mode 100644 test/renderer/utils/multimodelProviderVisibility.test.ts diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index be2ffe20..ff41320d 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -33,6 +33,7 @@ import { useStore } from '@renderer/store'; import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice'; import { formatBytes } from '@renderer/utils/formatters'; import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze'; +import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVisibility'; import { AlertTriangle, CheckCircle, @@ -321,7 +322,11 @@ function formatRuntimeAuthSummary( cliStatus: NonNullable['cliStatus']>, visibleProviders: readonly CliProviderStatus[] ): string | null { - if (cliStatus.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0) { + if (isMultimodelRuntimeStatus(cliStatus)) { + if (visibleProviders.length === 0) { + return null; + } + if ( visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated @@ -351,7 +356,7 @@ function isCheckingMultimodelStatus( visibleProviders: readonly CliProviderStatus[] ): boolean { return ( - cliStatus.flavor === 'agent_teams_orchestrator' && + isMultimodelRuntimeStatus(cliStatus) && visibleProviders.length > 0 && visibleProviders.every( (provider) => provider.statusMessage === 'Checking...' && !provider.authenticated diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 4d76908b..ffc9ff4c 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -20,6 +20,11 @@ import { import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; +import { + formatCliExtensionCapabilityStatus, + getVisibleMultimodelProviders, + isMultimodelRuntimeStatus, +} from '@renderer/utils/multimodelProviderVisibility'; import { resolveProjectPathById } from '@renderer/utils/projectLookup'; import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react'; @@ -178,9 +183,8 @@ export const ExtensionStoreView = (): React.JSX.Element => { ); const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; - const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); - const isMultimodel = - cliStatus?.flavor === 'agent_teams_orchestrator' && visibleProviders.length > 0; + const visibleProviders = getVisibleMultimodelProviders(providers); + const isMultimodel = isMultimodelRuntimeStatus(cliStatus); if (cliStatusLoading || cliStatus === null) { return ( @@ -260,57 +264,64 @@ export const ExtensionStoreView = (): React.JSX.Element => {

-
- {visibleProviders.map((provider) => { - const statusTone = provider.authenticated - ? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300' - : provider.supported - ? 'border-amber-500/30 bg-amber-500/5 text-amber-300' - : 'border-border bg-surface-raised text-text-muted'; - const statusLabel = provider.authenticated - ? 'Connected' - : provider.supported - ? 'Needs setup' - : 'Unsupported'; - const pluginStatus = provider.capabilities.extensions.plugins.status; + {visibleProviders.length > 0 && ( +
+ {visibleProviders.map((provider) => { + const statusTone = provider.authenticated + ? 'border-emerald-500/30 bg-emerald-500/5 text-emerald-300' + : provider.supported + ? 'border-amber-500/30 bg-amber-500/5 text-amber-300' + : 'border-border bg-surface-raised text-text-muted'; + const statusLabel = provider.authenticated + ? 'Connected' + : provider.supported + ? 'Needs setup' + : 'Unsupported'; + const pluginStatus = provider.capabilities.extensions.plugins.status; - return ( -
-
-
-

- - {provider.displayName} -

-

- {provider.statusMessage ?? provider.backend?.label ?? 'Ready to configure'} -

+ return ( +
+
+
+

+ + {provider.displayName} +

+

+ {provider.statusMessage ?? + provider.backend?.label ?? + 'Ready to configure'} +

+
+ + {statusLabel} + +
+
+ + Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)} + + + MCP:{' '} + {formatCliExtensionCapabilityStatus( + provider.capabilities.extensions.mcp.status + )} + + + Skills: {provider.capabilities.extensions.skills.ownership} +
- - {statusLabel} -
-
- - Plugins: {pluginStatus === 'supported' ? 'supported' : 'limited'} - - - MCP: {provider.capabilities.extensions.mcp.status} - - - Skills: {provider.capabilities.extensions.skills.ownership} - -
-
- ); - })} -
+ ); + })} +
+ )}
); } diff --git a/src/renderer/utils/multimodelProviderVisibility.ts b/src/renderer/utils/multimodelProviderVisibility.ts new file mode 100644 index 00000000..8fa4d753 --- /dev/null +++ b/src/renderer/utils/multimodelProviderVisibility.ts @@ -0,0 +1,32 @@ +import { filterMainScreenCliProviders } from './geminiUiFreeze'; + +import type { + CliExtensionCapability, + CliInstallationStatus, + CliProviderStatus, +} from '@shared/types'; + +export function getVisibleMultimodelProviders( + providers: readonly CliProviderStatus[] +): CliProviderStatus[] { + return filterMainScreenCliProviders(providers); +} + +export function isMultimodelRuntimeStatus( + cliStatus: Pick | null | undefined +): boolean { + return cliStatus?.flavor === 'agent_teams_orchestrator' && (cliStatus.providers?.length ?? 0) > 0; +} + +export function formatCliExtensionCapabilityStatus( + status: CliExtensionCapability['status'] +): string { + switch (status) { + case 'supported': + return 'supported'; + case 'read-only': + return 'read-only'; + default: + return 'unsupported'; + } +} diff --git a/test/renderer/components/cli/CliStatusVisibility.test.ts b/test/renderer/components/cli/CliStatusVisibility.test.ts index 96527c46..d383257d 100644 --- a/test/renderer/components/cli/CliStatusVisibility.test.ts +++ b/test/renderer/components/cli/CliStatusVisibility.test.ts @@ -170,7 +170,8 @@ function createApiKeyMisconfiguredProvider( connection: { supportsOAuth: true, supportsApiKey: true, - configurableAuthModes: providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'], + configurableAuthModes: + providerId === 'anthropic' ? ['auto', 'oauth', 'api_key'] : ['oauth', 'api_key'], configuredAuthMode: 'api_key', apiKeyBetaAvailable: providerId === 'codex' ? true : undefined, apiKeyBetaEnabled: providerId === 'codex' ? true : undefined, @@ -181,9 +182,7 @@ function createApiKeyMisconfiguredProvider( }; } -function createApiKeyModeProviderIssue( - providerId: 'anthropic' | 'codex' -): Record { +function createApiKeyModeProviderIssue(providerId: 'anthropic' | 'codex'): Record { return { ...createApiKeyMisconfiguredProvider(providerId), statusMessage: @@ -191,8 +190,8 @@ function createApiKeyModeProviderIssue( ? 'Anthropic API key was rejected by the runtime.' : 'OpenAI API key was rejected by the runtime.', connection: { - ...((createApiKeyMisconfiguredProvider(providerId) as { connection: Record }) - .connection), + ...(createApiKeyMisconfiguredProvider(providerId) as { connection: Record }) + .connection, apiKeyConfigured: true, apiKeySource: 'stored', apiKeySourceLabel: @@ -287,9 +286,7 @@ describe('CLI status visibility during completed install state', () => { it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; - storeState.fetchCliProviderStatus = vi.fn(() => - Promise.reject(new Error('refresh failed')) - ); + storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed'))); const host = document.createElement('div'); document.body.appendChild(host); @@ -345,6 +342,49 @@ describe('CLI status visibility during completed install state', () => { }); }); + it('does not fall back to direct-Claude auth copy when only hidden multimodel providers are available', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.cliInstallerState = 'idle'; + storeState.cliStatus = createInstalledCliStatus({ + flavor: 'agent_teams_orchestrator', + authLoggedIn: true, + providers: [ + { + providerId: 'gemini', + displayName: 'Gemini', + supported: true, + authenticated: true, + authMethod: 'cli_oauth_personal', + verificationState: 'verified', + statusMessage: 'Resolved to CLI SDK', + models: [], + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(CliStatusBanner)); + await Promise.resolve(); + }); + + expect(host.textContent).not.toContain('Authenticated'); + expect(host.textContent).not.toContain('Providers:'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('shows a degraded runtime warning when a binary is found but the health check fails', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; @@ -413,9 +453,7 @@ describe('CLI status visibility during completed install state', () => { it('preserves settings runtime backend refresh errors for the manage dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.cliInstallerState = 'idle'; - storeState.fetchCliProviderStatus = vi.fn(() => - Promise.reject(new Error('refresh failed')) - ); + storeState.fetchCliProviderStatus = vi.fn(() => Promise.reject(new Error('refresh failed'))); const host = document.createElement('div'); document.body.appendChild(host); @@ -490,8 +528,8 @@ describe('CLI status visibility during completed install state', () => { expect(host.textContent).not.toContain('Already logged in?'); expect(host.textContent).not.toContain('Login'); - const manageButton = Array.from(host.querySelectorAll('button')).find( - (button) => button.textContent?.includes('Manage Providers') + const manageButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Manage Providers') ); expect(manageButton).not.toBeUndefined(); diff --git a/test/renderer/utils/multimodelProviderVisibility.test.ts b/test/renderer/utils/multimodelProviderVisibility.test.ts new file mode 100644 index 00000000..47cac0f4 --- /dev/null +++ b/test/renderer/utils/multimodelProviderVisibility.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatCliExtensionCapabilityStatus, + getVisibleMultimodelProviders, + isMultimodelRuntimeStatus, +} from '@renderer/utils/multimodelProviderVisibility'; + +import type { CliInstallationStatus, CliProviderStatus } from '@shared/types'; + +function createProvider(providerId: CliProviderStatus['providerId']): CliProviderStatus { + return { + providerId, + displayName: + providerId === 'anthropic' ? 'Anthropic' : providerId === 'codex' ? 'Codex' : 'Gemini', + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + capabilities: { + teamLaunch: true, + oneShot: true, + }, + statusMessage: null, + detailMessage: null, + selectedBackendId: null, + resolvedBackendId: null, + availableBackends: [], + externalRuntimeDiagnostics: [], + models: [], + backend: null, + connection: null, + }; +} + +describe('multimodelProviderVisibility', () => { + it('keeps multimodel runtime detection true even when all visible provider cards are hidden', () => { + const cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [createProvider('gemini')], + } satisfies Pick; + + expect(isMultimodelRuntimeStatus(cliStatus)).toBe(true); + expect(getVisibleMultimodelProviders(cliStatus.providers)).toHaveLength(0); + }); + + it('filters Gemini from the visible provider cards while keeping supported providers', () => { + const providers = [ + createProvider('anthropic'), + createProvider('codex'), + createProvider('gemini'), + ]; + + expect(getVisibleMultimodelProviders(providers).map((provider) => provider.providerId)).toEqual( + ['anthropic', 'codex'] + ); + }); + + it('formats capability statuses without collapsing read-only into a vague limited label', () => { + expect(formatCliExtensionCapabilityStatus('supported')).toBe('supported'); + expect(formatCliExtensionCapabilityStatus('read-only')).toBe('read-only'); + expect(formatCliExtensionCapabilityStatus('unsupported')).toBe('unsupported'); + }); +}); From 7b16cfe73b6cdff7dcfc91fd5b76d78ec2537e7e Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:22:29 +0300 Subject: [PATCH 21/42] fix(extensions): treat multimodel flavor as runtime-aware before hydration --- .../utils/multimodelProviderVisibility.ts | 2 +- src/shared/utils/extensionNormalizers.ts | 2 +- .../multimodelProviderVisibility.test.ts | 9 ++ .../shared/utils/extensionNormalizers.test.ts | 109 ++++++++++-------- 4 files changed, 73 insertions(+), 49 deletions(-) diff --git a/src/renderer/utils/multimodelProviderVisibility.ts b/src/renderer/utils/multimodelProviderVisibility.ts index 8fa4d753..68e153f4 100644 --- a/src/renderer/utils/multimodelProviderVisibility.ts +++ b/src/renderer/utils/multimodelProviderVisibility.ts @@ -15,7 +15,7 @@ export function getVisibleMultimodelProviders( export function isMultimodelRuntimeStatus( cliStatus: Pick | null | undefined ): boolean { - return cliStatus?.flavor === 'agent_teams_orchestrator' && (cliStatus.providers?.length ?? 0) > 0; + return cliStatus?.flavor === 'agent_teams_orchestrator'; } export function formatCliExtensionCapabilityStatus( diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index e289030d..e3da4d7d 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -266,7 +266,7 @@ export function getExtensionActionDisableReason(options: { } const providers = cliStatus.providers ?? []; - const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator' && providers.length > 0; + const isMultimodel = cliStatus.flavor === 'agent_teams_orchestrator'; if (section === 'mcp') { if (!isMultimodel) { diff --git a/test/renderer/utils/multimodelProviderVisibility.test.ts b/test/renderer/utils/multimodelProviderVisibility.test.ts index 47cac0f4..84ab2872 100644 --- a/test/renderer/utils/multimodelProviderVisibility.test.ts +++ b/test/renderer/utils/multimodelProviderVisibility.test.ts @@ -45,6 +45,15 @@ describe('multimodelProviderVisibility', () => { expect(getVisibleMultimodelProviders(cliStatus.providers)).toHaveLength(0); }); + it('keeps multimodel runtime detection true even before provider metadata arrives', () => { + const cliStatus = { + flavor: 'agent_teams_orchestrator', + providers: [], + } satisfies Pick; + + expect(isMultimodelRuntimeStatus(cliStatus)).toBe(true); + }); + it('filters Gemini from the visible provider cards while keeping supported providers', () => { const providers = [ createProvider('anthropic'), diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 7e37218e..6b54cb09 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -23,21 +23,15 @@ import { describe('normalizeRepoUrl', () => { it('lowercases and strips .git', () => { - expect(normalizeRepoUrl('https://GitHub.com/Org/Repo.git')).toBe( - 'https://github.com/org/repo', - ); + expect(normalizeRepoUrl('https://GitHub.com/Org/Repo.git')).toBe('https://github.com/org/repo'); }); it('strips trailing slashes', () => { - expect(normalizeRepoUrl('https://github.com/org/repo/')).toBe( - 'https://github.com/org/repo', - ); + expect(normalizeRepoUrl('https://github.com/org/repo/')).toBe('https://github.com/org/repo'); }); it('handles already clean URLs', () => { - expect(normalizeRepoUrl('https://github.com/org/repo')).toBe( - 'https://github.com/org/repo', - ); + expect(normalizeRepoUrl('https://github.com/org/repo')).toBe('https://github.com/org/repo'); }); }); @@ -68,9 +62,10 @@ describe('inferCapabilities', () => { }); it('detects multiple capabilities', () => { - expect( - inferCapabilities(makePlugin({ hasLspServers: true, hasMcpServers: true })), - ).toEqual(['lsp', 'mcp']); + expect(inferCapabilities(makePlugin({ hasLspServers: true, hasMcpServers: true }))).toEqual([ + 'lsp', + 'mcp', + ]); }); it('preserves capability order', () => { @@ -80,8 +75,8 @@ describe('inferCapabilities', () => { hasHooks: true, hasAgents: true, hasLspServers: true, - }), - ), + }) + ) ).toEqual(['lsp', 'agent', 'hook']); }); }); @@ -151,7 +146,7 @@ describe('normalizeCategory', () => { describe('buildPluginId', () => { it('creates qualifiedName format', () => { expect(buildPluginId('context7', 'claude-plugins-official')).toBe( - 'context7@claude-plugins-official', + 'context7@claude-plugins-official' ); }); }); @@ -159,36 +154,28 @@ describe('buildPluginId', () => { describe('getPluginOperationKey', () => { it('namespaces user-scope plugin operation keys without a project suffix', () => { expect(getPluginOperationKey('context7@claude-plugins-official', 'user')).toBe( - 'plugin:context7@claude-plugins-official:user', + 'plugin:context7@claude-plugins-official:user' ); }); it('namespaces repo-scoped plugin operation keys by project path', () => { - expect( - getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project'), - ).toBe('plugin:context7@claude-plugins-official:local:/tmp/project'); + expect(getPluginOperationKey('context7@claude-plugins-official', 'local', '/tmp/project')).toBe( + 'plugin:context7@claude-plugins-official:local:/tmp/project' + ); }); }); describe('getMcpOperationKey', () => { it('namespaces MCP operation keys by scope', () => { expect(getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')).toBe( - 'mcp:io.github.upstash/context7:project:/tmp/project', + 'mcp:io.github.upstash/context7:project:/tmp/project' ); }); }); describe('hasInstallationInScope', () => { it('returns true when the selected scope exists', () => { - expect( - hasInstallationInScope( - [ - { scope: 'user' }, - { scope: 'project' }, - ], - 'project', - ), - ).toBe(true); + expect(hasInstallationInScope([{ scope: 'user' }, { scope: 'project' }], 'project')).toBe(true); }); it('returns false when the selected scope is missing', () => { @@ -210,12 +197,9 @@ describe('getInstallationSummaryLabel', () => { }); it('summarizes multiple scopes without pretending they are global', () => { - expect( - getInstallationSummaryLabel([ - { scope: 'project' }, - { scope: 'user' }, - ]), - ).toBe('Installed in 2 scopes'); + expect(getInstallationSummaryLabel([{ scope: 'project' }, { scope: 'user' }])).toBe( + 'Installed in 2 scopes' + ); }); }); @@ -252,12 +236,9 @@ describe('getMcpInstallationSummaryLabel', () => { }); it('summarizes multiple MCP scopes', () => { - expect( - getMcpInstallationSummaryLabel([ - { scope: 'user' }, - { scope: 'project' }, - ]) - ).toBe('Installed in 2 scopes'); + expect(getMcpInstallationSummaryLabel([{ scope: 'user' }, { scope: 'project' }])).toBe( + 'Installed in 2 scopes' + ); }); }); @@ -285,7 +266,7 @@ describe('getExtensionActionDisableReason', () => { isInstalled: false, cliStatus: createDirectCliStatus({ authLoggedIn: false }), cliStatusLoading: false, - }), + }) ).toContain('not signed in'); }); @@ -295,7 +276,7 @@ describe('getExtensionActionDisableReason', () => { isInstalled: true, cliStatus: createDirectCliStatus({ authLoggedIn: false }), cliStatusLoading: false, - }), + }) ).toBeNull(); }); @@ -305,7 +286,7 @@ describe('getExtensionActionDisableReason', () => { isInstalled: true, cliStatus: createDirectCliStatus({ installed: false, authLoggedIn: false }), cliStatusLoading: false, - }), + }) ).toContain('configured runtime'); }); @@ -322,7 +303,7 @@ describe('getExtensionActionDisableReason', () => { }), }, cliStatusLoading: false, - }), + }) ).toContain('failed to start'); }); @@ -365,7 +346,7 @@ describe('getExtensionActionDisableReason', () => { ], }, cliStatusLoading: false, - }), + }) ).toContain('Anthropic plugins unavailable'); }); @@ -404,9 +385,43 @@ describe('getExtensionActionDisableReason', () => { ], }, cliStatusLoading: false, - }), + }) ).toBeNull(); }); + + it('uses conservative multimodel fallback when provider metadata is not available yet', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + section: 'plugins', + cliStatus: { + installed: true, + authLoggedIn: false, + binaryPath: '/usr/local/bin/claude-multimodel', + launchError: null, + flavor: 'agent_teams_orchestrator', + providers: [], + }, + cliStatusLoading: false, + }) + ).toContain('not supported by the current runtime'); + + expect( + getExtensionActionDisableReason({ + isInstalled: false, + section: 'mcp', + cliStatus: { + installed: true, + authLoggedIn: false, + binaryPath: '/usr/local/bin/claude-multimodel', + launchError: null, + flavor: 'agent_teams_orchestrator', + providers: [], + }, + cliStatusLoading: false, + }) + ).toContain('not supported by the current runtime'); + }); }); describe('sanitizeMcpServerName', () => { From 1ee139b66ab5faeadf51a76d8a6eccad026d32b4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 20:24:45 +0300 Subject: [PATCH 22/42] fix(extensions): keep extensions entry points available before auth --- .../components/dashboard/CliStatusBanner.tsx | 5 +- .../settings/sections/CliStatusSection.tsx | 3 +- .../cli/CliStatusVisibility.test.ts | 47 ++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index ff41320d..26118973 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -387,6 +387,7 @@ const InstalledBanner = ({ () => filterMainScreenCliProviders(cliStatus.providers), [cliStatus.providers] ); + const canOpenExtensions = cliStatus.installed; const runtimeLabel = formatRuntimeLabel(cliStatus); const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders); @@ -471,8 +472,8 @@ const InstalledBanner = ({ disabled={isBusy || cliStatusLoading || multimodelBusy} />
- {/* Extensions button — only when installed + authenticated */} - {cliStatus.authLoggedIn && ( + {/* Extensions button — available whenever the runtime is installed */} + {canOpenExtensions && ( ) : null} {/* Extensions button — right-aligned */} - {effectiveCliStatus.authLoggedIn && ( + {canOpenExtensions && (