fix(extensions): scope mcp operation state by install scope
This commit is contained in:
parent
94291f50f0
commit
66cf1443b2
8 changed files with 359 additions and 49 deletions
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<Scope>('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<Scope>('user');
|
||||
const [serverName, setServerName] = useState('');
|
||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||
|
|
|
|||
|
|
@ -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<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 ──
|
||||
apiKeys: ApiKeyEntry[];
|
||||
|
|
@ -131,6 +131,7 @@ export interface ExtensionsSlice {
|
|||
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
||||
let pluginCatalogRequestSeq = 0;
|
||||
const pluginSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const mcpSuccessResetTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let mcpDiagnosticsInFlight: Promise<void> | 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<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 {
|
||||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||
}
|
||||
|
|
@ -434,9 +511,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
|
||||
try {
|
||||
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
||||
set({
|
||||
mcpInstalledServers: installed,
|
||||
mcpInstalledProjectPath: projectPath ?? null,
|
||||
set((prev) => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
|
||||
// ── MCP install ──
|
||||
installMcpServer: async (request: McpInstallRequest) => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
]);
|
||||
|
||||
set((prev) => ({
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
return;
|
||||
}
|
||||
|
||||
const progressKey = `custom:${request.serverName}`;
|
||||
clearMcpSuccessResetTimer(progressKey);
|
||||
set((prev) => ({
|
||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
||||
}));
|
||||
|
|
@ -919,12 +1015,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
|||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
||||
}));
|
||||
|
||||
setTimeout(() => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
scope?: string,
|
||||
projectPath?: string
|
||||
) => {
|
||||
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<AppState, [], [], ExtensionsSli
|
|||
]);
|
||||
|
||||
set((prev) => ({
|
||||
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 },
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<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', () => {
|
||||
|
|
@ -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<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');
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue