diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index c6515f8d..87ef0a08 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -76,7 +76,7 @@ export const McpServerDetailDialog = ({ const cliStatus = useStore((s) => s.cliStatus); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const [scope, setScope] = useState(defaultSharedScope); - const operationKey = server ? getMcpOperationKey(server.id, scope) : null; + const operationKey = server ? getMcpOperationKey(server.id, scope, projectPath) : null; const installProgress = useStore( (s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' ); diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index c799433a..816ba2b0 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -18,6 +18,7 @@ import { formatRelativeTime } from '@renderer/utils/formatters'; import { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getPreferredMcpInstallationEntry, sanitizeMcpServerName, } from '@shared/utils/extensionNormalizers'; @@ -79,18 +80,24 @@ export const McpServersPanel = ({ selectedMcpServerId, setSelectedMcpServerId, }: McpServersPanelProps): React.JSX.Element => { + const projectStateKey = getMcpProjectStateKey(projectPath); const { browseCatalog, browseNextCursor, browseLoading, browseError, mcpBrowse, - installedServers, + installedServersByProjectPath, + installedServersFallback, fetchMcpGitHubStars, - mcpDiagnostics, - mcpDiagnosticsLoading, - mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback, + mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback, + mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback, + mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback, runMcpDiagnostics, cliStatus, } = useStore( @@ -100,16 +107,33 @@ export const McpServersPanel = ({ browseLoading: s.mcpBrowseLoading, browseError: s.mcpBrowseError, mcpBrowse: s.mcpBrowse, - installedServers: s.mcpInstalledServers, + installedServersByProjectPath: s.mcpInstalledServersByProjectPath, + installedServersFallback: s.mcpInstalledServers, fetchMcpGitHubStars: s.fetchMcpGitHubStars, - mcpDiagnostics: s.mcpDiagnostics, - mcpDiagnosticsLoading: s.mcpDiagnosticsLoading, - mcpDiagnosticsError: s.mcpDiagnosticsError, - mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt, + mcpDiagnosticsByProjectPath: s.mcpDiagnosticsByProjectPath, + mcpDiagnosticsFallback: s.mcpDiagnostics, + mcpDiagnosticsLoadingByProjectPath: s.mcpDiagnosticsLoadingByProjectPath, + mcpDiagnosticsLoadingFallback: s.mcpDiagnosticsLoading, + mcpDiagnosticsErrorByProjectPath: s.mcpDiagnosticsErrorByProjectPath, + mcpDiagnosticsErrorFallback: s.mcpDiagnosticsError, + mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath, + mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt, runMcpDiagnostics: s.runMcpDiagnostics, cliStatus: s.cliStatus, })) ); + const installedServers = + installedServersByProjectPath?.[projectStateKey] ?? installedServersFallback ?? []; + const mcpDiagnostics = + mcpDiagnosticsByProjectPath?.[projectStateKey] ?? mcpDiagnosticsFallback ?? {}; + const mcpDiagnosticsLoading = + mcpDiagnosticsLoadingByProjectPath?.[projectStateKey] ?? mcpDiagnosticsLoadingFallback ?? false; + const mcpDiagnosticsError = + mcpDiagnosticsErrorByProjectPath?.[projectStateKey] ?? mcpDiagnosticsErrorFallback ?? null; + const mcpDiagnosticsLastCheckedAt = + mcpDiagnosticsLastCheckedAtByProjectPath?.[projectStateKey] ?? + mcpDiagnosticsLastCheckedAtFallback ?? + null; const [mcpSort, setMcpSort] = useState('name-asc'); diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 15fb4556..20dbae3b 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -8,6 +8,7 @@ import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '@shared/utils/extensionNormalizers'; @@ -56,11 +57,16 @@ export interface ExtensionsSlice { mcpBrowseLoading: boolean; mcpBrowseError: string | null; mcpInstalledServers: InstalledMcpEntry[]; + mcpInstalledServersByProjectPath: Record; mcpInstalledProjectPath: string | null; mcpDiagnostics: Record; + mcpDiagnosticsByProjectPath: Record>; mcpDiagnosticsLoading: boolean; + mcpDiagnosticsLoadingByProjectPath: Record; mcpDiagnosticsError: string | null; + mcpDiagnosticsErrorByProjectPath: Record; mcpDiagnosticsLastCheckedAt: number | null; + mcpDiagnosticsLastCheckedAtByProjectPath: Record; // ── Install progress ── pluginInstallProgress: Record; @@ -137,7 +143,7 @@ let pluginFetchInFlight: { key: string; promise: Promise } | null = null; let pluginCatalogRequestSeq = 0; const pluginSuccessResetTimers = new Map>(); const mcpSuccessResetTimers = new Map>(); -let mcpDiagnosticsInFlight: Promise | null = null; +const mcpDiagnosticsInFlightByKey = new Map>(); let skillsCatalogRequestSeq = 0; let skillsDetailRequestSeq = 0; const latestSkillsCatalogRequestByKey = new Map(); @@ -227,10 +233,26 @@ function schedulePluginSuccessReset( pluginSuccessResetTimers.set(operationKey, timer); } -function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string { +function getCustomMcpOperationKey( + serverName: string, + scope: InstallScope, + projectPath?: string | null +): string { + if (scope === 'project' || scope === 'local') { + return `mcp-custom:${serverName}:${scope}:${getMcpProjectStateKey(projectPath)}`; + } return `mcp-custom:${serverName}:${scope}`; } +function isProjectScopedMcpOperationKey(operationKey: string): boolean { + return ( + operationKey.includes(':project:') || + operationKey.endsWith(':project') || + operationKey.includes(':local:') || + operationKey.endsWith(':local') + ); +} + function clearMcpSuccessResetTimer(operationKey: string): void { const timer = mcpSuccessResetTimers.get(operationKey); if (!timer) { @@ -274,7 +296,7 @@ function clearMcpProjectScopedOperationState( for (const operationKey of Object.keys(nextMcpInstallProgress)) { if ( (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && - (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + isProjectScopedMcpOperationKey(operationKey) ) { delete nextMcpInstallProgress[operationKey]; } @@ -283,7 +305,7 @@ function clearMcpProjectScopedOperationState( for (const operationKey of Object.keys(nextInstallErrors)) { if ( (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && - (operationKey.endsWith(':project') || operationKey.endsWith(':local')) + isProjectScopedMcpOperationKey(operationKey) ) { delete nextInstallErrors[operationKey]; } @@ -297,7 +319,7 @@ function clearMcpProjectScopedOperationState( function clearMcpProjectScopedSuccessResetTimers(): void { for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) { - if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) { + if (isProjectScopedMcpOperationKey(operationKey)) { clearMcpSuccessResetTimer(operationKey); } } @@ -336,11 +358,16 @@ export const createExtensionsSlice: StateCreator { const nextProjectPath = projectPath ?? null; + const stateKey = getMcpProjectStateKey(nextProjectPath); const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath; const nextOperationState = isSameProjectContext ? { @@ -533,6 +561,10 @@ export const createExtensionsSlice: StateCreator { const mcpRegistry = api.mcpRegistry; if (!mcpRegistry) return; + const projectStateKey = getMcpProjectStateKey(projectPath); - if (mcpDiagnosticsInFlight) { - await mcpDiagnosticsInFlight; + const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey); + if (existing) { + await existing; return; } - set({ mcpDiagnosticsLoading: true, mcpDiagnosticsError: null }); + set((prev) => ({ + mcpDiagnosticsLoading: true, + mcpDiagnosticsError: null, + mcpDiagnosticsLoadingByProjectPath: { + ...prev.mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: true, + }, + mcpDiagnosticsErrorByProjectPath: { + ...prev.mcpDiagnosticsErrorByProjectPath, + [projectStateKey]: null, + }, + })); const promise = (async () => { try { const diagnostics = await mcpRegistry.diagnose(projectPath); + const diagnosticsRecord = Object.fromEntries( + diagnostics.map((entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const) + ); + const checkedAt = Date.now(); set({ - mcpDiagnostics: Object.fromEntries( - diagnostics.map( - (entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const - ) - ), + mcpDiagnostics: diagnosticsRecord, mcpDiagnosticsLoading: false, - mcpDiagnosticsLastCheckedAt: Date.now(), + mcpDiagnosticsByProjectPath: { + ...get().mcpDiagnosticsByProjectPath, + [projectStateKey]: diagnosticsRecord, + }, + mcpDiagnosticsLoadingByProjectPath: { + ...get().mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: false, + }, + mcpDiagnosticsLastCheckedAt: checkedAt, + mcpDiagnosticsLastCheckedAtByProjectPath: { + ...get().mcpDiagnosticsLastCheckedAtByProjectPath, + [projectStateKey]: checkedAt, + }, }); } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to check MCP server health'; set({ mcpDiagnosticsLoading: false, - mcpDiagnosticsError: - err instanceof Error ? err.message : 'Failed to check MCP server health', + mcpDiagnosticsError: errorMessage, + mcpDiagnosticsLoadingByProjectPath: { + ...get().mcpDiagnosticsLoadingByProjectPath, + [projectStateKey]: false, + }, + mcpDiagnosticsErrorByProjectPath: { + ...get().mcpDiagnosticsErrorByProjectPath, + [projectStateKey]: errorMessage, + }, }); } finally { - mcpDiagnosticsInFlight = null; + mcpDiagnosticsInFlightByKey.delete(projectStateKey); } })(); - mcpDiagnosticsInFlight = promise; + mcpDiagnosticsInFlightByKey.set(projectStateKey, promise); await promise; }, @@ -935,7 +1001,7 @@ export const createExtensionsSlice: StateCreator { - const operationKey = getMcpOperationKey(request.registryId, request.scope); + const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); set((prev) => ({ @@ -967,8 +1033,8 @@ export const createExtensionsSlice: StateCreator ({ @@ -989,7 +1055,11 @@ export const createExtensionsSlice: StateCreator { const operationScope = request.scope; - const progressKey = getCustomMcpOperationKey(request.serverName, operationScope); + const progressKey = getCustomMcpOperationKey( + request.serverName, + operationScope, + request.projectPath + ); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(progressKey); set((prev) => ({ @@ -1015,8 +1085,8 @@ export const createExtensionsSlice: StateCreator ({ @@ -1043,7 +1113,7 @@ export const createExtensionsSlice: StateCreator { const operationScope: InstallScope = scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user'; - const operationKey = getMcpOperationKey(registryId, operationScope); + const operationKey = getMcpOperationKey(registryId, operationScope, projectPath); if (!api.mcpRegistry) { clearMcpSuccessResetTimer(operationKey); set((prev) => ({ @@ -1072,8 +1142,8 @@ export const createExtensionsSlice: StateCreator ({ diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 5601f944..356946bf 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -116,7 +116,14 @@ export function getPluginOperationKey(pluginId: string, scope: InstallScope): st /** * Namespaced operation-state key for MCP install/uninstall UI state. */ -export function getMcpOperationKey(registryId: string, scope: InstallScope): string { +export function getMcpOperationKey( + registryId: string, + scope: InstallScope, + projectPath?: string | null +): string { + if (scope === 'project' || scope === 'local') { + return `mcp:${registryId}:${scope}:${getMcpProjectStateKey(projectPath)}`; + } return `mcp:${registryId}:${scope}`; } @@ -128,6 +135,13 @@ export function getMcpDiagnosticKey(name: string, scope?: string | null): string return scope ? `mcp-diagnostic:${scope}:${name}` : `mcp-diagnostic:${name}`; } +/** + * Stable project-aware cache key for MCP installed/diagnostics state. + */ +export function getMcpProjectStateKey(projectPath?: string | null): string { + return projectPath ?? '__global__'; +} + /** * Check whether a plugin has an installation for the selected scope. */ diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 85ccf6d5..db2adef6 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -255,11 +255,12 @@ describe('McpServerCard direct action safety', () => { }; storeState.mcpInstallProgress = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'error', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending', }; storeState.installErrors = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: + 'Project failed', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed', }; diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index a5e9ea00..7e1b32e9 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -581,10 +581,11 @@ describe('McpServerDetailDialog installed entry handling', () => { const root = createRoot(host); storeState.mcpInstallProgress = { [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success', - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: 'error', }; storeState.installErrors = { - [getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', + [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]: + 'Project failed', }; await act(async () => { diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 0d9451df..85a2281d 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -18,11 +18,19 @@ interface StoreState { mcpBrowseError: string | null; mcpBrowse: ReturnType; mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; + mcpInstalledServersByProjectPath?: Record< + string, + Array<{ name: string; scope: 'local' | 'user' | 'project' }> + >; fetchMcpGitHubStars: ReturnType; mcpDiagnostics: Record; + mcpDiagnosticsByProjectPath?: Record>; mcpDiagnosticsLoading: boolean; + mcpDiagnosticsLoadingByProjectPath?: Record; mcpDiagnosticsError: string | null; + mcpDiagnosticsErrorByProjectPath?: Record; mcpDiagnosticsLastCheckedAt: number | null; + mcpDiagnosticsLastCheckedAtByProjectPath?: Record; runMcpDiagnostics: ReturnType; } @@ -121,11 +129,16 @@ describe('McpServersPanel initial browse loading', () => { storeState.mcpBrowseError = null; storeState.mcpBrowse = vi.fn(); storeState.mcpInstalledServers = []; + storeState.mcpInstalledServersByProjectPath = undefined; storeState.fetchMcpGitHubStars = vi.fn(); storeState.mcpDiagnostics = {}; + storeState.mcpDiagnosticsByProjectPath = undefined; storeState.mcpDiagnosticsLoading = false; + storeState.mcpDiagnosticsLoadingByProjectPath = undefined; storeState.mcpDiagnosticsError = null; + storeState.mcpDiagnosticsErrorByProjectPath = undefined; storeState.mcpDiagnosticsLastCheckedAt = null; + storeState.mcpDiagnosticsLastCheckedAtByProjectPath = undefined; storeState.runMcpDiagnostics = vi.fn(); }); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index c9a5337c..350e074c 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -56,6 +56,7 @@ vi.mock('../../../src/renderer/api', () => ({ import { api } from '../../../src/renderer/api'; import { getMcpDiagnosticKey, + getMcpProjectStateKey, getMcpOperationKey, getPluginOperationKey, } from '../../../src/shared/utils/extensionNormalizers'; @@ -154,8 +155,11 @@ const makeReadyCliStatus = () => ({ const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => getPluginOperationKey(pluginId, scope); -const mcpOperationKey = (registryId: string, scope: 'user' | 'project' | 'local' = 'user') => - getMcpOperationKey(registryId, scope); +const mcpOperationKey = ( + registryId: string, + scope: 'user' | 'project' | 'local' | 'global' = 'user', + projectPath?: string +) => getMcpOperationKey(registryId, scope, projectPath); describe('extensionsSlice', () => { let store: TestStore; @@ -393,20 +397,34 @@ describe('extensionsSlice', () => { expect(store.getState().mcpInstalledServers).toEqual(installed); }); + it('stores installed MCP servers independently per project context', async () => { + (api.mcpRegistry!.getInstalled as ReturnType) + .mockResolvedValueOnce([{ name: 'global-server', scope: 'global' as const }]) + .mockResolvedValueOnce([{ name: 'project-server', scope: 'project' as const }]); + + await store.getState().mcpFetchInstalled(); + await store.getState().mcpFetchInstalled('/tmp/project-a'); + + expect(store.getState().mcpInstalledServersByProjectPath).toMatchObject({ + [getMcpProjectStateKey()]: [{ name: 'global-server', scope: 'global' }], + [getMcpProjectStateKey('/tmp/project-a')]: [{ name: 'project-server', scope: 'project' }], + }); + }); + it('clears stale project- and local-scoped MCP operation state when project changes', async () => { store.setState({ mcpInstalledProjectPath: '/tmp/project-a', mcpInstallProgress: { - [mcpOperationKey('project-server', 'project')]: 'error', - [mcpOperationKey('local-server', 'local')]: 'success', + [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'error', + [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'success', [mcpOperationKey('user-server', 'user')]: 'pending', }, installErrors: { - [mcpOperationKey('project-server', 'project')]: 'Project failed', - [mcpOperationKey('local-server', 'local')]: 'Local failed', + [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'Project failed', + [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'Local failed', [mcpOperationKey('user-server', 'user')]: 'Keep user state', 'plugin:test@marketplace:user': 'Keep plugin state', - 'mcp-custom:custom-server:project': 'Clear custom project state', + 'mcp-custom:custom-server:project:/tmp/project-a': 'Clear custom project state', }, }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); @@ -414,13 +432,21 @@ describe('extensionsSlice', () => { await store.getState().mcpFetchInstalled('/tmp/project-b'); expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b'); - expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined(); - expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project', '/tmp/project-a')] + ).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local', '/tmp/project-a')] + ).toBeUndefined(); expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe( 'pending', ); - expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined(); - expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined(); + expect( + store.getState().installErrors[mcpOperationKey('project-server', 'project', '/tmp/project-a')] + ).toBeUndefined(); + expect( + store.getState().installErrors[mcpOperationKey('local-server', 'local', '/tmp/project-a')] + ).toBeUndefined(); expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe( 'Keep user state', ); @@ -749,16 +775,20 @@ describe('extensionsSlice', () => { headers: [], }); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe( - 'success', - ); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBe('success'); await store.getState().mcpFetchInstalled('/tmp/project-b'); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBeUndefined(); await vi.advanceTimersByTimeAsync(2_000); - expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined(); + expect( + store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] + ).toBeUndefined(); }); }); @@ -868,6 +898,69 @@ describe('extensionsSlice', () => { }), }); }); + + it('stores MCP diagnostics independently per project context', async () => { + (api.mcpRegistry!.diagnose as ReturnType) + .mockResolvedValueOnce([ + { + name: 'global-server', + scope: 'global', + target: 'npx global-server', + status: 'connected', + statusLabel: 'Connected', + rawLine: 'global-server: npx global-server - Connected', + checkedAt: 1, + }, + ]) + .mockResolvedValueOnce([ + { + name: 'project-server', + scope: 'project', + target: 'uvx project-server', + status: 'failed', + statusLabel: 'Failed to connect', + rawLine: 'project-server: uvx project-server - Failed to connect', + checkedAt: 2, + }, + ]); + + await store.getState().runMcpDiagnostics(); + await store.getState().runMcpDiagnostics('/tmp/project-a'); + + expect(store.getState().mcpDiagnosticsByProjectPath).toMatchObject({ + [getMcpProjectStateKey()]: { + [getMcpDiagnosticKey('global-server', 'global')]: expect.objectContaining({ + target: 'npx global-server', + }), + }, + [getMcpProjectStateKey('/tmp/project-a')]: { + [getMcpDiagnosticKey('project-server', 'project')]: expect.objectContaining({ + target: 'uvx project-server', + }), + }, + }); + }); + + it('refreshes MCP install state using the operation project context instead of the last viewed tab', async () => { + store.setState({ + mcpInstalledProjectPath: '/tmp/project-b', + }); + (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); + (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); + (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); + + await store.getState().installMcpServer({ + registryId: 'test-id', + serverName: 'test-server', + scope: 'project', + projectPath: '/tmp/project-a', + envValues: {}, + headers: [], + }); + + expect(api.mcpRegistry!.getInstalled).toHaveBeenLastCalledWith('/tmp/project-a'); + expect(api.mcpRegistry!.diagnose).toHaveBeenLastCalledWith('/tmp/project-a'); + }); }); describe('skills state hardening', () => { diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index de60b2ed..979c318d 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -166,8 +166,8 @@ describe('getPluginOperationKey', () => { describe('getMcpOperationKey', () => { it('namespaces MCP operation keys by scope', () => { - expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe( - 'mcp:io.github.upstash/context7:project', + expect(getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')).toBe( + 'mcp:io.github.upstash/context7:project:/tmp/project', ); }); });