fix(extensions): scope mcp renderer state by project

This commit is contained in:
777genius 2026-04-17 14:28:25 +03:00
parent 81c59440bf
commit 489e3eb967
9 changed files with 277 additions and 61 deletions

View file

@ -76,7 +76,7 @@ export const McpServerDetailDialog = ({
const cliStatus = useStore((s) => s.cliStatus);
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const [scope, setScope] = useState<Scope>(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'
);

View file

@ -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<McpSortValue>('name-asc');

View file

@ -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<string, InstalledMcpEntry[]>;
mcpInstalledProjectPath: string | null;
mcpDiagnostics: Record<string, McpServerDiagnostic>;
mcpDiagnosticsByProjectPath: Record<string, Record<string, McpServerDiagnostic>>;
mcpDiagnosticsLoading: boolean;
mcpDiagnosticsLoadingByProjectPath: Record<string, boolean>;
mcpDiagnosticsError: string | null;
mcpDiagnosticsErrorByProjectPath: Record<string, string | null>;
mcpDiagnosticsLastCheckedAt: number | null;
mcpDiagnosticsLastCheckedAtByProjectPath: Record<string, number | null>;
// ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>;
@ -137,7 +143,7 @@ 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;
const mcpDiagnosticsInFlightByKey = new Map<string, Promise<void>>();
let skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0;
const latestSkillsCatalogRequestByKey = new Map<string, number>();
@ -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<AppState, [], [], ExtensionsSli
mcpBrowseLoading: false,
mcpBrowseError: null,
mcpInstalledServers: [],
mcpInstalledServersByProjectPath: {},
mcpInstalledProjectPath: null,
mcpDiagnostics: {},
mcpDiagnosticsByProjectPath: {},
mcpDiagnosticsLoading: false,
mcpDiagnosticsLoadingByProjectPath: {},
mcpDiagnosticsError: null,
mcpDiagnosticsErrorByProjectPath: {},
mcpDiagnosticsLastCheckedAt: null,
mcpDiagnosticsLastCheckedAtByProjectPath: {},
pluginInstallProgress: {},
mcpInstallProgress: {},
@ -519,6 +546,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const installed = await api.mcpRegistry.getInstalled(projectPath);
set((prev) => {
const nextProjectPath = projectPath ?? null;
const stateKey = getMcpProjectStateKey(nextProjectPath);
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
const nextOperationState = isSameProjectContext
? {
@ -533,6 +561,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return {
mcpInstalledServers: installed,
mcpInstalledServersByProjectPath: {
...prev.mcpInstalledServersByProjectPath,
[stateKey]: installed,
},
mcpInstalledProjectPath: nextProjectPath,
mcpInstallProgress: nextOperationState.mcpInstallProgress,
installErrors: nextOperationState.installErrors,
@ -546,38 +578,72 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
runMcpDiagnostics: async (projectPath?: string) => {
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<AppState, [], [], ExtensionsSli
// ── MCP install ──
installMcpServer: async (request: McpInstallRequest) => {
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<AppState, [], [], ExtensionsSli
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
get().mcpFetchInstalled(request.projectPath),
get().runMcpDiagnostics(request.projectPath),
]);
set((prev) => ({
@ -989,7 +1055,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── MCP custom install ──
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
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<AppState, [], [], ExtensionsSli
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
get().mcpFetchInstalled(request.projectPath),
get().runMcpDiagnostics(request.projectPath),
]);
set((prev) => ({
@ -1043,7 +1113,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
) => {
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<AppState, [], [], ExtensionsSli
}
await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined),
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined),
get().mcpFetchInstalled(projectPath),
get().runMcpDiagnostics(projectPath),
]);
set((prev) => ({

View file

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

View file

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

View file

@ -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 () => {

View file

@ -18,11 +18,19 @@ interface StoreState {
mcpBrowseError: string | null;
mcpBrowse: ReturnType<typeof vi.fn>;
mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>;
mcpInstalledServersByProjectPath?: Record<
string,
Array<{ name: string; scope: 'local' | 'user' | 'project' }>
>;
fetchMcpGitHubStars: ReturnType<typeof vi.fn>;
mcpDiagnostics: Record<string, never>;
mcpDiagnosticsByProjectPath?: Record<string, Record<string, never>>;
mcpDiagnosticsLoading: boolean;
mcpDiagnosticsLoadingByProjectPath?: Record<string, boolean>;
mcpDiagnosticsError: string | null;
mcpDiagnosticsErrorByProjectPath?: Record<string, string | null>;
mcpDiagnosticsLastCheckedAt: number | null;
mcpDiagnosticsLastCheckedAtByProjectPath?: Record<string, number | null>;
runMcpDiagnostics: ReturnType<typeof vi.fn>;
}
@ -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();
});

View file

@ -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<typeof vi.fn>)
.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<typeof vi.fn>).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<typeof vi.fn>)
.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<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(api.mcpRegistry!.getInstalled).toHaveBeenLastCalledWith('/tmp/project-a');
expect(api.mcpRegistry!.diagnose).toHaveBeenLastCalledWith('/tmp/project-a');
});
});
describe('skills state hardening', () => {

View file

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