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(