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 {
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
);

View file

@ -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[]>([]);

View file

@ -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 },
}));
}
},

View file

@ -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.
*/

View file

@ -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();
});
});
});

View file

@ -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();
});
});
});

View file

@ -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',
);
});
});

View file

@ -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(