From 57f546bab0ddf3a6126cbe1591a3264b161b0560 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:16:38 +0300 Subject: [PATCH 01/23] fix(extensions): scope plugin operation state by install scope --- .../extensions/plugins/PluginCard.tsx | 6 +- .../extensions/plugins/PluginDetailDialog.tsx | 11 ++- src/renderer/store/slices/extensionsSlice.ts | 94 +++++++++++-------- src/shared/utils/extensionNormalizers.ts | 7 ++ test/renderer/store/extensionsSlice.test.ts | 90 ++++++++++++------ .../shared/utils/extensionNormalizers.test.ts | 9 ++ 6 files changed, 145 insertions(+), 72 deletions(-) diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx index 24ca2d87..deef088f 100644 --- a/src/renderer/components/extensions/plugins/PluginCard.tsx +++ b/src/renderer/components/extensions/plugins/PluginCard.tsx @@ -7,6 +7,7 @@ import { useStore } from '@renderer/store'; import { getInstallationSummaryLabel, getCapabilityLabel, + getPluginOperationKey, hasInstallationInScope, inferCapabilities, normalizeCategory, @@ -27,10 +28,11 @@ interface PluginCardProps { export const PluginCard = ({ plugin, index, onClick }: PluginCardProps): React.JSX.Element => { const capabilities = inferCapabilities(plugin); const category = normalizeCategory(plugin.category); - const installProgress = useStore((s) => s.pluginInstallProgress[plugin.pluginId] ?? 'idle'); + const operationKey = getPluginOperationKey(plugin.pluginId, 'user'); + const installProgress = useStore((s) => s.pluginInstallProgress[operationKey] ?? 'idle'); const installPlugin = useStore((s) => s.installPlugin); const uninstallPlugin = useStore((s) => s.uninstallPlugin); - const installError = useStore((s) => s.installErrors[plugin.pluginId]); + const installError = useStore((s) => s.installErrors[operationKey]); const isUserInstalled = hasInstallationInScope(plugin.installations, 'user'); const installSummaryLabel = getInstallationSummaryLabel(plugin.installations); const baseStriped = index % 2 === 0; diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx index 8d6e05ac..732e9ca6 100644 --- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx +++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx @@ -27,6 +27,7 @@ import { useStore } from '@renderer/store'; import { getInstallationSummaryLabel, getCapabilityLabel, + getPluginOperationKey, hasInstallationInScope, inferCapabilities, normalizeCategory, @@ -74,10 +75,6 @@ export const PluginDetailDialog = ({ pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); - const installProgress = useStore( - (s) => (plugin ? s.pluginInstallProgress[plugin.pluginId] : undefined) ?? 'idle' - ); - const installError = useStore((s) => (plugin ? s.installErrors[plugin.pluginId] : undefined)); const [scope, setScope] = useState('user'); const projectScopeAvailable = Boolean(pluginCatalogProjectPath); @@ -100,6 +97,12 @@ export const PluginDetailDialog = ({ } }, [projectScopeAvailable, scope]); + const operationKey = plugin ? getPluginOperationKey(plugin.pluginId, scope) : null; + const installProgress = useStore( + (s) => (operationKey ? s.pluginInstallProgress[operationKey] : undefined) ?? 'idle' + ); + const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined)); + if (!plugin) return <>; const capabilities = inferCapabilities(plugin); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 7932eeeb..fdea0a79 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 { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -150,6 +151,10 @@ 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 clearPluginOperationState( pluginIds: Set, pluginInstallProgress: Record, @@ -166,8 +171,10 @@ function clearPluginOperationState( const nextInstallErrors = { ...installErrors }; for (const pluginId of pluginIds) { - delete nextPluginInstallProgress[pluginId]; - delete nextInstallErrors[pluginId]; + for (const operationKey of buildPluginOperationKeys(pluginId)) { + delete nextPluginInstallProgress[operationKey]; + delete nextInstallErrors[operationKey]; + } } return { @@ -176,40 +183,42 @@ function clearPluginOperationState( }; } -function clearPluginSuccessResetTimer(pluginId: string): void { - const timer = pluginSuccessResetTimers.get(pluginId); +function clearPluginSuccessResetTimer(operationKey: string): void { + const timer = pluginSuccessResetTimers.get(operationKey); if (!timer) { return; } clearTimeout(timer); - pluginSuccessResetTimers.delete(pluginId); + pluginSuccessResetTimers.delete(operationKey); } function clearPluginSuccessResetTimers(pluginIds: Set): void { for (const pluginId of pluginIds) { - clearPluginSuccessResetTimer(pluginId); + for (const operationKey of buildPluginOperationKeys(pluginId)) { + clearPluginSuccessResetTimer(operationKey); + } } } function schedulePluginSuccessReset( - pluginId: string, + operationKey: string, set: Parameters>[0] ): void { - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); const timer = setTimeout(() => { - pluginSuccessResetTimers.delete(pluginId); + pluginSuccessResetTimers.delete(operationKey); set((prev) => { - if (prev.pluginInstallProgress[pluginId] !== 'success') { + if (prev.pluginInstallProgress[operationKey] !== 'success') { return {}; } return { - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'idle' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'idle' }, }; }); }, SUCCESS_DISPLAY_MS); - pluginSuccessResetTimers.set(pluginId, timer); + pluginSuccessResetTimers.set(operationKey, timer); } function getSkillsCatalogKey(projectPath?: string): string { @@ -226,6 +235,7 @@ 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, @@ -691,6 +701,7 @@ export const createExtensionsSlice: StateCreator ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [request.pluginId]: preflightError }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: preflightError }, })); return; } - clearPluginSuccessResetTimer(request.pluginId); + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'pending' }, - installErrors: { ...prev.installErrors, [request.pluginId]: '' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, + installErrors: { ...prev.installErrors, [operationKey]: '' }, })); try { const result = await api.plugins.install(effectiveRequest); if (result.state === 'error') { set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [request.pluginId]: result.error ?? 'Install failed', + [operationKey]: result.error ?? 'Install failed', }, })); return; } set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'success' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' }, })); // Refresh catalog to pick up new installed state void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); - schedulePluginSuccessReset(request.pluginId, set); + schedulePluginSuccessReset(operationKey, set); } catch (err) { - clearPluginSuccessResetTimer(request.pluginId); + clearPluginSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [request.pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [request.pluginId]: message }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, @@ -769,48 +780,53 @@ export const createExtensionsSlice: StateCreator { if (!api.plugins) return; + const effectiveScope = scope ?? 'user'; + const operationKey = getPluginOperationKey(pluginId, effectiveScope); const effectiveProjectPath = - scope && scope !== 'user' + effectiveScope !== 'user' ? (projectPath ?? get().pluginCatalogProjectPath ?? undefined) : projectPath; - if (scope && scope !== 'user' && !effectiveProjectPath) { - clearPluginSuccessResetTimer(pluginId); + if (effectiveScope !== 'user' && !effectiveProjectPath) { + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: PROJECT_SCOPE_REQUIRED_MESSAGE }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: PROJECT_SCOPE_REQUIRED_MESSAGE }, })); return; } - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'pending' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, })); try { const result = await api.plugins.uninstall(pluginId, scope, effectiveProjectPath); if (result.state === 'error') { set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: result.error ?? 'Uninstall failed' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { + ...prev.installErrors, + [operationKey]: result.error ?? 'Uninstall failed', + }, })); return; } set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'success' }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'success' }, })); // Refresh catalog void get().fetchPluginCatalog(get().pluginCatalogProjectPath ?? undefined, true); - schedulePluginSuccessReset(pluginId, set); + schedulePluginSuccessReset(operationKey, set); } catch (err) { - clearPluginSuccessResetTimer(pluginId); + clearPluginSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ - pluginInstallProgress: { ...prev.pluginInstallProgress, [pluginId]: 'error' }, - installErrors: { ...prev.installErrors, [pluginId]: message }, + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 7684925a..a5c6420e 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -100,6 +100,13 @@ export function buildPluginId(pluginName: string, marketplaceName: string): stri return `${pluginName}@${marketplaceName}`; } +/** + * Namespaced operation-state key for plugin install/uninstall UI state. + */ +export function getPluginOperationKey(pluginId: string, scope: InstallScope): string { + return `plugin:${pluginId}:${scope}`; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 058b32d6..8e2a0c8b 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -40,6 +40,7 @@ vi.mock('../../../src/renderer/api', () => ({ })); import { api } from '../../../src/renderer/api'; +import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers'; import type { EnrichedPlugin, @@ -133,6 +134,9 @@ const makeReadyCliStatus = () => ({ providers: [], }); +const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => + getPluginOperationKey(pluginId, scope); + describe('extensionsSlice', () => { let store: TestStore; @@ -187,10 +191,10 @@ describe('extensionsSlice', () => { pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { - 'project-a@m': 'error', + [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { - 'project-a@m': 'Install failed', + [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); @@ -201,8 +205,10 @@ describe('extensionsSlice', () => { await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalogProjectPath).toBe('/tmp/project-b'); - expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); - expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect( + store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], + ).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); @@ -266,10 +272,10 @@ describe('extensionsSlice', () => { pluginCatalog: [makePlugin({ pluginId: 'project-a@m' })], pluginCatalogProjectPath: '/tmp/project-a', pluginInstallProgress: { - 'project-a@m': 'error', + [pluginOperationKey('project-a@m', 'project')]: 'error', }, installErrors: { - 'project-a@m': 'Install failed', + [pluginOperationKey('project-a@m', 'project')]: 'Install failed', 'mcp-server': 'Keep me', }, }); @@ -278,8 +284,10 @@ describe('extensionsSlice', () => { await store.getState().fetchPluginCatalog('/tmp/project-b'); expect(store.getState().pluginCatalog).toEqual([]); - expect(store.getState().pluginInstallProgress['project-a@m']).toBeUndefined(); - expect(store.getState().installErrors['project-a@m']).toBeUndefined(); + expect( + store.getState().pluginInstallProgress[pluginOperationKey('project-a@m', 'project')], + ).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('project-a@m', 'project')]).toBeUndefined(); expect(store.getState().installErrors['mcp-server']).toBe('Keep me'); }); }); @@ -448,10 +456,10 @@ describe('extensionsSlice', () => { const promise = store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); // During execution, should be pending - expect(store.getState().pluginInstallProgress['test@m']).toBe('pending'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('sets progress to error on failure', async () => { @@ -463,7 +471,7 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'fail@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['fail@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('fail@m')]).toBe('error'); }); it('fills missing projectPath from the active Extensions project context', async () => { @@ -488,8 +496,12 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'project@m', scope: 'project' }); expect(api.plugins!.install).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); - expect(store.getState().installErrors['project@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( + 'active project', + ); }); it('fills missing projectPath for local scope from the active Extensions project context', async () => { @@ -514,8 +526,24 @@ describe('extensionsSlice', () => { await store.getState().installPlugin({ pluginId: 'local@m', scope: 'local' }); expect(api.plugins!.install).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); - expect(store.getState().installErrors['local@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( + 'active project', + ); + }); + + it('keeps user-scope state isolated from local-scope failures', async () => { + store.setState({ cliStatus: makeReadyCliStatus(), pluginCatalogProjectPath: null }); + + await store.getState().installPlugin({ pluginId: 'shared@m', scope: 'local' }); + + expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().pluginInstallProgress[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); + expect(store.getState().installErrors[pluginOperationKey('shared@m', 'user')]).toBeUndefined(); }); it('clears older success reset timers before a new operation on the same plugin', async () => { @@ -527,14 +555,14 @@ describe('extensionsSlice', () => { .mockResolvedValueOnce({ state: 'error', error: 'second failure' }); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().installPlugin({ pluginId: 'test@m', scope: 'user' }); - expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().pluginInstallProgress['test@m']).toBe('error'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('error'); }); }); @@ -546,10 +574,10 @@ describe('extensionsSlice', () => { const promise = store.getState().uninstallPlugin('test@m', 'user'); - expect(store.getState().pluginInstallProgress['test@m']).toBe('pending'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('pending'); await promise; - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); }); it('fills missing projectPath from the active Extensions project context', async () => { @@ -567,8 +595,12 @@ describe('extensionsSlice', () => { await store.getState().uninstallPlugin('project@m', 'project'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['project@m']).toBe('error'); - expect(store.getState().installErrors['project@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('project@m', 'project')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('project@m', 'project')]).toContain( + 'active project', + ); }); it('fills missing projectPath for local uninstall from the active Extensions project context', async () => { @@ -586,8 +618,12 @@ describe('extensionsSlice', () => { await store.getState().uninstallPlugin('local@m', 'local'); expect(api.plugins!.uninstall).not.toHaveBeenCalled(); - expect(store.getState().pluginInstallProgress['local@m']).toBe('error'); - expect(store.getState().installErrors['local@m']).toContain('active project'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('local@m', 'local')]).toBe( + 'error', + ); + expect(store.getState().installErrors[pluginOperationKey('local@m', 'local')]).toContain( + 'active project', + ); }); it('does not restore idle state after project switch clears a pending success timer', async () => { @@ -602,14 +638,14 @@ describe('extensionsSlice', () => { (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); await store.getState().uninstallPlugin('test@m', 'user'); - expect(store.getState().pluginInstallProgress['test@m']).toBe('success'); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBe('success'); await store.getState().fetchPluginCatalog('/tmp/project-b'); - expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().pluginInstallProgress['test@m']).toBeUndefined(); + expect(store.getState().pluginInstallProgress[pluginOperationKey('test@m')]).toBeUndefined(); }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 7905fd99..427a22bb 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -8,6 +8,7 @@ import { getExtensionActionDisableReason, getCapabilityLabel, getInstallationSummaryLabel, + getPluginOperationKey, getPrimaryCapabilityLabel, hasInstallationInScope, inferCapabilities, @@ -152,6 +153,14 @@ 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', + ); + }); +}); + describe('hasInstallationInScope', () => { it('returns true when the selected scope exists', () => { expect( From 743dbec36d2ebaa2de785410e41ef8d843bffe51 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:17:41 +0300 Subject: [PATCH 02/23] fix(extensions): surface cli healthcheck failures in action tooltips --- src/shared/utils/extensionNormalizers.ts | 8 ++++++- .../shared/utils/extensionNormalizers.test.ts | 21 ++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index a5c6420e..ecff2758 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -149,7 +149,10 @@ export function getInstallationSummaryLabel( */ export function getExtensionActionDisableReason(options: { isInstalled: boolean; - cliStatus: Pick | null; + cliStatus: Pick< + CliInstallationStatus, + 'installed' | 'authLoggedIn' | 'binaryPath' | 'launchError' + > | null; cliStatusLoading: boolean; }): string | null { const { isInstalled, cliStatus, cliStatusLoading } = options; @@ -162,6 +165,9 @@ export function getExtensionActionDisableReason(options: { } if (cliStatus.installed === false) { + if (cliStatus.binaryPath && cliStatus.launchError) { + return 'Claude CLI was found but failed to start. Open the Dashboard to repair or reinstall it.'; + } return 'Claude CLI required. Install it from the Dashboard.'; } diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 427a22bb..b78f1bdc 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -207,7 +207,7 @@ describe('getExtensionActionDisableReason', () => { expect( getExtensionActionDisableReason({ isInstalled: false, - cliStatus: { installed: true, authLoggedIn: false }, + cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null }, cliStatusLoading: false, }), ).toContain('not signed in'); @@ -217,7 +217,7 @@ describe('getExtensionActionDisableReason', () => { expect( getExtensionActionDisableReason({ isInstalled: true, - cliStatus: { installed: true, authLoggedIn: false }, + cliStatus: { installed: true, authLoggedIn: false, binaryPath: null, launchError: null }, cliStatusLoading: false, }), ).toBeNull(); @@ -227,11 +227,26 @@ describe('getExtensionActionDisableReason', () => { expect( getExtensionActionDisableReason({ isInstalled: true, - cliStatus: { installed: false, authLoggedIn: false }, + cliStatus: { installed: false, authLoggedIn: false, binaryPath: null, launchError: null }, cliStatusLoading: false, }), ).toContain('Claude CLI required'); }); + + it('surfaces startup health-check failures separately from missing CLI', () => { + expect( + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus: { + installed: false, + authLoggedIn: false, + binaryPath: '/usr/local/bin/claude', + launchError: 'spawn EACCES', + }, + cliStatusLoading: false, + }), + ).toContain('failed to start'); + }); }); describe('sanitizeMcpServerName', () => { From 0d19e5174fb0f7f124a5c8896a96d89829678784 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:22:05 +0300 Subject: [PATCH 03/23] fix(extensions): use tab project context for plugin actions --- .../extensions/ExtensionStoreView.tsx | 1 + .../extensions/plugins/PluginDetailDialog.tsx | 20 +- .../extensions/plugins/PluginsPanel.tsx | 3 + .../plugins/PluginDetailDialog.test.ts | 269 ++++++++++++++++++ 4 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 7b736687..84648b44 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -323,6 +323,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { void; + projectPath: string | null; } const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [ @@ -57,27 +58,20 @@ export const PluginDetailDialog = ({ plugin, open, onClose, + projectPath, }: PluginDetailDialogProps): React.JSX.Element => { - const { - fetchPluginReadme, - readmes, - readmeLoading, - installPlugin, - uninstallPlugin, - pluginCatalogProjectPath, - } = useStore( + const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore( useShallow((s) => ({ fetchPluginReadme: s.fetchPluginReadme, readmes: s.pluginReadmes, readmeLoading: s.pluginReadmeLoading, installPlugin: s.installPlugin, uninstallPlugin: s.uninstallPlugin, - pluginCatalogProjectPath: s.pluginCatalogProjectPath, })) ); const [scope, setScope] = useState('user'); - const projectScopeAvailable = Boolean(pluginCatalogProjectPath); + const projectScopeAvailable = Boolean(projectPath); useEffect(() => { if (plugin && open) { @@ -205,16 +199,14 @@ export const PluginDetailDialog = ({ installPlugin({ pluginId: plugin.pluginId, scope, - ...(scope !== 'user' && pluginCatalogProjectPath - ? { projectPath: pluginCatalogProjectPath } - : {}), + ...(scope !== 'user' && projectPath ? { projectPath } : {}), }) } onUninstall={() => uninstallPlugin( plugin.pluginId, scope, - scope !== 'user' ? (pluginCatalogProjectPath ?? undefined) : undefined + scope !== 'user' ? (projectPath ?? undefined) : undefined ) } size="default" diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx index 5480b7c8..4fe38a2e 100644 --- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx +++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx @@ -35,6 +35,7 @@ import type { } from '@shared/types/extensions'; interface PluginsPanelProps { + projectPath: string | null; pluginFilters: PluginFilters; pluginSort: { field: PluginSortField; order: 'asc' | 'desc' }; selectedPluginId: string | null; @@ -111,6 +112,7 @@ function selectFilteredPlugins( } export const PluginsPanel = ({ + projectPath, pluginFilters, pluginSort, selectedPluginId, @@ -395,6 +397,7 @@ export const PluginsPanel = ({ plugin={selectedPlugin} open={selectedPluginId !== null} onClose={() => setSelectedPluginId(null)} + projectPath={projectPath} /> ); diff --git a/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts new file mode 100644 index 00000000..753761f8 --- /dev/null +++ b/test/renderer/components/extensions/plugins/PluginDetailDialog.test.ts @@ -0,0 +1,269 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { EnrichedPlugin } from '@shared/types/extensions'; + +interface StoreState { + fetchPluginReadme: ReturnType; + pluginReadmes: Record; + pluginReadmeLoading: Record; + installPlugin: ReturnType; + uninstallPlugin: ReturnType; + pluginCatalogProjectPath: string | null; + pluginInstallProgress: Record; + installErrors: Record; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + }, +})); + +vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({ + MarkdownViewer: ({ content }: { content: string }) => + React.createElement('div', { 'data-testid': 'markdown' }, content), +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + }>) => + React.createElement( + 'button', + { + type, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', { 'data-testid': 'dialog' }, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => + React.createElement('div', { 'data-testid': 'dialog-content' }, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'scope-select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement( + 'option', + { + value, + disabled, + }, + children + ), +})); + +vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ + InstallButton: ({ + isInstalled, + onInstall, + onUninstall, + }: { + isInstalled: boolean; + onInstall: () => void; + onUninstall: () => void; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'install-button', + onClick: () => (isInstalled ? onUninstall() : onInstall()), + }, + isInstalled ? 'Uninstall' : 'Install' + ), +})); + +vi.mock('@renderer/components/extensions/common/InstallCountBadge', () => ({ + InstallCountBadge: ({ count }: { count: number }) => + React.createElement('span', { 'data-testid': 'install-count' }, String(count)), +})); + +vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ + SourceBadge: ({ source }: { source: string }) => + React.createElement('span', { 'data-testid': 'source-badge' }, source), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + ExternalLink: Icon, + Loader2: Icon, + Mail: Icon, + }; +}); + +import { PluginDetailDialog } from '@renderer/components/extensions/plugins/PluginDetailDialog'; + +const makePlugin = (): EnrichedPlugin => ({ + pluginId: 'context7@claude-plugins-official', + marketplaceId: 'context7@claude-plugins-official', + qualifiedName: 'context7@claude-plugins-official', + name: 'Context7', + source: 'official', + description: 'Fresh docs in Claude', + category: 'docs', + author: { name: 'Anthropic', email: 'help@example.com' }, + version: '1.0.0', + homepage: 'https://example.com/context7', + tags: [], + hasLspServers: false, + hasMcpServers: true, + hasAgents: false, + hasCommands: false, + hasHooks: false, + isExternal: true, + installCount: 42, + isInstalled: false, + installations: [], +}); + +describe('PluginDetailDialog project context', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.fetchPluginReadme = vi.fn(); + storeState.pluginReadmes = {}; + storeState.pluginReadmeLoading = {}; + storeState.installPlugin = vi.fn(); + storeState.uninstallPlugin = vi.fn(); + storeState.pluginCatalogProjectPath = '/tmp/global-project'; + storeState.pluginInstallProgress = {}; + storeState.installErrors = {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uses the current tab project path for project-scope installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const plugin = makePlugin(); + + 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; + expect(scopeSelect).not.toBeNull(); + + await act(async () => { + scopeSelect.value = 'project'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(installButton).not.toBeNull(); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installPlugin).toHaveBeenCalledWith({ + pluginId: plugin.pluginId, + scope: 'project', + projectPath: '/tmp/tab-project', + }); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('disables project and local scopes when the current tab has no project', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(PluginDetailDialog, { + plugin: makePlugin(), + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect).not.toBeNull(); + expect(scopeSelect.querySelector('option[value="project"]')?.disabled).toBe(true); + expect(scopeSelect.querySelector('option[value="local"]')?.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 495d8514c1c11e2be55dce4483dcc64e39f1b5c3 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:23:46 +0300 Subject: [PATCH 04/23] fix(extensions): clear detail selections on sub-tab switch --- src/renderer/hooks/useExtensionsTabState.ts | 12 ++ .../hooks/useExtensionsTabState.test.ts | 122 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 test/renderer/hooks/useExtensionsTabState.test.ts diff --git a/src/renderer/hooks/useExtensionsTabState.ts b/src/renderer/hooks/useExtensionsTabState.ts index cffc4e9a..ce71077d 100644 --- a/src/renderer/hooks/useExtensionsTabState.ts +++ b/src/renderer/hooks/useExtensionsTabState.ts @@ -68,6 +68,18 @@ export function useExtensionsTabState() { }; }, []); + useEffect(() => { + if (activeSubTab !== 'plugins' && selectedPluginId !== null) { + setSelectedPluginId(null); + } + if (activeSubTab !== 'mcp-servers' && selectedMcpServerId !== null) { + setSelectedMcpServerId(null); + } + if (activeSubTab !== 'skills' && selectedSkillId !== null) { + setSelectedSkillId(null); + } + }, [activeSubTab, selectedMcpServerId, selectedPluginId, selectedSkillId]); + const mcpSearch = useCallback((query: string) => { setMcpSearchQuery(query); diff --git a/test/renderer/hooks/useExtensionsTabState.test.ts b/test/renderer/hooks/useExtensionsTabState.test.ts new file mode 100644 index 00000000..52ae6fbb --- /dev/null +++ b/test/renderer/hooks/useExtensionsTabState.test.ts @@ -0,0 +1,122 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useExtensionsTabState } from '../../../src/renderer/hooks/useExtensionsTabState'; + +type ExtensionsTabState = ReturnType; + +let capturedState: ExtensionsTabState | null = null; + +vi.mock('@renderer/api', () => ({ + api: { + mcpRegistry: null, + }, +})); + +function Harness(): null { + capturedState = useExtensionsTabState(); + return null; +} + +describe('useExtensionsTabState', () => { + afterEach(() => { + capturedState = null; + document.body.innerHTML = ''; + }); + + it('clears selected plugin when leaving the plugins sub-tab', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + capturedState?.setSelectedPluginId('context7@claude-plugins-official'); + await Promise.resolve(); + }); + expect(capturedState?.selectedPluginId).toBe('context7@claude-plugins-official'); + + await act(async () => { + capturedState?.setActiveSubTab('mcp-servers'); + await Promise.resolve(); + }); + expect(capturedState?.selectedPluginId).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears selected MCP server when leaving the MCP sub-tab', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + capturedState?.setActiveSubTab('mcp-servers'); + await Promise.resolve(); + }); + await act(async () => { + capturedState?.setSelectedMcpServerId('server-1'); + await Promise.resolve(); + }); + expect(capturedState?.selectedMcpServerId).toBe('server-1'); + + await act(async () => { + capturedState?.setActiveSubTab('skills'); + await Promise.resolve(); + }); + expect(capturedState?.selectedMcpServerId).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('clears selected skill when leaving the skills sub-tab', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + capturedState?.setActiveSubTab('skills'); + await Promise.resolve(); + }); + await act(async () => { + capturedState?.setSelectedSkillId('skill-1'); + await Promise.resolve(); + }); + expect(capturedState?.selectedSkillId).toBe('skill-1'); + + await act(async () => { + capturedState?.setActiveSubTab('api-keys'); + await Promise.resolve(); + }); + expect(capturedState?.selectedSkillId).toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 113f9105fbb73170881f060529907560ef05df71 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:26:03 +0300 Subject: [PATCH 05/23] fix(extensions): ignore stale MCP search responses --- src/renderer/hooks/useExtensionsTabState.ts | 13 +- .../hooks/useExtensionsTabState.test.ts | 131 +++++++++++++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/src/renderer/hooks/useExtensionsTabState.ts b/src/renderer/hooks/useExtensionsTabState.ts index ce71077d..6cb9375e 100644 --- a/src/renderer/hooks/useExtensionsTabState.ts +++ b/src/renderer/hooks/useExtensionsTabState.ts @@ -58,6 +58,7 @@ export function useExtensionsTabState() { // ── Debounced MCP search ── const searchTimerRef = useRef | null>(null); + const mcpSearchRequestSeqRef = useRef(0); // Cleanup timer on unmount useEffect(() => { @@ -65,6 +66,7 @@ export function useExtensionsTabState() { if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); } + mcpSearchRequestSeqRef.current += 1; }; }, []); @@ -82,6 +84,7 @@ export function useExtensionsTabState() { const mcpSearch = useCallback((query: string) => { setMcpSearchQuery(query); + const requestId = ++mcpSearchRequestSeqRef.current; if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); @@ -98,17 +101,25 @@ export function useExtensionsTabState() { searchTimerRef.current = setTimeout(() => { if (!api.mcpRegistry) { - setMcpSearchLoading(false); + if (mcpSearchRequestSeqRef.current === requestId) { + setMcpSearchLoading(false); + } return; } void api.mcpRegistry.search(query).then( (result: McpSearchResult) => { + if (mcpSearchRequestSeqRef.current !== requestId) { + return; + } setMcpSearchResults(result.servers); setMcpSearchWarnings(result.warnings); setMcpSearchLoading(false); }, () => { + if (mcpSearchRequestSeqRef.current !== requestId) { + return; + } setMcpSearchLoading(false); setMcpSearchWarnings(['Search failed']); } diff --git a/test/renderer/hooks/useExtensionsTabState.test.ts b/test/renderer/hooks/useExtensionsTabState.test.ts index 52ae6fbb..340d2849 100644 --- a/test/renderer/hooks/useExtensionsTabState.test.ts +++ b/test/renderer/hooks/useExtensionsTabState.test.ts @@ -1,16 +1,21 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useExtensionsTabState } from '../../../src/renderer/hooks/useExtensionsTabState'; +import type { McpCatalogItem } from '@shared/types/extensions'; + type ExtensionsTabState = ReturnType; let capturedState: ExtensionsTabState | null = null; +const mcpSearchMock = vi.fn(); vi.mock('@renderer/api', () => ({ api: { - mcpRegistry: null, + mcpRegistry: { + search: (...args: unknown[]) => mcpSearchMock(...args), + }, }, })); @@ -19,10 +24,39 @@ function Harness(): null { return null; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function makeMcpServer(id: string): McpCatalogItem { + return { + id, + name: id, + description: `${id} description`, + source: 'official', + installSpec: null, + envVars: [], + tools: [], + requiresAuth: false, + }; +} + describe('useExtensionsTabState', () => { + beforeEach(() => { + mcpSearchMock.mockReset(); + mcpSearchMock.mockResolvedValue({ servers: [], warnings: [] }); + }); + afterEach(() => { capturedState = null; document.body.innerHTML = ''; + vi.useRealTimers(); }); it('clears selected plugin when leaving the plugins sub-tab', async () => { @@ -119,4 +153,97 @@ describe('useExtensionsTabState', () => { await Promise.resolve(); }); }); + + it('ignores stale MCP search responses that resolve out of order', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + const first = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + const second = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + + mcpSearchMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + capturedState?.mcpSearch('first'); + await vi.advanceTimersByTimeAsync(300); + }); + + await act(async () => { + capturedState?.mcpSearch('second'); + await vi.advanceTimersByTimeAsync(300); + }); + + await act(async () => { + second.resolve({ servers: [makeMcpServer('second-result')], warnings: ['new warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']); + expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']); + + await act(async () => { + first.resolve({ servers: [makeMcpServer('first-result')], warnings: ['old warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults.map((server) => server.id)).toEqual(['second-result']); + expect(capturedState?.mcpSearchWarnings).toEqual(['new warning']); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('drops in-flight MCP search results after clearing the query', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + const pending = createDeferred<{ servers: McpCatalogItem[]; warnings: string[] }>(); + mcpSearchMock.mockReturnValueOnce(pending.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + await act(async () => { + capturedState?.mcpSearch('context7'); + await vi.advanceTimersByTimeAsync(300); + }); + expect(capturedState?.mcpSearchLoading).toBe(true); + + await act(async () => { + capturedState?.mcpSearch(''); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchQuery).toBe(''); + expect(capturedState?.mcpSearchResults).toEqual([]); + expect(capturedState?.mcpSearchWarnings).toEqual([]); + expect(capturedState?.mcpSearchLoading).toBe(false); + + await act(async () => { + pending.resolve({ servers: [makeMcpServer('stale-result')], warnings: ['stale warning'] }); + await Promise.resolve(); + }); + expect(capturedState?.mcpSearchResults).toEqual([]); + expect(capturedState?.mcpSearchWarnings).toEqual([]); + expect(capturedState?.mcpSearchLoading).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From acabe52ae79090ab987f4d2b359491abd93a8df4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:28:08 +0300 Subject: [PATCH 06/23] fix(extensions): stop MCP browse auto-retries after errors --- .../extensions/mcp/McpServersPanel.tsx | 4 +- .../extensions/mcp/McpServersPanel.test.ts | 211 ++++++++++++++++++ 2 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/McpServersPanel.test.ts diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 60ae4157..1a831f0f 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -107,10 +107,10 @@ export const McpServersPanel = ({ // Load initial browse data useEffect(() => { - if (browseCatalog.length === 0 && !browseLoading) { + if (browseCatalog.length === 0 && !browseLoading && !browseError) { void mcpBrowse(); } - }, [browseCatalog.length, browseLoading, mcpBrowse]); + }, [browseCatalog.length, browseError, browseLoading, mcpBrowse]); useEffect(() => { void runMcpDiagnostics(); diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts new file mode 100644 index 00000000..7c5956f7 --- /dev/null +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -0,0 +1,211 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + mcpBrowseCatalog: Array<{ + id: string; + name: string; + description: string; + source: 'official' | 'glama'; + installSpec: null; + envVars: []; + tools: []; + requiresAuth: boolean; + }>; + mcpBrowseNextCursor?: string; + mcpBrowseLoading: boolean; + mcpBrowseError: string | null; + mcpBrowse: ReturnType; + mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; + fetchMcpGitHubStars: ReturnType; + mcpDiagnostics: Record; + mcpDiagnosticsLoading: boolean; + mcpDiagnosticsError: string | null; + mcpDiagnosticsLastCheckedAt: number | null; + runMcpDiagnostics: ReturnType; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: unknown) => selector, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement('button', { type: 'button' }, children), + SelectValue: () => React.createElement('span', null, 'select-value'), + SelectContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + SelectItem: ({ children }: React.PropsWithChildren<{ value: string }>) => + React.createElement('button', { type: 'button' }, children), +})); + +vi.mock('@renderer/components/extensions/common/SearchInput', () => ({ + SearchInput: ({ + value, + onChange, + }: { + value: string; + onChange: (value: string) => void; + }) => + React.createElement('input', { + value, + onChange: (event: React.ChangeEvent) => onChange(event.target.value), + }), +})); + +vi.mock('@renderer/components/extensions/mcp/McpServerCard', () => ({ + McpServerCard: ({ server }: { server: { id: string; name: string } }) => + React.createElement('div', { 'data-testid': 'mcp-card', 'data-server-id': server.id }, server.name), +})); + +vi.mock('@renderer/components/extensions/mcp/McpServerDetailDialog', () => ({ + McpServerDetailDialog: ({ open }: { open: boolean }) => + open ? React.createElement('div', { 'data-testid': 'mcp-detail' }) : null, +})); + +vi.mock('@renderer/utils/formatters', () => ({ + formatRelativeTime: () => 'just now', +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + AlertTriangle: Icon, + RefreshCw: Icon, + Search: Icon, + Server: Icon, + }; +}); + +import { McpServersPanel } from '@renderer/components/extensions/mcp/McpServersPanel'; + +describe('McpServersPanel initial browse loading', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.mcpBrowseCatalog = []; + storeState.mcpBrowseNextCursor = undefined; + storeState.mcpBrowseLoading = false; + storeState.mcpBrowseError = null; + storeState.mcpBrowse = vi.fn(); + storeState.mcpInstalledServers = []; + storeState.fetchMcpGitHubStars = vi.fn(); + storeState.mcpDiagnostics = {}; + storeState.mcpDiagnosticsLoading = false; + storeState.mcpDiagnosticsError = null; + storeState.mcpDiagnosticsLastCheckedAt = null; + storeState.runMcpDiagnostics = vi.fn(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('loads the catalog once on first mount when browse state is empty', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + expect(storeState.runMcpDiagnostics).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not auto-retry browse after an error with an empty catalog', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + + storeState.mcpBrowseError = 'Registry unavailable'; + await act(async () => { + root.render( + React.createElement(McpServersPanel, { + mcpSearchQuery: '', + mcpSearch: vi.fn(), + mcpSearchResults: [], + mcpSearchLoading: false, + mcpSearchWarnings: [], + selectedMcpServerId: null, + setSelectedMcpServerId: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(storeState.mcpBrowse).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 0d6276ea0ba5d41abda1d81c4aefc88dfa7e7284 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:31:56 +0300 Subject: [PATCH 07/23] fix(extensions): honor installed MCP targets in manage actions --- .../extensions/mcp/McpServerCard.tsx | 26 +- .../extensions/mcp/McpServerDetailDialog.tsx | 20 +- .../extensions/mcp/McpServersPanel.tsx | 2 + .../extensions/mcp/McpServerCard.test.ts | 193 +++++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 222 ++++++++++++++++++ 5 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/McpServerCard.test.ts create mode 100644 test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index e2f04f87..d6586ff3 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -18,11 +18,16 @@ import { Github as GithubIcon } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; import { SourceBadge } from '../common/SourceBadge'; -import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions'; +import type { + InstalledMcpEntry, + McpCatalogItem, + McpServerDiagnostic, +} from '@shared/types/extensions'; interface McpServerCardProps { server: McpCatalogItem; isInstalled: boolean; + installedEntry?: InstalledMcpEntry | null; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; onClick: (serverId: string) => void; @@ -31,6 +36,7 @@ interface McpServerCardProps { export const McpServerCard = ({ server, isInstalled, + installedEntry, diagnostic, diagnosticsLoading, onClick, @@ -48,6 +54,14 @@ export const McpServerCard = ({ server.envVars.length > 0 || server.requiresAuth || (server.authHeaders?.length ?? 0) > 0; + const defaultServerName = sanitizeMcpServerName(server.name); + const supportsDirectInstalledAction = + isInstalled && + installedEntry?.scope === 'user' && + installedEntry.name === defaultServerName && + !requiresConfiguration; + const shouldShowDirectInstallButton = + canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); const [imgError, setImgError] = useState(false); const hasIcon = !!server.iconUrl && !imgError; const diagnosticBadgeClass = @@ -224,7 +238,7 @@ export const McpServerCard = ({ )} - {canAutoInstall && !requiresConfiguration && ( + {shouldShowDirectInstallButton && (
installMcpServer({ registryId: server.id, - serverName: sanitizeMcpServerName(server.name), + serverName: defaultServerName, scope: 'user', envValues: {}, headers: [], }) } - onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))} + onUninstall={() => + uninstallMcpServer(server.id, installedEntry?.name ?? defaultServerName, 'user') + } size="sm" errorMessage={installError} />
)} - {canAutoInstall && requiresConfiguration && ( + {canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 1a831f0f..c21b181c 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -374,6 +374,7 @@ export const McpServersPanel = ({ key={server.id} server={server} isInstalled={isServerInstalled(server)} + installedEntry={getInstalledEntry(server)} diagnostic={getDiagnostic(server)} diagnosticsLoading={mcpDiagnosticsLoading} onClick={setSelectedMcpServerId} @@ -400,6 +401,7 @@ export const McpServersPanel = ({ ; + installMcpServer: ReturnType; + uninstallMcpServer: ReturnType; + installErrors: Record; + mcpGitHubStars: Record; +} + +const storeState = {} as StoreState; + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + }, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + }: React.PropsWithChildren<{ + onClick?: (event: React.MouseEvent) => void; + type?: 'button' | 'submit' | 'reset'; + }>) => + React.createElement( + 'button', + { + type, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ + InstallButton: () => React.createElement('button', { type: 'button', 'data-testid': 'install-button' }, 'Install'), +})); + +vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ + SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source), +})); + +vi.mock('@renderer/utils/formatters', () => ({ + formatCompactNumber: (value: number) => String(value), + formatRelativeTime: () => 'recently', +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Clock: Icon, + Cloud: Icon, + Globe: Icon, + KeyRound: Icon, + Lock: Icon, + Monitor: Icon, + Star: Icon, + Tag: Icon, + Wrench: Icon, + Github: Icon, + }; +}); + +import { McpServerCard } from '@renderer/components/extensions/mcp/McpServerCard'; + +function makeServer(): McpCatalogItem { + return { + id: 'io.github.upstash/context7', + name: 'Context7', + description: 'Docs server', + source: 'official', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envVars: [], + tools: [], + requiresAuth: false, + authHeaders: [], + }; +} + +describe('McpServerCard direct action safety', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.mcpInstallProgress = {}; + storeState.installMcpServer = vi.fn(); + storeState.uninstallMcpServer = vi.fn(); + storeState.installErrors = {}; + storeState.mcpGitHubStars = {}; + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('falls back to Manage for installed entries that cannot be safely uninstalled directly', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClick = vi.fn(); + const installedEntry: InstalledMcpEntry = { + name: 'context7-local', + scope: 'local', + }; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + onClick, + }) + ); + await Promise.resolve(); + }); + + expect(host.querySelector('[data-testid="install-button"]')).toBeNull(); + const manageButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Manage' + ) as HTMLButtonElement | undefined; + expect(manageButton).toBeDefined(); + + await act(async () => { + manageButton?.click(); + await Promise.resolve(); + }); + + expect(onClick).toHaveBeenCalledWith('io.github.upstash/context7'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps direct actions for standard user-scope installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'user', + }; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + 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 new file mode 100644 index 00000000..5329963a --- /dev/null +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -0,0 +1,222 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; + +interface StoreState { + mcpInstallProgress: Record; + installMcpServer: ReturnType; + uninstallMcpServer: ReturnType; + installErrors: Record; + mcpGitHubStars: Record; +} + +const storeState = {} as StoreState; +const lookupMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + openExternal: vi.fn(), + apiKeys: { + lookup: (...args: unknown[]) => lookupMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/badge', () => ({ + Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children), +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', null, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => + React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'scope-select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ children, value }: React.PropsWithChildren<{ value: string }>) => + React.createElement('option', { value }, children), +})); + +vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ + InstallButton: ({ + isInstalled, + onInstall, + onUninstall, + }: { + isInstalled: boolean; + onInstall: () => void; + onUninstall: () => void; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'install-button', + onClick: () => (isInstalled ? onUninstall() : onInstall()), + }, + isInstalled ? 'Uninstall' : 'Install' + ), +})); + +vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ + SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + ExternalLink: Icon, + Lock: Icon, + Plus: Icon, + Star: Icon, + Trash2: Icon, + Wrench: Icon, + }; +}); + +import { McpServerDetailDialog } from '@renderer/components/extensions/mcp/McpServerDetailDialog'; + +function makeServer(): McpCatalogItem { + return { + id: 'io.github.upstash/context7', + name: 'Context7', + description: 'Docs server', + source: 'official', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envVars: [], + tools: [], + requiresAuth: false, + authHeaders: [], + }; +} + +describe('McpServerDetailDialog installed entry handling', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.mcpInstallProgress = {}; + storeState.installMcpServer = vi.fn(); + storeState.uninstallMcpServer = vi.fn(); + storeState.installErrors = {}; + storeState.mcpGitHubStars = {}; + lookupMock.mockReset(); + lookupMock.mockResolvedValue([]); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('uninstalls using the real installed server name and scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7-local', + scope: 'local', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; + expect(serverNameInput).not.toBeNull(); + expect(serverNameInput.value).toBe('context7-local'); + expect(serverNameInput.disabled).toBe(true); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('local'); + + const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + uninstallButton.click(); + await Promise.resolve(); + }); + + expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( + 'io.github.upstash/context7', + 'context7-local', + 'local' + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 3f03bc720a4998f367d9eb540e8ca7e1abeea405 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:32:47 +0300 Subject: [PATCH 08/23] fix(extensions): dedupe MCP detail API key lookups --- .../extensions/mcp/McpServerDetailDialog.tsx | 32 ---------------- .../mcp/McpServerDetailDialog.test.ts | 38 +++++++++++++++++++ 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 7ddeb82c..256b4cc2 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -128,38 +128,6 @@ export const McpServerDetailDialog = ({ ); }, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps - // Auto-fill env vars from saved API keys - useEffect(() => { - if (!server || server.envVars.length === 0 || !api.apiKeys) return; - - const envVarNames = server.envVars.map((env) => env.name); - void api.apiKeys.lookup(envVarNames).then( - (results) => { - if (results.length === 0) return; - const filled = new Set(); - const updates: Record = {}; - for (const r of results) { - updates[r.envVarName] = r.value; - filled.add(r.envVarName); - } - setEnvValues((prev) => { - const next = { ...prev }; - for (const [k, v] of Object.entries(updates)) { - // Only auto-fill if the field is empty - if (!next[k]) { - next[k] = v; - } - } - return next; - }); - setAutoFilledFields(filled); - }, - () => { - // Silently ignore lookup failures - } - ); - }, [server?.id]); // eslint-disable-line react-hooks/exhaustive-deps - if (!server) return <>; const canAutoInstall = !!server.installSpec; diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 5329963a..8835a9dd 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -219,4 +219,42 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('looks up saved API keys only once per dialog open', 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.mockResolvedValue([ + { + envVarName: 'CONTEXT7_API_KEY', + value: 'secret', + }, + ]); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledTimes(1); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); From 0420428281f4771ecb332551655c74bf27bca51c Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:39:03 +0300 Subject: [PATCH 09/23] fix(extensions): support project-scoped mcp installs --- .../extensions/install/McpInstallService.ts | 18 ++ .../extensions/ExtensionStoreView.tsx | 2 + .../extensions/mcp/CustomMcpServerDialog.tsx | 19 +- .../extensions/mcp/McpServerDetailDialog.tsx | 35 ++- .../extensions/mcp/McpServersPanel.tsx | 3 + .../extensions/McpInstallService.test.ts | 41 ++++ .../mcp/CustomMcpServerDialog.test.ts | 206 ++++++++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 98 ++++++++- .../extensions/mcp/McpServersPanel.test.ts | 3 + 9 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 3c83c0fe..779e6178 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -59,6 +59,13 @@ export class McpInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project scope', + }; + } + // 3. Validate env var keys (prevent command injection) for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { @@ -212,6 +219,10 @@ export class McpInstallService { return { state: 'error', error: `Invalid scope: "${scope}".` }; } + if (scope === 'project' && !projectPath) { + return { state: 'error', error: 'projectPath is required for project scope' }; + } + for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { return { state: 'error', error: `Invalid env var name: "${key}".` }; @@ -319,6 +330,13 @@ export class McpInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project scope', + }; + } + if (projectPath && !path.isAbsolute(projectPath)) { return { state: 'error', diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 84648b44..7f1331c3 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -340,6 +340,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { { setCustomMcpDialogOpen(false)} + projectPath={projectPath} /> diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 35624aad..b096287e 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -37,14 +37,16 @@ const SERVER_NAME_RE = /^[\w.-]{1,100}$/; interface CustomMcpServerDialogProps { open: boolean; onClose: () => void; + projectPath: string | null; } type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user'; +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' }, ]; @@ -62,6 +64,7 @@ interface EnvEntry { export const CustomMcpServerDialog = ({ open, onClose, + projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); @@ -101,6 +104,12 @@ export const CustomMcpServerDialog = ({ } }, [open]); + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + // Auto-fill env vars from saved API keys useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; @@ -168,6 +177,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -197,6 +207,7 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && + !(scope === 'project' && !projectPath) && !installing; return ( @@ -372,7 +383,11 @@ export const CustomMcpServerDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 256b4cc2..391e8239 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -44,14 +44,16 @@ interface McpServerDetailDialogProps { installedEntry?: InstalledMcpEntry | null; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; + projectPath: string | null; open: boolean; onClose: () => void; } -type Scope = 'local' | 'user'; +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' }, ]; @@ -61,6 +63,7 @@ export const McpServerDetailDialog = ({ installedEntry, diagnostic, diagnosticsLoading, + projectPath, open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { @@ -100,11 +103,17 @@ export const McpServerDetailDialog = ({ })) ); setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(installedEntry?.scope === 'local' ? 'local' : 'user'); + setScope(installedEntry?.scope ?? 'user'); setImgError(false); setAutoFilledFields(new Set()); }, [installedEntry?.name, installedEntry?.scope, open, server?.id]); + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + // Auto-fill env values from saved API keys useEffect(() => { if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; @@ -144,9 +153,15 @@ export const McpServerDetailDialog = ({ const missingRequiredHeaders = headers.some( (header) => header.isRequired && !header.value.trim() ); - const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders; const uninstallServerName = installedEntry?.name ?? serverName; const uninstallScope = installedEntry?.scope ?? scope; + const scopeRequiresProjectPath = + (scope === 'project' || uninstallScope === 'project') && !projectPath; + const installDisabled = + !serverName.trim() || + missingRequiredEnvVars || + missingRequiredHeaders || + scopeRequiresProjectPath; const diagnosticBadgeClass = diagnostic?.status === 'connected' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' @@ -161,13 +176,19 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, envValues, headers, }); }; const handleUninstall = () => { - uninstallMcpServer(server.id, uninstallServerName, uninstallScope); + uninstallMcpServer( + server.id, + uninstallServerName, + uninstallScope, + uninstallScope === 'project' ? (projectPath ?? undefined) : undefined + ); }; const addHeader = () => { @@ -370,7 +391,11 @@ export const McpServerDetailDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index c21b181c..fcc3d6f1 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -55,6 +55,7 @@ function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatal } interface McpServersPanelProps { + projectPath: string | null; mcpSearchQuery: string; mcpSearch: (query: string) => void; mcpSearchResults: McpCatalogItem[]; @@ -65,6 +66,7 @@ interface McpServersPanelProps { } export const McpServersPanel = ({ + projectPath, mcpSearchQuery, mcpSearch, mcpSearchResults, @@ -404,6 +406,7 @@ export const McpServersPanel = ({ installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null} diagnostic={selectedServer ? getDiagnostic(selectedServer) : null} diagnosticsLoading={mcpDiagnosticsLoading} + projectPath={projectPath} open={selectedMcpServerId !== null} onClose={() => setSelectedMcpServerId(null)} /> diff --git a/test/main/services/extensions/McpInstallService.test.ts b/test/main/services/extensions/McpInstallService.test.ts index 40f1dc5a..1320d359 100644 --- a/test/main/services/extensions/McpInstallService.test.ts +++ b/test/main/services/extensions/McpInstallService.test.ts @@ -216,6 +216,20 @@ describe('McpInstallService', () => { expect(result.state).toBe('error'); expect(result.error).toContain('Manual setup required'); }); + + it('rejects project scope install without project path', async () => { + const result = await service.install({ + registryId: 'upstash/context7-mcp', + serverName: 'context7', + scope: 'project', + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── install: error masking ────────────────────────────────────────────────── @@ -259,6 +273,25 @@ describe('McpInstallService', () => { }); }); + describe('installCustom (validation)', () => { + it('rejects project scope custom install without project path', async () => { + const result = await service.installCustom({ + serverName: 'custom-context7', + scope: 'project', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + }); + // ── uninstall ─────────────────────────────────────────────────────────────── describe('uninstall', () => { @@ -293,6 +326,14 @@ describe('McpInstallService', () => { expect(mockExecCli).not.toHaveBeenCalled(); }); + it('rejects project scope uninstall without project path', async () => { + const result = await service.uninstall('context7', 'project'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + it('returns error on CLI failure', async () => { mockExecCli.mockRejectedValue(new Error('Not found')); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts new file mode 100644 index 00000000..9dd675ef --- /dev/null +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -0,0 +1,206 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + installCustomMcpServer: ReturnType; +} + +const storeState = {} as StoreState; +const lookupMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + apiKeys: { + lookup: (...args: unknown[]) => lookupMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', null, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => + React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'scope-select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Plus: Icon, + Server: Icon, + Trash2: Icon, + }; +}); + +import { CustomMcpServerDialog } from '@renderer/components/extensions/mcp/CustomMcpServerDialog'; + +function setNativeValue( + element: HTMLInputElement | HTMLSelectElement, + value: string, + eventName: 'input' | 'change' +): 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(eventName, { bubbles: true })); +} + +describe('CustomMcpServerDialog project scope', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); + lookupMock.mockReset(); + lookupMock.mockResolvedValue([]); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('disables project scope without an active project', 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: null, + }) + ); + await Promise.resolve(); + }); + + const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + + 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); + const root = createRoot(host); + const onClose = vi.fn(); + const projectPath = '/tmp/custom-mcp-project'; + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose, + projectPath, + }) + ); + 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, 'custom-context7', 'input'); + setNativeValue(packageInput, '@upstash/context7-mcp', 'input'); + setNativeValue(scopeSelect, 'project', 'change'); + await Promise.resolve(); + }); + + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Install' + ) as HTMLButtonElement; + expect(installButton.disabled).toBe(false); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installCustomMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'custom-context7', + scope: 'project', + projectPath, + }) + ); + expect(onClose).toHaveBeenCalledTimes(1); + + 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 8835a9dd..42d178b4 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -93,8 +93,12 @@ vi.mock('@renderer/components/ui/select', () => ({ SelectValue: () => null, SelectContent: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), - SelectItem: ({ children, value }: React.PropsWithChildren<{ value: string }>) => - React.createElement('option', { value }, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), })); vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ @@ -187,6 +191,7 @@ describe('McpServerDetailDialog installed entry handling', () => { installedEntry, diagnostic: null, diagnosticsLoading: false, + projectPath: '/tmp/project', open: true, onClose: vi.fn(), }) @@ -211,7 +216,8 @@ describe('McpServerDetailDialog installed entry handling', () => { expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( 'io.github.upstash/context7', 'context7-local', - 'local' + 'local', + undefined ); await act(async () => { @@ -241,6 +247,7 @@ describe('McpServerDetailDialog installed entry handling', () => { installedEntry: null, diagnostic: null, diagnosticsLoading: false, + projectPath: null, open: true, onClose: vi.fn(), }) @@ -257,4 +264,89 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('passes project path for project-scoped installs and uninstalls', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const projectPath = '/tmp/project-context7'; + const installedEntry: InstalledMcpEntry = { + name: 'context7-project', + scope: 'project', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('project'); + + const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + uninstallButton.click(); + await Promise.resolve(); + }); + + expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( + 'io.github.upstash/context7', + 'context7-project', + 'project', + projectPath + ); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const installScopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + installScopeSelect.value = 'project'; + installScopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'io.github.upstash/context7', + scope: 'project', + projectPath, + }) + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 7c5956f7..0d9451df 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -142,6 +142,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], @@ -171,6 +172,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], @@ -189,6 +191,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], From be8f4f45d219ef59c83eb607033f7581b560cbd4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:41:10 +0300 Subject: [PATCH 10/23] fix(extensions): scope mcp installed cache by project --- .../state/McpInstallationStateService.ts | 13 +-- .../McpInstallationStateService.test.ts | 98 +++++++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/main/services/extensions/McpInstallationStateService.test.ts diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index f947f190..6e5950ec 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -27,15 +27,16 @@ interface TimedCache { } export class McpInstallationStateService { - private cache: TimedCache | null = null; + private cache = new Map>(); /** * Get all installed MCP servers across user and project scopes. */ async getInstalled(projectPath?: string): Promise { - // Cache is project-path-dependent, so invalidate on path change - if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { - return this.cache.data; + const cacheKey = projectPath ?? '__user__'; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.data; } const entries: InstalledMcpEntry[] = []; @@ -50,7 +51,7 @@ export class McpInstallationStateService { entries.push(...projectEntries); } - this.cache = { data: entries, fetchedAt: Date.now() }; + this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } @@ -58,7 +59,7 @@ export class McpInstallationStateService { * Invalidate cache. Call after install/uninstall operations. */ invalidateCache(): void { - this.cache = null; + this.cache.clear(); } // ── Private ──────────────────────────────────────────────────────────── diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts new file mode 100644 index 00000000..9e65a2be --- /dev/null +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; + +import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService'; + +vi.mock('@main/utils/pathDecoder', () => ({ + getHomeDir: () => '/tmp/mock-home', +})); + +vi.mock('node:fs/promises'); + +describe('McpInstallationStateService', () => { + let service: McpInstallationStateService; + const mockedFs = vi.mocked(fs); + + beforeEach(() => { + service = new McpInstallationStateService(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getInstalled', () => { + it('caches results within TTL for the same project path', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + await service.getInstalled('/tmp/project-a'); + await service.getInstalled('/tmp/project-a'); + + expect(mockedFs.readFile).toHaveBeenCalledTimes(2); + }); + + it('caches results independently per project path', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-b/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-b-server': { command: 'uvx repo-b-mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const projectAEntries = await service.getInstalled('/tmp/project-a'); + const projectBEntries = await service.getInstalled('/tmp/project-b'); + + expect(projectAEntries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'repo-a-server', scope: 'project', transport: 'http' }, + ]); + expect(projectBEntries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'repo-b-server', scope: 'project', transport: 'stdio' }, + ]); + expect(mockedFs.readFile).toHaveBeenCalledTimes(4); + }); + }); +}); From 7418643dc91c82d07d29e365df85ff2ef519a1ee Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:45:34 +0300 Subject: [PATCH 11/23] fix(extensions): make mcp scope actions scope-aware --- .../extensions/mcp/McpServerCard.tsx | 24 +++++--- .../extensions/mcp/McpServerDetailDialog.tsx | 49 +++++++++++----- .../extensions/mcp/McpServersPanel.tsx | 24 ++++++-- src/shared/utils/extensionNormalizers.ts | 50 ++++++++++++++++ .../extensions/mcp/McpServerCard.test.ts | 34 +++++++++++ .../mcp/McpServerDetailDialog.test.ts | 58 +++++++++++++++++++ .../shared/utils/extensionNormalizers.test.ts | 42 +++++++++++++- 7 files changed, 254 insertions(+), 27 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index d6586ff3..aec1c0c3 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -11,7 +11,10 @@ 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 { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; +import { + getMcpInstallationSummaryLabel, + sanitizeMcpServerName, +} from '@shared/utils/extensionNormalizers'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; import { Github as GithubIcon } from 'lucide-react'; @@ -28,6 +31,7 @@ interface McpServerCardProps { server: McpCatalogItem; isInstalled: boolean; installedEntry?: InstalledMcpEntry | null; + installedEntries?: InstalledMcpEntry[]; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; onClick: (serverId: string) => void; @@ -37,6 +41,7 @@ export const McpServerCard = ({ server, isInstalled, installedEntry, + installedEntries = [], diagnostic, diagnosticsLoading, onClick, @@ -49,17 +54,22 @@ export const McpServerCard = ({ server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined ); const canAutoInstall = !!server.installSpec; + const normalizedInstalledEntries = installedEntries.length + ? installedEntries + : installedEntry + ? [installedEntry] + : []; const requiresConfiguration = server.installSpec?.type === 'http' || server.envVars.length > 0 || server.requiresAuth || (server.authHeaders?.length ?? 0) > 0; const defaultServerName = sanitizeMcpServerName(server.name); + const userInstallEntry = + normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null; + const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); const supportsDirectInstalledAction = - isInstalled && - installedEntry?.scope === 'user' && - installedEntry.name === defaultServerName && - !requiresConfiguration; + isInstalled && userInstallEntry?.name === defaultServerName && !requiresConfiguration; const shouldShowDirectInstallButton = canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); const [imgError, setImgError] = useState(false); @@ -117,7 +127,7 @@ export const McpServerCard = ({ className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400" variant="outline" > - Installed + {installSummaryLabel ?? 'Installed'} )} {isInstalled && diagnosticsLoading && !diagnostic && ( @@ -253,7 +263,7 @@ export const McpServerCard = ({ }) } onUninstall={() => - uninstallMcpServer(server.id, installedEntry?.name ?? defaultServerName, 'user') + uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user') } size="sm" errorMessage={installError} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 391e8239..91f3b2f8 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -25,7 +25,11 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; -import { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers'; +import { + getMcpInstallationSummaryLabel, + getPreferredMcpInstallationEntry, + sanitizeMcpServerName, +} from '@shared/utils/extensionNormalizers'; import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react'; import { InstallButton } from '../common/InstallButton'; @@ -42,6 +46,7 @@ interface McpServerDetailDialogProps { server: McpCatalogItem | null; isInstalled: boolean; installedEntry?: InstalledMcpEntry | null; + installedEntries?: InstalledMcpEntry[]; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; projectPath: string | null; @@ -61,6 +66,7 @@ export const McpServerDetailDialog = ({ server, isInstalled, installedEntry, + installedEntries = [], diagnostic, diagnosticsLoading, projectPath, @@ -83,6 +89,15 @@ export const McpServerDetailDialog = ({ const [headers, setHeaders] = useState([]); const [imgError, setImgError] = useState(false); const [autoFilledFields, setAutoFilledFields] = useState>(new Set()); + const normalizedInstalledEntries = installedEntries.length + ? installedEntries + : installedEntry + ? [installedEntry] + : []; + const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries); + const selectedInstalledEntry = + normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; + const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); // Initialize form when dialog opens or server changes useEffect(() => { @@ -102,11 +117,19 @@ export const McpServerDetailDialog = ({ locked: true, })) ); - setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(installedEntry?.scope ?? 'user'); + setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); + setScope(preferredInstalledEntry?.scope ?? 'user'); setImgError(false); setAutoFilledFields(new Set()); - }, [installedEntry?.name, installedEntry?.scope, open, server?.id]); + }, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]); + + useEffect(() => { + if (!server || !open) { + return; + } + + setServerName(selectedInstalledEntry?.name ?? sanitizeMcpServerName(server.name)); + }, [open, scope, selectedInstalledEntry?.name, server]); useEffect(() => { if (open && scope === 'project' && !projectPath) { @@ -153,10 +176,10 @@ export const McpServerDetailDialog = ({ const missingRequiredHeaders = headers.some( (header) => header.isRequired && !header.value.trim() ); - const uninstallServerName = installedEntry?.name ?? serverName; - const uninstallScope = installedEntry?.scope ?? scope; - const scopeRequiresProjectPath = - (scope === 'project' || uninstallScope === 'project') && !projectPath; + const isInstalledForScope = selectedInstalledEntry !== null; + const uninstallServerName = selectedInstalledEntry?.name ?? serverName; + const uninstallScope = selectedInstalledEntry?.scope ?? scope; + const scopeRequiresProjectPath = scope === 'project' && !projectPath; const installDisabled = !serverName.trim() || missingRequiredEnvVars || @@ -231,7 +254,7 @@ export const McpServerDetailDialog = ({ className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400" variant="outline" > - Installed + {installSummaryLabel ?? 'Installed'} )} {server.source !== 'official' && } @@ -325,7 +348,7 @@ export const McpServerDetailDialog = ({ does not describe them. If connection fails after install, check the provider docs. )} - {(isInstalled || diagnosticsLoading) && ( + {isInstalledForScope && (
Claude Status @@ -364,7 +387,7 @@ export const McpServerDetailDialog = ({ {canAutoInstall && (

- {isInstalled ? 'Manage Installation' : 'Install Server'} + {isInstalledForScope ? 'Manage Installation' : 'Install Server'}

{/* Server name */} @@ -378,7 +401,7 @@ export const McpServerDetailDialog = ({ onChange={(e) => setServerName(e.target.value)} placeholder="my-server" className="h-8 text-sm" - disabled={isInstalled} + disabled={isInstalledForScope} />
@@ -502,7 +525,7 @@ export const McpServerDetailDialog = ({
new Map(installedServers.map((entry) => [entry.name.toLowerCase(), entry] as const)), - [installedServers] - ); + const installedEntriesByName = useMemo(() => { + const entriesByName = new Map(); + for (const entry of installedServers) { + const key = entry.name.toLowerCase(); + entriesByName.set(key, [...(entriesByName.get(key) ?? []), entry]); + } + return entriesByName; + }, [installedServers]); /** Check if a catalog server is installed by comparing sanitized names */ const isServerInstalled = (server: McpCatalogItem): boolean => installedNames.has(sanitizeMcpServerName(server.name)); + const getInstalledEntries = (server: McpCatalogItem): InstalledMcpEntry[] => + installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? []; + const getInstalledEntry = (server: McpCatalogItem): InstalledMcpEntry | null => - installedEntriesByName.get(sanitizeMcpServerName(server.name)) ?? null; + getPreferredMcpInstallationEntry(getInstalledEntries(server)); const getDiagnostic = (server: McpCatalogItem): McpServerDiagnostic | null => { const installedEntry = getInstalledEntry(server); @@ -377,6 +387,7 @@ export const McpServersPanel = ({ server={server} isInstalled={isServerInstalled(server)} installedEntry={getInstalledEntry(server)} + installedEntries={getInstalledEntries(server)} diagnostic={getDiagnostic(server)} diagnosticsLoading={mcpDiagnosticsLoading} onClick={setSelectedMcpServerId} @@ -404,6 +415,7 @@ export const McpServersPanel = ({ server={selectedServer} isInstalled={selectedServer ? isServerInstalled(selectedServer) : false} installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null} + installedEntries={selectedServer ? getInstalledEntries(selectedServer) : []} diagnostic={selectedServer ? getDiagnostic(selectedServer) : null} diagnosticsLoading={mcpDiagnosticsLoading} projectPath={projectPath} diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index ecff2758..11c4e931 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -5,6 +5,7 @@ import type { CliInstallationStatus, InstallScope, + InstalledMcpEntry, InstalledPluginEntry, PluginCapability, PluginCatalogItem, @@ -144,6 +145,55 @@ export function getInstallationSummaryLabel( } } +const MCP_SCOPE_PRIORITY: Record = { + user: 0, + project: 1, + local: 2, +}; + +/** + * Pick a stable MCP installation entry when multiple scopes are installed. + * Prefer user scope because it is the only safe direct-card action target. + */ +export function getPreferredMcpInstallationEntry( + installations: InstalledMcpEntry[] +): InstalledMcpEntry | null { + if (installations.length === 0) { + return null; + } + + return [...installations].sort( + (left, right) => MCP_SCOPE_PRIORITY[left.scope] - MCP_SCOPE_PRIORITY[right.scope] + )[0]!; +} + +/** + * Build a concise install-status label for MCP badges. + */ +export function getMcpInstallationSummaryLabel( + installations: Pick[] +): string | null { + const scopes = Array.from(new Set(installations.map((installation) => installation.scope))); + if (scopes.length === 0) { + return null; + } + + if (scopes.length > 1) { + return `Installed in ${scopes.length} scopes`; + } + + switch (scopes[0]) { + case 'user': + return 'Installed globally'; + case 'project': + return 'Installed in project'; + case 'local': + return 'Installed locally'; + default: + return 'Installed'; + } +} + /** * Install actions require Claude auth, but uninstall only requires a working CLI. */ diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 7561f663..2e2a2be2 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -175,6 +175,7 @@ describe('McpServerCard direct action safety', () => { server: makeServer(), isInstalled: true, installedEntry, + installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, onClick: vi.fn(), @@ -190,4 +191,37 @@ describe('McpServerCard direct action safety', () => { await Promise.resolve(); }); }); + + it('keeps direct user action when the same server is installed in multiple scopes', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntries: InstalledMcpEntry[] = [ + { name: 'context7', scope: 'user' }, + { name: 'context7', scope: 'project' }, + ]; + + await act(async () => { + root.render( + React.createElement(McpServerCard, { + server: makeServer(), + isInstalled: true, + installedEntry: installedEntries[1], + installedEntries, + diagnostic: null, + diagnosticsLoading: false, + onClick: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Installed in 2 scopes'); + 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 42d178b4..a19aa6b7 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -281,6 +281,7 @@ describe('McpServerDetailDialog installed entry handling', () => { server: makeServer(), isInstalled: true, installedEntry, + installedEntries: [installedEntry], diagnostic: null, diagnosticsLoading: false, projectPath, @@ -313,6 +314,7 @@ describe('McpServerDetailDialog installed entry handling', () => { server: makeServer(), isInstalled: false, installedEntry: null, + installedEntries: [], diagnostic: null, diagnosticsLoading: false, projectPath, @@ -349,4 +351,60 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('uses selected scope instead of aggregated installed state', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'user', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + installedEntries: [installedEntry], + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + 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(); + }); + + const actionButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(actionButton.textContent).toBe('Install'); + + await act(async () => { + actionButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'io.github.upstash/context7', + scope: 'project', + projectPath: '/tmp/project', + }) + ); + expect(storeState.uninstallMcpServer).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index b78f1bdc..a558f227 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { PluginCatalogItem } from '@shared/types/extensions'; +import type { InstalledMcpEntry, PluginCatalogItem } from '@shared/types/extensions'; import { buildPluginId, @@ -8,6 +8,8 @@ import { getExtensionActionDisableReason, getCapabilityLabel, getInstallationSummaryLabel, + getMcpInstallationSummaryLabel, + getPreferredMcpInstallationEntry, getPluginOperationKey, getPrimaryCapabilityLabel, hasInstallationInScope, @@ -202,6 +204,44 @@ describe('getInstallationSummaryLabel', () => { }); }); +describe('getPreferredMcpInstallationEntry', () => { + it('returns null when there are no MCP installs', () => { + expect(getPreferredMcpInstallationEntry([])).toBeNull(); + }); + + it('prefers user scope over project and local', () => { + const installations: InstalledMcpEntry[] = [ + { name: 'context7', scope: 'project' }, + { name: 'context7', scope: 'user' }, + { name: 'context7', scope: 'local' }, + ]; + + expect(getPreferredMcpInstallationEntry(installations)).toEqual({ + name: 'context7', + scope: 'user', + }); + }); +}); + +describe('getMcpInstallationSummaryLabel', () => { + it('returns null when there are no MCP installations', () => { + expect(getMcpInstallationSummaryLabel([])).toBeNull(); + }); + + it('describes a single local MCP installation', () => { + expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally'); + }); + + it('summarizes multiple MCP scopes', () => { + expect( + getMcpInstallationSummaryLabel([ + { scope: 'user' }, + { scope: 'project' }, + ]) + ).toBe('Installed in 2 scopes'); + }); +}); + describe('getExtensionActionDisableReason', () => { it('requires auth only for install actions', () => { expect( From a3c5b7dca9ecd6a32605896a83f8b723ac7000cf Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:48:43 +0300 Subject: [PATCH 12/23] fix(extensions): honor local mcp scope precedence --- .../state/McpInstallationStateService.ts | 82 +++++++++++++------ .../extensions/mcp/McpServerCard.tsx | 5 +- src/shared/utils/extensionNormalizers.ts | 8 +- .../McpInstallationStateService.test.ts | 52 ++++++++++++ .../extensions/mcp/McpServerCard.test.ts | 5 +- .../mcp/McpServerDetailDialog.test.ts | 38 +++++++++ .../shared/utils/extensionNormalizers.test.ts | 6 +- 7 files changed, 162 insertions(+), 34 deletions(-) diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index 6e5950ec..a550237b 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -3,8 +3,8 @@ * * Sources: * - User scope: ~/.claude.json → mcpServers + * - Local scope: ~/.claude.json → projects[projectPath].mcpServers * - Project scope: .mcp.json in project root - * - Local scope: determined by Claude CLI (may also be in ~/.claude.json) * * Both files are managed by the Claude CLI. This service is read-only. */ @@ -30,7 +30,7 @@ export class McpInstallationStateService { private cache = new Map>(); /** - * Get all installed MCP servers across user and project scopes. + * Get all installed MCP servers across user, local, and project scopes. */ async getInstalled(projectPath?: string): Promise { const cacheKey = projectPath ?? '__user__'; @@ -40,15 +40,14 @@ export class McpInstallationStateService { } const entries: InstalledMcpEntry[] = []; + const claudeConfig = await this.readClaudeConfig(); // User scope: ~/.claude.json - const userEntries = await this.readUserMcpServers(); - entries.push(...userEntries); + entries.push(...this.readUserMcpServers(claudeConfig)); - // Project scope: .mcp.json if (projectPath) { - const projectEntries = await this.readProjectMcpServers(projectPath); - entries.push(...projectEntries); + entries.push(...this.readLocalMcpServers(claudeConfig, projectPath)); + entries.push(...(await this.readProjectMcpServers(projectPath))); } this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); @@ -64,9 +63,37 @@ export class McpInstallationStateService { // ── Private ──────────────────────────────────────────────────────────── - private async readUserMcpServers(): Promise { + private async readClaudeConfig(): Promise | null> { const configPath = path.join(getHomeDir(), '.claude.json'); - return this.readMcpServersFromFile(configPath, 'user'); + 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 { @@ -74,6 +101,27 @@ export class McpInstallationStateService { 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' @@ -81,21 +129,7 @@ export class McpInstallationStateService { try { const raw = await fs.readFile(filePath, 'utf-8'); const json = JSON.parse(raw) as Record; - const mcpServers = json.mcpServers as - | Record - | undefined; - - if (!mcpServers || typeof mcpServers !== 'object') { - 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 }; - }); + return this.readMcpServersFromConfig(json.mcpServers, scope); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return []; diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index aec1c0c3..c1e0d260 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -69,7 +69,10 @@ export const McpServerCard = ({ normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); const supportsDirectInstalledAction = - isInstalled && userInstallEntry?.name === defaultServerName && !requiresConfiguration; + isInstalled && + normalizedInstalledEntries.length === 1 && + userInstallEntry?.name === defaultServerName && + !requiresConfiguration; const shouldShowDirectInstallButton = canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction); const [imgError, setImgError] = useState(false); diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 11c4e931..9eb456c9 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -146,14 +146,14 @@ export function getInstallationSummaryLabel( } const MCP_SCOPE_PRIORITY: Record = { - user: 0, + local: 0, project: 1, - local: 2, + user: 2, }; /** - * Pick a stable MCP installation entry when multiple scopes are installed. - * Prefer user scope because it is the only safe direct-card action target. + * Pick the MCP installation entry that Claude will actually use. + * Scope precedence matches Claude Code: local > project > user. */ export function getPreferredMcpInstallationEntry( installations: InstalledMcpEntry[] diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts index 9e65a2be..61bfb566 100644 --- a/test/main/services/extensions/McpInstallationStateService.test.ts +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -23,6 +23,44 @@ describe('McpInstallationStateService', () => { }); describe('getInstalled', () => { + it('includes local scope from the current project entry in ~/.claude.json', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + projects: { + '/tmp/project-a': { + mcpServers: { + stripe: { url: 'https://mcp.stripe.com' }, + }, + }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + paypal: { url: 'https://mcp.paypal.com/mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const entries = await service.getInstalled('/tmp/project-a'); + + expect(entries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'stripe', scope: 'local', transport: 'http' }, + { name: 'paypal', scope: 'project', transport: 'http' }, + ]); + }); + it('caches results within TTL for the same project path', async () => { mockedFs.readFile.mockImplementation(async (filePath) => { const normalizedPath = String(filePath); @@ -59,6 +97,18 @@ describe('McpInstallationStateService', () => { mcpServers: { context7: { command: 'npx -y @upstash/context7-mcp' }, }, + projects: { + '/tmp/project-a': { + mcpServers: { + stripe: { url: 'https://mcp.stripe.com' }, + }, + }, + '/tmp/project-b': { + mcpServers: { + github: { command: 'uvx github-mcp' }, + }, + }, + }, }); } @@ -86,10 +136,12 @@ describe('McpInstallationStateService', () => { expect(projectAEntries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'stripe', scope: 'local', transport: 'http' }, { name: 'repo-a-server', scope: 'project', transport: 'http' }, ]); expect(projectBEntries).toEqual([ { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'github', scope: 'local', transport: 'stdio' }, { name: 'repo-b-server', scope: 'project', transport: 'stdio' }, ]); expect(mockedFs.readFile).toHaveBeenCalledTimes(4); diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 2e2a2be2..a122cd66 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -192,7 +192,7 @@ describe('McpServerCard direct action safety', () => { }); }); - it('keeps direct user action when the same server is installed in multiple scopes', async () => { + it('falls back to Manage when the same server is installed in multiple scopes', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -217,7 +217,8 @@ describe('McpServerCard direct action safety', () => { }); expect(host.textContent).toContain('Installed in 2 scopes'); - expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="install-button"]')).toBeNull(); + expect(host.textContent).toContain('Manage'); 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 a19aa6b7..356415e9 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -407,4 +407,42 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('defaults to the highest-precedence installed scope', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntries: InstalledMcpEntry[] = [ + { name: 'context7', scope: 'user' }, + { name: 'context7-shared', scope: 'project' }, + ]; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry: installedEntries[0], + installedEntries, + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + const serverNameInput = host.querySelector('#server-name') as HTMLInputElement; + + expect(scopeSelect.value).toBe('project'); + expect(serverNameInput.value).toBe('context7-shared'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index a558f227..6b4708fc 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -209,16 +209,16 @@ describe('getPreferredMcpInstallationEntry', () => { expect(getPreferredMcpInstallationEntry([])).toBeNull(); }); - it('prefers user scope over project and local', () => { + it('prefers local scope over project and user', () => { const installations: InstalledMcpEntry[] = [ - { name: 'context7', scope: 'project' }, { name: 'context7', scope: 'user' }, + { name: 'context7', scope: 'project' }, { name: 'context7', scope: 'local' }, ]; expect(getPreferredMcpInstallationEntry(installations)).toEqual({ name: 'context7', - scope: 'user', + scope: 'local', }); }); }); From 94291f50f0381b575783007bad56f2cb15ee3f8e Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:51:05 +0300 Subject: [PATCH 13/23] fix(extensions): require project context for local mcp scope --- .../extensions/install/McpInstallService.ts | 16 ++-- .../extensions/mcp/CustomMcpServerDialog.tsx | 8 +- .../extensions/mcp/McpServerDetailDialog.tsx | 10 +-- .../extensions/McpInstallService.test.ts | 40 +++++++++ .../mcp/CustomMcpServerDialog.test.ts | 58 +++++++++++- .../mcp/McpServerDetailDialog.test.ts | 90 ++++++++++++++++++- 6 files changed, 205 insertions(+), 17 deletions(-) diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 779e6178..687c904a 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -37,6 +37,10 @@ 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'; +} + export class McpInstallService { constructor(private readonly aggregator: McpCatalogAggregator) {} @@ -59,10 +63,10 @@ export class McpInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project scope', + error: `projectPath is required for ${scope} scope`, }; } @@ -219,8 +223,8 @@ export class McpInstallService { return { state: 'error', error: `Invalid scope: "${scope}".` }; } - if (scope === 'project' && !projectPath) { - return { state: 'error', error: 'projectPath is required for project scope' }; + if (scopeRequiresProjectPath(scope) && !projectPath) { + return { state: 'error', error: `projectPath is required for ${scope} scope` }; } for (const key of Object.keys(envValues)) { @@ -330,10 +334,10 @@ export class McpInstallService { }; } - if (scope === 'project' && !projectPath) { + if (scopeRequiresProjectPath(scope) && !projectPath) { return { state: 'error', - error: 'projectPath is required for project scope', + error: `projectPath is required for ${scope} scope`, }; } diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index b096287e..7cb8e740 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -105,7 +105,7 @@ export const CustomMcpServerDialog = ({ }, [open]); useEffect(() => { - if (open && scope === 'project' && !projectPath) { + if (open && scope !== 'user' && !projectPath) { setScope('user'); } }, [open, projectPath, scope]); @@ -177,7 +177,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, - projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, + projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -207,7 +207,7 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && - !(scope === 'project' && !projectPath) && + !(scope !== 'user' && !projectPath) && !installing; return ( @@ -386,7 +386,7 @@ export const CustomMcpServerDialog = ({ {opt.label} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 91f3b2f8..43b49c27 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -132,7 +132,7 @@ export const McpServerDetailDialog = ({ }, [open, scope, selectedInstalledEntry?.name, server]); useEffect(() => { - if (open && scope === 'project' && !projectPath) { + if (open && scope !== 'user' && !projectPath) { setScope('user'); } }, [open, projectPath, scope]); @@ -179,7 +179,7 @@ export const McpServerDetailDialog = ({ const isInstalledForScope = selectedInstalledEntry !== null; const uninstallServerName = selectedInstalledEntry?.name ?? serverName; const uninstallScope = selectedInstalledEntry?.scope ?? scope; - const scopeRequiresProjectPath = scope === 'project' && !projectPath; + const scopeRequiresProjectPath = scope !== 'user' && !projectPath; const installDisabled = !serverName.trim() || missingRequiredEnvVars || @@ -199,7 +199,7 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, - projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, + projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, envValues, headers, }); @@ -210,7 +210,7 @@ export const McpServerDetailDialog = ({ server.id, uninstallServerName, uninstallScope, - uninstallScope === 'project' ? (projectPath ?? undefined) : undefined + uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined ); }; @@ -417,7 +417,7 @@ export const McpServerDetailDialog = ({ {opt.label} diff --git a/test/main/services/extensions/McpInstallService.test.ts b/test/main/services/extensions/McpInstallService.test.ts index 1320d359..f67716ad 100644 --- a/test/main/services/extensions/McpInstallService.test.ts +++ b/test/main/services/extensions/McpInstallService.test.ts @@ -130,6 +130,7 @@ describe('McpInstallService', () => { registryId: 'upstash/context7-mcp', serverName: 'context7', scope: 'local', + projectPath: '/tmp/test', envValues: {}, headers: [], }); @@ -230,6 +231,20 @@ describe('McpInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope install without project path', async () => { + const result = await service.install({ + registryId: 'upstash/context7-mcp', + serverName: 'context7', + scope: 'local', + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── install: error masking ────────────────────────────────────────────────── @@ -290,6 +305,23 @@ describe('McpInstallService', () => { expect(result.error).toContain('projectPath is required'); expect(mockExecCli).not.toHaveBeenCalled(); }); + + it('rejects local scope custom install without project path', async () => { + const result = await service.installCustom({ + serverName: 'custom-context7', + scope: 'local', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── uninstall ─────────────────────────────────────────────────────────────── @@ -334,6 +366,14 @@ describe('McpInstallService', () => { expect(mockExecCli).not.toHaveBeenCalled(); }); + it('rejects local scope uninstall without project path', async () => { + const result = await service.uninstall('context7', 'local'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + it('returns error on CLI failure', async () => { mockExecCli.mockRejectedValue(new Error('Not found')); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 9dd675ef..31ef3d9f 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -125,7 +125,7 @@ describe('CustomMcpServerDialog project scope', () => { vi.unstubAllGlobals(); }); - it('disables project scope without an active project', async () => { + it('disables non-user scopes without an active project', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -142,7 +142,9 @@ describe('CustomMcpServerDialog project scope', () => { }); const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; + const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; expect(projectOption.disabled).toBe(true); + expect(localOption.disabled).toBe(true); await act(async () => { root.unmount(); @@ -203,4 +205,58 @@ describe('CustomMcpServerDialog project scope', () => { await Promise.resolve(); }); }); + + it('passes projectPath for local-scoped custom installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClose = vi.fn(); + const projectPath = '/tmp/custom-mcp-project'; + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose, + projectPath, + }) + ); + 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, 'local-context7', 'input'); + setNativeValue(packageInput, '@upstash/context7-mcp', 'input'); + setNativeValue(scopeSelect, 'local', 'change'); + await Promise.resolve(); + }); + + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Install' + ) as HTMLButtonElement; + expect(installButton.disabled).toBe(false); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installCustomMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'local-context7', + scope: 'local', + projectPath, + }) + ); + expect(onClose).toHaveBeenCalledTimes(1); + + 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 356415e9..262161bd 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -217,7 +217,7 @@ describe('McpServerDetailDialog installed entry handling', () => { 'io.github.upstash/context7', 'context7-local', 'local', - undefined + '/tmp/project' ); await act(async () => { @@ -258,6 +258,10 @@ describe('McpServerDetailDialog installed entry handling', () => { expect(lookupMock).toHaveBeenCalledTimes(1); expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']); + const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; + const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + expect(localOption.disabled).toBe(true); await act(async () => { root.unmount(); @@ -352,6 +356,90 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('passes project path for local-scoped installs and uninstalls', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const projectPath = '/tmp/local-context7'; + const installedEntry: InstalledMcpEntry = { + name: 'context7-local', + scope: 'local', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + installedEntries: [installedEntry], + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + uninstallButton.click(); + await Promise.resolve(); + }); + + expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( + 'io.github.upstash/context7', + 'context7-local', + 'local', + projectPath + ); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + scopeSelect.value = 'local'; + scopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'io.github.upstash/context7', + scope: 'local', + projectPath, + }) + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('uses selected scope instead of aggregated installed state', async () => { const host = document.createElement('div'); document.body.appendChild(host); From 66cf1443b2a5b763e16e4f0177c13d91b8d832a8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:57:22 +0300 Subject: [PATCH 14/23] fix(extensions): scope mcp operation state by install scope --- .../extensions/mcp/McpServerCard.tsx | 6 +- .../extensions/mcp/McpServerDetailDialog.tsx | 8 +- src/renderer/store/slices/extensionsSlice.ts | 170 ++++++++++++++---- src/shared/utils/extensionNormalizers.ts | 7 + .../extensions/mcp/McpServerCard.test.ts | 62 ++++++- .../mcp/McpServerDetailDialog.test.ts | 56 ++++++ test/renderer/store/extensionsSlice.test.ts | 90 +++++++++- .../shared/utils/extensionNormalizers.test.ts | 9 + 8 files changed, 359 insertions(+), 49 deletions(-) diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index c1e0d260..afae2142 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -13,6 +13,7 @@ import { useStore } from '@renderer/store'; import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters'; import { getMcpInstallationSummaryLabel, + getMcpOperationKey, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; @@ -46,10 +47,11 @@ export const McpServerCard = ({ diagnosticsLoading, onClick, }: McpServerCardProps): React.JSX.Element => { - const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle'); + const operationKey = getMcpOperationKey(server.id, 'user'); + const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle'); const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); - const installError = useStore((s) => s.installErrors[server.id]); + const installError = useStore((s) => s.installErrors[operationKey]); const stars = useStore((s) => server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined ); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 43b49c27..d16e0885 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -27,6 +27,7 @@ import { import { useStore } from '@renderer/store'; import { getMcpInstallationSummaryLabel, + getMcpOperationKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -73,17 +74,18 @@ export const McpServerDetailDialog = ({ open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { + const [scope, setScope] = useState('user'); + const operationKey = server ? getMcpOperationKey(server.id, scope) : null; const installProgress = useStore( - (s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle' + (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' ); const installMcpServer = useStore((s) => s.installMcpServer); const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); - const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined)); + const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined)); const stars = useStore((s) => server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined ); - const [scope, setScope] = useState('user'); const [serverName, setServerName] = useState(''); const [envValues, setEnvValues] = useState>({}); const [headers, setHeaders] = useState([]); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index fdea0a79..6f56629b 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -5,7 +5,7 @@ import { api } from '@renderer/api'; import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; -import { getPluginOperationKey } from '@shared/utils/extensionNormalizers'; +import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers'; @@ -60,7 +60,7 @@ export interface ExtensionsSlice { // ── Install progress ── pluginInstallProgress: Record; mcpInstallProgress: Record; - installErrors: Record; // keyed by pluginId or registryId + installErrors: Record; // keyed by scoped operation key // ── API Keys ── apiKeys: ApiKeyEntry[]; @@ -131,6 +131,7 @@ export interface ExtensionsSlice { let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; const pluginSuccessResetTimers = new Map>(); +const mcpSuccessResetTimers = new Map>(); let mcpDiagnosticsInFlight: Promise | null = null; let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; @@ -221,6 +222,82 @@ function schedulePluginSuccessReset( pluginSuccessResetTimers.set(operationKey, timer); } +function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string { + return `mcp-custom:${serverName}:${scope}`; +} + +function clearMcpSuccessResetTimer(operationKey: string): void { + const timer = mcpSuccessResetTimers.get(operationKey); + if (!timer) { + return; + } + + clearTimeout(timer); + mcpSuccessResetTimers.delete(operationKey); +} + +function scheduleMcpSuccessReset( + operationKey: string, + set: Parameters>[0] +): void { + clearMcpSuccessResetTimer(operationKey); + const timer = setTimeout(() => { + mcpSuccessResetTimers.delete(operationKey); + set((prev) => { + if (prev.mcpInstallProgress[operationKey] !== 'success') { + return {}; + } + + return { + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'idle' }, + }; + }); + }, SUCCESS_DISPLAY_MS); + mcpSuccessResetTimers.set(operationKey, timer); +} + +function clearMcpProjectScopedOperationState( + mcpInstallProgress: Record, + installErrors: Record +): { + mcpInstallProgress: Record; + installErrors: Record; +} { + const nextMcpInstallProgress = { ...mcpInstallProgress }; + const nextInstallErrors = { ...installErrors }; + + for (const operationKey of Object.keys(nextMcpInstallProgress)) { + if ( + (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && + (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + ) { + delete nextMcpInstallProgress[operationKey]; + } + } + + for (const operationKey of Object.keys(nextInstallErrors)) { + if ( + (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && + (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + ) { + delete nextInstallErrors[operationKey]; + } + } + + return { + mcpInstallProgress: nextMcpInstallProgress, + installErrors: nextInstallErrors, + }; +} + +function clearMcpProjectScopedSuccessResetTimers(): void { + for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) { + if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) { + clearMcpSuccessResetTimer(operationKey); + } + } +} + function getSkillsCatalogKey(projectPath?: string): string { return projectPath ?? USER_SKILLS_CATALOG_KEY; } @@ -434,9 +511,26 @@ export const createExtensionsSlice: StateCreator { + const nextProjectPath = projectPath ?? null; + const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath; + const nextOperationState = isSameProjectContext + ? { + mcpInstallProgress: prev.mcpInstallProgress, + installErrors: prev.installErrors, + } + : clearMcpProjectScopedOperationState(prev.mcpInstallProgress, prev.installErrors); + + if (!isSameProjectContext) { + clearMcpProjectScopedSuccessResetTimers(); + } + + return { + mcpInstalledServers: installed, + mcpInstalledProjectPath: nextProjectPath, + mcpInstallProgress: nextOperationState.mcpInstallProgress, + installErrors: nextOperationState.installErrors, + }; }); } catch { // Silently fail — installed state is supplementary @@ -833,29 +927,32 @@ export const createExtensionsSlice: StateCreator { + const operationKey = getMcpOperationKey(request.registryId, request.scope); if (!api.mcpRegistry) { + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [request.registryId]: 'MCP Registry not available', + [operationKey]: 'MCP Registry not available', }, })); return; } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, })); try { const result = await api.mcpRegistry.install(request); if (result.state === 'error') { set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [request.registryId]: result.error ?? 'Install failed', + [operationKey]: result.error ?? 'Install failed', }, })); return; @@ -867,27 +964,26 @@ export const createExtensionsSlice: StateCreator ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' }, })); - setTimeout(() => { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + scheduleMcpSuccessReset(operationKey, set); } catch (err) { + clearMcpSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, - installErrors: { ...prev.installErrors, [request.registryId]: message }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, // ── MCP custom install ── installCustomMcpServer: async (request: McpCustomInstallRequest) => { + const operationScope = request.scope; + const progressKey = getCustomMcpOperationKey(request.serverName, operationScope); if (!api.mcpRegistry) { - const progressKey = `custom:${request.serverName}`; + clearMcpSuccessResetTimer(progressKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, @@ -895,7 +991,7 @@ export const createExtensionsSlice: StateCreator ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, })); @@ -919,12 +1015,9 @@ export const createExtensionsSlice: StateCreator { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + scheduleMcpSuccessReset(progressKey, set); } catch (err) { + clearMcpSuccessResetTimer(progressKey); const message = err instanceof Error ? err.message : 'Install failed'; set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, @@ -940,26 +1033,30 @@ export const createExtensionsSlice: StateCreator { + const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user'; + const operationKey = getMcpOperationKey(registryId, operationScope); if (!api.mcpRegistry) { + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, - installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: 'MCP Registry not available' }, })); return; } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, })); try { const result = await api.mcpRegistry.uninstall(name, scope, projectPath); if (result.state === 'error') { set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, installErrors: { ...prev.installErrors, - [registryId]: result.error ?? 'Uninstall failed', + [operationKey]: result.error ?? 'Uninstall failed', }, })); return; @@ -971,19 +1068,16 @@ export const createExtensionsSlice: StateCreator ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' }, })); - setTimeout(() => { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' }, - })); - }, SUCCESS_DISPLAY_MS); + scheduleMcpSuccessReset(operationKey, set); } catch (err) { + clearMcpSuccessResetTimer(operationKey); const message = err instanceof Error ? err.message : 'Uninstall failed'; set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, - installErrors: { ...prev.installErrors, [registryId]: message }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: message }, })); } }, diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 9eb456c9..acdf3a88 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -108,6 +108,13 @@ export function getPluginOperationKey(pluginId: string, scope: InstallScope): st return `plugin:${pluginId}:${scope}`; } +/** + * Namespaced operation-state key for MCP install/uninstall UI state. + */ +export function getMcpOperationKey(registryId: string, scope: InstallScope): string { + return `mcp:${registryId}:${scope}`; +} + /** * 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 a122cd66..3a5509ec 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.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 { getMcpOperationKey } from '@shared/utils/extensionNormalizers'; import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; interface StoreState { @@ -55,7 +56,23 @@ vi.mock('@renderer/components/ui/tooltip', () => ({ })); vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ - InstallButton: () => React.createElement('button', { type: 'button', 'data-testid': 'install-button' }, 'Install'), + InstallButton: ({ + state, + errorMessage, + }: { + state?: string; + errorMessage?: string; + }) => + React.createElement( + 'button', + { + type: 'button', + 'data-testid': 'install-button', + 'data-state': state, + 'data-error': errorMessage, + }, + 'Install' + ), })); vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({ @@ -225,4 +242,47 @@ describe('McpServerCard direct action safety', () => { await Promise.resolve(); }); }); + + it('reads direct-action state from the user-scope operation key', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const installedEntry: InstalledMcpEntry = { + name: 'context7', + scope: 'user', + }; + + storeState.mcpInstallProgress = { + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending', + }; + storeState.installErrors = { + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed', + }; + + 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(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(installButton.dataset.state).toBe('pending'); + expect(installButton.dataset.error).toBe('User failed'); + + 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 262161bd..c6f4c204 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.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 { getMcpOperationKey } from '@shared/utils/extensionNormalizers'; import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; interface StoreState { @@ -104,10 +105,14 @@ vi.mock('@renderer/components/ui/select', () => ({ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ InstallButton: ({ isInstalled, + state, + errorMessage, onInstall, onUninstall, }: { isInstalled: boolean; + state?: string; + errorMessage?: string; onInstall: () => void; onUninstall: () => void; }) => @@ -116,6 +121,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ { type: 'button', 'data-testid': 'install-button', + 'data-state': state, + 'data-error': errorMessage, onClick: () => (isInstalled ? onUninstall() : onInstall()), }, isInstalled ? 'Uninstall' : 'Install' @@ -533,4 +540,53 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('reads install state from the selected scope operation key', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + storeState.mcpInstallProgress = { + [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success', + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + }; + storeState.installErrors = { + [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + installedEntries: [], + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + expect(installButton.dataset.state).toBe('success'); + expect(installButton.dataset.error ?? '').toBe(''); + + 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(); + }); + + expect(installButton.dataset.state).toBe('error'); + expect(installButton.dataset.error).toBe('Project failed'); + + 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 8e2a0c8b..16733b56 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -40,7 +40,10 @@ vi.mock('../../../src/renderer/api', () => ({ })); import { api } from '../../../src/renderer/api'; -import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers'; +import { + getMcpOperationKey, + getPluginOperationKey, +} from '../../../src/shared/utils/extensionNormalizers'; import type { EnrichedPlugin, @@ -136,6 +139,8 @@ 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); describe('extensionsSlice', () => { let store: TestStore; @@ -371,6 +376,43 @@ describe('extensionsSlice', () => { expect(store.getState().mcpInstalledServers).toEqual(installed); }); + + 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('user-server', 'user')]: 'pending', + }, + installErrors: { + [mcpOperationKey('project-server', 'project')]: 'Project failed', + [mcpOperationKey('local-server', 'local')]: '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', + }, + }); + (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + + 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('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('user-server', 'user')]).toBe( + 'Keep user state', + ); + expect(store.getState().installErrors['mcp-custom:custom-server:project']).toBeUndefined(); + expect(store.getState().installErrors['plugin:test@marketplace:user']).toBe( + 'Keep plugin state', + ); + }); }); describe('openExtensionsTab', () => { @@ -663,10 +705,44 @@ describe('extensionsSlice', () => { headers: [], }); - expect(store.getState().mcpInstallProgress['test-id']).toBe('pending'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'pending', + ); await promise; - expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'success', + ); + }); + + it('does not restore idle state after project switch clears a pending project-scope success timer', async () => { + vi.useFakeTimers(); + store.setState({ + mcpInstalledProjectPath: '/tmp/project-a', + }); + (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(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe( + 'success', + ); + + await store.getState().mcpFetchInstalled('/tmp/project-b'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + + await vi.advanceTimersByTimeAsync(2_000); + + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); }); }); @@ -678,10 +754,14 @@ describe('extensionsSlice', () => { const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user'); - expect(store.getState().mcpInstallProgress['test-id']).toBe('pending'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'pending', + ); await promise; - expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); + expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe( + 'success', + ); }); }); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 6b4708fc..087d1f07 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -9,6 +9,7 @@ import { getCapabilityLabel, getInstallationSummaryLabel, getMcpInstallationSummaryLabel, + getMcpOperationKey, getPreferredMcpInstallationEntry, getPluginOperationKey, getPrimaryCapabilityLabel, @@ -163,6 +164,14 @@ 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', + ); + }); +}); + describe('hasInstallationInScope', () => { it('returns true when the selected scope exists', () => { expect( From c02a12b3f66049836342e749248a25cefad4c173 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 23:01:33 +0300 Subject: [PATCH 15/23] fix(extensions): keep skill import destination in sync --- .../extensions/skills/SkillImportDialog.tsx | 29 ++- .../skills/SkillImportDialog.test.ts | 237 ++++++++++++++++++ 2 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 test/renderer/components/extensions/skills/SkillImportDialog.test.ts diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx index 507b4f8c..4697dae0 100644 --- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx @@ -56,6 +56,11 @@ interface SkillImportDialogProps { onImported: (skillId: string | null) => void; } +function getFolderNameFromPath(value: string): string { + const segments = value.split(/[\\/]/u).filter(Boolean); + return segments.at(-1) ?? ''; +} + export const SkillImportDialog = ({ open, projectPath, @@ -68,6 +73,7 @@ export const SkillImportDialog = ({ const [sourceDir, setSourceDir] = useState(''); const [folderName, setFolderName] = useState(''); + const [folderNameEdited, setFolderNameEdited] = useState(false); const [scope, setScope] = useState<'user' | 'project'>('user'); const [rootKind, setRootKind] = useState<'claude' | 'cursor' | 'agents'>('claude'); const [preview, setPreview] = useState(null); @@ -80,6 +86,7 @@ export const SkillImportDialog = ({ if (!open) return; setSourceDir(''); setFolderName(''); + setFolderNameEdited(false); setScope(projectPath ? 'project' : 'user'); setRootKind('claude'); setPreview(null); @@ -89,15 +96,24 @@ export const SkillImportDialog = ({ setMutationError(null); }, [open, projectPath]); + useEffect(() => { + if (!open || folderNameEdited) { + return; + } + setFolderName(getFolderNameFromPath(sourceDir)); + }, [folderNameEdited, open, sourceDir]); + + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + async function handleChooseFolder(): Promise { const selected = await api.config.selectFolders(); const first = selected[0]; if (!first) return; setSourceDir(first); - if (!folderName) { - const segments = first.split(/[\\/]/u).filter(Boolean); - setFolderName(segments.at(-1) ?? ''); - } } async function handleReview(): Promise { @@ -190,7 +206,10 @@ export const SkillImportDialog = ({ setFolderName(event.target.value)} + onChange={(event) => { + setFolderNameEdited(true); + setFolderName(event.target.value); + }} placeholder="Defaults to source folder name" />
diff --git a/test/renderer/components/extensions/skills/SkillImportDialog.test.ts b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts new file mode 100644 index 00000000..d1be6e25 --- /dev/null +++ b/test/renderer/components/extensions/skills/SkillImportDialog.test.ts @@ -0,0 +1,237 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + previewSkillImport: ReturnType; + applySkillImport: ReturnType; +} + +const storeState = {} as StoreState; +const selectFoldersMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + config: { + selectFolders: (...args: unknown[]) => selectFoldersMock(...args), + }, + }, +})); + +vi.mock('@renderer/components/ui/button', () => ({ + Button: ({ + children, + onClick, + type = 'button', + disabled, + }: React.PropsWithChildren<{ + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; + disabled?: boolean; + }>) => + React.createElement( + 'button', + { + type, + disabled, + onClick, + }, + children + ), +})); + +vi.mock('@renderer/components/ui/dialog', () => ({ + Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) => + open ? React.createElement('div', null, children) : null, + DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogDescription: ({ children }: React.PropsWithChildren) => + React.createElement('p', null, children), + DialogFooter: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children), + DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children), +})); + +vi.mock('@renderer/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => + React.createElement('input', props), +})); + +vi.mock('@renderer/components/ui/label', () => ({ + Label: ({ children, htmlFor }: React.PropsWithChildren<{ htmlFor?: string }>) => + React.createElement('label', { htmlFor }, children), +})); + +vi.mock('@renderer/components/ui/select', () => ({ + Select: ({ + children, + value, + onValueChange, + }: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) => + React.createElement( + 'select', + { + 'data-testid': 'select', + value, + onChange: (event: React.ChangeEvent) => onValueChange(event.target.value), + }, + children + ), + SelectTrigger: () => null, + SelectValue: () => null, + SelectContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), +})); + +vi.mock('@renderer/components/extensions/skills/SkillReviewDialog', () => ({ + SkillReviewDialog: () => null, +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + FileSearch: Icon, + FolderOpen: Icon, + X: Icon, + }; +}); + +import { SkillImportDialog } from '@renderer/components/extensions/skills/SkillImportDialog'; + +describe('SkillImportDialog', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.previewSkillImport = vi.fn(); + storeState.applySkillImport = vi.fn(); + selectFoldersMock.mockReset(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('keeps destination folder name synced with the chosen source until edited manually', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + selectFoldersMock + .mockResolvedValueOnce(['/tmp/first-skill']) + .mockResolvedValueOnce(['/tmp/second-skill']) + .mockResolvedValueOnce(['/tmp/third-skill']); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const browseButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.includes('Browse') + ) as HTMLButtonElement; + const sourceInput = host.querySelector('#skill-import-source') as HTMLInputElement; + const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement; + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/first-skill'); + expect(folderInput.value).toBe('first-skill'); + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/second-skill'); + expect(folderInput.value).toBe('second-skill'); + + await act(async () => { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setValue?.call(folderInput, 'custom-name'); + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + await Promise.resolve(); + }); + + expect(folderInput.value).toBe('custom-name'); + + await act(async () => { + browseButton.click(); + await Promise.resolve(); + }); + + expect(sourceInput.value).toBe('/tmp/third-skill'); + expect(folderInput.value).toBe('custom-name'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('falls back to user scope when the project context disappears mid-dialog', 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: '/tmp/project-a', + projectLabel: 'Project A', + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; + expect(scopeSelect.value).toBe('project'); + + await act(async () => { + root.render( + React.createElement(SkillImportDialog, { + open: true, + projectPath: null, + projectLabel: null, + onClose: vi.fn(), + onImported: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const updatedScopeSelect = host.querySelectorAll('select')[0] as HTMLSelectElement; + expect(updatedScopeSelect.value).toBe('user'); + const projectOption = Array.from(updatedScopeSelect.options).find( + (option) => option.value === 'project' + ) as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); From 3e5ec7c173b28829bbf6ff06dfc1ecf7d52896ef Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 23:03:55 +0300 Subject: [PATCH 16/23] fix(extensions): preserve skill project context in dialogs --- .../extensions/skills/SkillDetailDialog.tsx | 20 +- .../extensions/skills/SkillEditorDialog.tsx | 23 +- .../extensions/skills/SkillImportDialog.tsx | 5 +- .../extensions/skills/skillProjectUtils.ts | 13 + .../skills/SkillDetailDialog.test.ts | 280 ++++++++++++++++++ .../skills/skillProjectUtils.test.ts | 19 ++ 6 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 src/renderer/components/extensions/skills/skillProjectUtils.ts create mode 100644 test/renderer/components/extensions/skills/SkillDetailDialog.test.ts create mode 100644 test/renderer/components/extensions/skills/skillProjectUtils.test.ts diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx index d70238d6..2b57a9ca 100644 --- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx +++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx @@ -26,6 +26,8 @@ import { useStore } from '@renderer/store'; import { AlertTriangle, ExternalLink, FolderOpen, Pencil, Trash2 } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; +import { resolveSkillProjectPath } from './skillProjectUtils'; + interface SkillDetailDialogProps { skillId: string | null; open: boolean; @@ -58,8 +60,13 @@ export const SkillDetailDialog = ({ useEffect(() => { if (!open || !skillId) return; - void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined); - }, [fetchSkillDetail, open, projectPath, skillId]); + void fetchSkillDetail( + skillId, + detail?.item.scope + ? resolveSkillProjectPath(detail.item.scope, projectPath, detail.item.projectRoot) + : (projectPath ?? undefined) + ).catch(() => undefined); + }, [detail?.item.projectRoot, detail?.item.scope, fetchSkillDetail, open, projectPath, skillId]); useEffect(() => { if (!open) { @@ -70,6 +77,9 @@ export const SkillDetailDialog = ({ }, [open]); const item = detail?.item; + const effectiveProjectPath = item + ? resolveSkillProjectPath(item.scope, projectPath, item.projectRoot) + : (projectPath ?? undefined); function formatRootKind(rootKind: 'claude' | 'cursor' | 'agents'): string { return `.${rootKind}`; @@ -92,7 +102,7 @@ export const SkillDetailDialog = ({ try { await deleteSkill({ skillId: item.id, - projectPath: projectPath ?? undefined, + projectPath: effectiveProjectPath, }); setDeleteConfirmOpen(false); onDeleted(); @@ -125,7 +135,7 @@ export const SkillDetailDialog = ({ variant="outline" size="sm" onClick={() => { - void fetchSkillDetail(skillId, projectPath ?? undefined).catch(() => undefined); + void fetchSkillDetail(skillId, effectiveProjectPath).catch(() => undefined); }} > Retry @@ -288,7 +298,7 @@ export const SkillDetailDialog = ({