fix(extensions): scope mcp operation state by install scope

This commit is contained in:
777genius 2026-04-16 22:57:22 +03:00
parent 94291f50f0
commit 66cf1443b2
8 changed files with 359 additions and 49 deletions

View file

@ -13,6 +13,7 @@ import { useStore } from '@renderer/store';
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters'; import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
import { import {
getMcpInstallationSummaryLabel, getMcpInstallationSummaryLabel,
getMcpOperationKey,
sanitizeMcpServerName, sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react'; import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
@ -46,10 +47,11 @@ export const McpServerCard = ({
diagnosticsLoading, diagnosticsLoading,
onClick, onClick,
}: McpServerCardProps): React.JSX.Element => { }: 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 installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); 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) => const stars = useStore((s) =>
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
); );

View file

@ -27,6 +27,7 @@ import {
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getMcpInstallationSummaryLabel, getMcpInstallationSummaryLabel,
getMcpOperationKey,
getPreferredMcpInstallationEntry, getPreferredMcpInstallationEntry,
sanitizeMcpServerName, sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
@ -73,17 +74,18 @@ export const McpServerDetailDialog = ({
open, open,
onClose, onClose,
}: McpServerDetailDialogProps): React.JSX.Element => { }: McpServerDetailDialogProps): React.JSX.Element => {
const [scope, setScope] = useState<Scope>('user');
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
const installProgress = useStore( 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 installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer); 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) => const stars = useStore((s) =>
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
); );
const [scope, setScope] = useState<Scope>('user');
const [serverName, setServerName] = useState(''); const [serverName, setServerName] = useState('');
const [envValues, setEnvValues] = useState<Record<string, string>>({}); const [envValues, setEnvValues] = useState<Record<string, string>>({});
const [headers, setHeaders] = useState<McpHeaderDef[]>([]); const [headers, setHeaders] = useState<McpHeaderDef[]>([]);

View file

@ -5,7 +5,7 @@
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; 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'; import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
@ -60,7 +60,7 @@ export interface ExtensionsSlice {
// ── Install progress ── // ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>; pluginInstallProgress: Record<string, ExtensionOperationState>;
mcpInstallProgress: Record<string, ExtensionOperationState>; mcpInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>; // keyed by pluginId or registryId installErrors: Record<string, string>; // keyed by scoped operation key
// ── API Keys ── // ── API Keys ──
apiKeys: ApiKeyEntry[]; apiKeys: ApiKeyEntry[];
@ -131,6 +131,7 @@ export interface ExtensionsSlice {
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null; let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
let pluginCatalogRequestSeq = 0; let pluginCatalogRequestSeq = 0;
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>(); const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
let mcpDiagnosticsInFlight: Promise<void> | null = null; let mcpDiagnosticsInFlight: Promise<void> | null = null;
let skillsCatalogRequestSeq = 0; let skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0; let skillsDetailRequestSeq = 0;
@ -221,6 +222,82 @@ function schedulePluginSuccessReset(
pluginSuccessResetTimers.set(operationKey, timer); 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<StateCreator<AppState, [], [], ExtensionsSlice>>[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<string, ExtensionOperationState>,
installErrors: Record<string, string>
): {
mcpInstallProgress: Record<string, ExtensionOperationState>;
installErrors: Record<string, string>;
} {
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 { function getSkillsCatalogKey(projectPath?: string): string {
return projectPath ?? USER_SKILLS_CATALOG_KEY; return projectPath ?? USER_SKILLS_CATALOG_KEY;
} }
@ -434,9 +511,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
try { try {
const installed = await api.mcpRegistry.getInstalled(projectPath); const installed = await api.mcpRegistry.getInstalled(projectPath);
set({ set((prev) => {
mcpInstalledServers: installed, const nextProjectPath = projectPath ?? null;
mcpInstalledProjectPath: 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 { } catch {
// Silently fail — installed state is supplementary // Silently fail — installed state is supplementary
@ -833,29 +927,32 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── MCP install ── // ── MCP install ──
installMcpServer: async (request: McpInstallRequest) => { installMcpServer: async (request: McpInstallRequest) => {
const operationKey = getMcpOperationKey(request.registryId, request.scope);
if (!api.mcpRegistry) { if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { installErrors: {
...prev.installErrors, ...prev.installErrors,
[request.registryId]: 'MCP Registry not available', [operationKey]: 'MCP Registry not available',
}, },
})); }));
return; return;
} }
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
})); }));
try { try {
const result = await api.mcpRegistry.install(request); const result = await api.mcpRegistry.install(request);
if (result.state === 'error') { if (result.state === 'error') {
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { installErrors: {
...prev.installErrors, ...prev.installErrors,
[request.registryId]: result.error ?? 'Install failed', [operationKey]: result.error ?? 'Install failed',
}, },
})); }));
return; return;
@ -867,27 +964,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
]); ]);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
})); }));
setTimeout(() => { scheduleMcpSuccessReset(operationKey, set);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch (err) { } catch (err) {
clearMcpSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Install failed'; const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [request.registryId]: message }, installErrors: { ...prev.installErrors, [operationKey]: message },
})); }));
} }
}, },
// ── MCP custom install ── // ── MCP custom install ──
installCustomMcpServer: async (request: McpCustomInstallRequest) => { installCustomMcpServer: async (request: McpCustomInstallRequest) => {
const operationScope = request.scope;
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope);
if (!api.mcpRegistry) { if (!api.mcpRegistry) {
const progressKey = `custom:${request.serverName}`; clearMcpSuccessResetTimer(progressKey);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
@ -895,7 +991,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return; return;
} }
const progressKey = `custom:${request.serverName}`; clearMcpSuccessResetTimer(progressKey);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
})); }));
@ -919,12 +1015,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
})); }));
setTimeout(() => { scheduleMcpSuccessReset(progressKey, set);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch (err) { } catch (err) {
clearMcpSuccessResetTimer(progressKey);
const message = err instanceof Error ? err.message : 'Install failed'; const message = err instanceof Error ? err.message : 'Install failed';
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
@ -940,26 +1033,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
scope?: string, scope?: string,
projectPath?: string projectPath?: string
) => { ) => {
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
const operationKey = getMcpOperationKey(registryId, operationScope);
if (!api.mcpRegistry) { if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' }, installErrors: { ...prev.installErrors, [operationKey]: 'MCP Registry not available' },
})); }));
return; return;
} }
clearMcpSuccessResetTimer(operationKey);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
})); }));
try { try {
const result = await api.mcpRegistry.uninstall(name, scope, projectPath); const result = await api.mcpRegistry.uninstall(name, scope, projectPath);
if (result.state === 'error') { if (result.state === 'error') {
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { installErrors: {
...prev.installErrors, ...prev.installErrors,
[registryId]: result.error ?? 'Uninstall failed', [operationKey]: result.error ?? 'Uninstall failed',
}, },
})); }));
return; return;
@ -971,19 +1068,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
]); ]);
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
})); }));
setTimeout(() => { scheduleMcpSuccessReset(operationKey, set);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
}));
}, SUCCESS_DISPLAY_MS);
} catch (err) { } catch (err) {
clearMcpSuccessResetTimer(operationKey);
const message = err instanceof Error ? err.message : 'Uninstall failed'; const message = err instanceof Error ? err.message : 'Uninstall failed';
set((prev) => ({ set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' }, mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
installErrors: { ...prev.installErrors, [registryId]: message }, installErrors: { ...prev.installErrors, [operationKey]: message },
})); }));
} }
}, },

View file

@ -108,6 +108,13 @@ export function getPluginOperationKey(pluginId: string, scope: InstallScope): st
return `plugin:${pluginId}:${scope}`; 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. * Check whether a plugin has an installation for the selected scope.
*/ */

View file

@ -2,6 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
interface StoreState { interface StoreState {
@ -55,7 +56,23 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
})); }));
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ 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', () => ({ vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
@ -225,4 +242,47 @@ describe('McpServerCard direct action safety', () => {
await Promise.resolve(); 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();
});
});
}); });

View file

@ -2,6 +2,7 @@ import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions'; import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
interface StoreState { interface StoreState {
@ -104,10 +105,14 @@ vi.mock('@renderer/components/ui/select', () => ({
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
InstallButton: ({ InstallButton: ({
isInstalled, isInstalled,
state,
errorMessage,
onInstall, onInstall,
onUninstall, onUninstall,
}: { }: {
isInstalled: boolean; isInstalled: boolean;
state?: string;
errorMessage?: string;
onInstall: () => void; onInstall: () => void;
onUninstall: () => void; onUninstall: () => void;
}) => }) =>
@ -116,6 +121,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
{ {
type: 'button', type: 'button',
'data-testid': 'install-button', 'data-testid': 'install-button',
'data-state': state,
'data-error': errorMessage,
onClick: () => (isInstalled ? onUninstall() : onInstall()), onClick: () => (isInstalled ? onUninstall() : onInstall()),
}, },
isInstalled ? 'Uninstall' : 'Install' isInstalled ? 'Uninstall' : 'Install'
@ -533,4 +540,53 @@ describe('McpServerDetailDialog installed entry handling', () => {
await Promise.resolve(); 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();
});
});
}); });

View file

@ -40,7 +40,10 @@ vi.mock('../../../src/renderer/api', () => ({
})); }));
import { api } from '../../../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 { import type {
EnrichedPlugin, EnrichedPlugin,
@ -136,6 +139,8 @@ const makeReadyCliStatus = () => ({
const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') => const pluginOperationKey = (pluginId: string, scope: 'user' | 'project' | 'local' = 'user') =>
getPluginOperationKey(pluginId, scope); getPluginOperationKey(pluginId, scope);
const mcpOperationKey = (registryId: string, scope: 'user' | 'project' | 'local' = 'user') =>
getMcpOperationKey(registryId, scope);
describe('extensionsSlice', () => { describe('extensionsSlice', () => {
let store: TestStore; let store: TestStore;
@ -371,6 +376,43 @@ describe('extensionsSlice', () => {
expect(store.getState().mcpInstalledServers).toEqual(installed); 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<typeof vi.fn>).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', () => { describe('openExtensionsTab', () => {
@ -663,10 +705,44 @@ describe('extensionsSlice', () => {
headers: [], headers: [],
}); });
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending'); expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
'pending',
);
await promise; 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<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).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'); 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; await promise;
expect(store.getState().mcpInstallProgress['test-id']).toBe('success'); expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
'success',
);
}); });
}); });

View file

@ -9,6 +9,7 @@ import {
getCapabilityLabel, getCapabilityLabel,
getInstallationSummaryLabel, getInstallationSummaryLabel,
getMcpInstallationSummaryLabel, getMcpInstallationSummaryLabel,
getMcpOperationKey,
getPreferredMcpInstallationEntry, getPreferredMcpInstallationEntry,
getPluginOperationKey, getPluginOperationKey,
getPrimaryCapabilityLabel, 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', () => { describe('hasInstallationInScope', () => {
it('returns true when the selected scope exists', () => { it('returns true when the selected scope exists', () => {
expect( expect(