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 cliStatus = useStore((s) => s.cliStatus);
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
const [scope, setScope] = useState<Scope>(defaultSharedScope); 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( const installProgress = useStore(
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle' (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 { CLI_NOT_FOUND_MARKER } from '@shared/constants/cli';
import { import {
getMcpDiagnosticKey, getMcpDiagnosticKey,
getMcpProjectStateKey,
getPreferredMcpInstallationEntry, getPreferredMcpInstallationEntry,
sanitizeMcpServerName, sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
@ -79,18 +80,24 @@ export const McpServersPanel = ({
selectedMcpServerId, selectedMcpServerId,
setSelectedMcpServerId, setSelectedMcpServerId,
}: McpServersPanelProps): React.JSX.Element => { }: McpServersPanelProps): React.JSX.Element => {
const projectStateKey = getMcpProjectStateKey(projectPath);
const { const {
browseCatalog, browseCatalog,
browseNextCursor, browseNextCursor,
browseLoading, browseLoading,
browseError, browseError,
mcpBrowse, mcpBrowse,
installedServers, installedServersByProjectPath,
installedServersFallback,
fetchMcpGitHubStars, fetchMcpGitHubStars,
mcpDiagnostics, mcpDiagnosticsByProjectPath,
mcpDiagnosticsLoading, mcpDiagnosticsFallback,
mcpDiagnosticsError, mcpDiagnosticsLoadingByProjectPath,
mcpDiagnosticsLastCheckedAt, mcpDiagnosticsLoadingFallback,
mcpDiagnosticsErrorByProjectPath,
mcpDiagnosticsErrorFallback,
mcpDiagnosticsLastCheckedAtByProjectPath,
mcpDiagnosticsLastCheckedAtFallback,
runMcpDiagnostics, runMcpDiagnostics,
cliStatus, cliStatus,
} = useStore( } = useStore(
@ -100,16 +107,33 @@ export const McpServersPanel = ({
browseLoading: s.mcpBrowseLoading, browseLoading: s.mcpBrowseLoading,
browseError: s.mcpBrowseError, browseError: s.mcpBrowseError,
mcpBrowse: s.mcpBrowse, mcpBrowse: s.mcpBrowse,
installedServers: s.mcpInstalledServers, installedServersByProjectPath: s.mcpInstalledServersByProjectPath,
installedServersFallback: s.mcpInstalledServers,
fetchMcpGitHubStars: s.fetchMcpGitHubStars, fetchMcpGitHubStars: s.fetchMcpGitHubStars,
mcpDiagnostics: s.mcpDiagnostics, mcpDiagnosticsByProjectPath: s.mcpDiagnosticsByProjectPath,
mcpDiagnosticsLoading: s.mcpDiagnosticsLoading, mcpDiagnosticsFallback: s.mcpDiagnostics,
mcpDiagnosticsError: s.mcpDiagnosticsError, mcpDiagnosticsLoadingByProjectPath: s.mcpDiagnosticsLoadingByProjectPath,
mcpDiagnosticsLastCheckedAt: s.mcpDiagnosticsLastCheckedAt, mcpDiagnosticsLoadingFallback: s.mcpDiagnosticsLoading,
mcpDiagnosticsErrorByProjectPath: s.mcpDiagnosticsErrorByProjectPath,
mcpDiagnosticsErrorFallback: s.mcpDiagnosticsError,
mcpDiagnosticsLastCheckedAtByProjectPath: s.mcpDiagnosticsLastCheckedAtByProjectPath,
mcpDiagnosticsLastCheckedAtFallback: s.mcpDiagnosticsLastCheckedAt,
runMcpDiagnostics: s.runMcpDiagnostics, runMcpDiagnostics: s.runMcpDiagnostics,
cliStatus: s.cliStatus, 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'); 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 { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
import { import {
getMcpDiagnosticKey, getMcpDiagnosticKey,
getMcpProjectStateKey,
getMcpOperationKey, getMcpOperationKey,
getPluginOperationKey, getPluginOperationKey,
} from '@shared/utils/extensionNormalizers'; } from '@shared/utils/extensionNormalizers';
@ -56,11 +57,16 @@ export interface ExtensionsSlice {
mcpBrowseLoading: boolean; mcpBrowseLoading: boolean;
mcpBrowseError: string | null; mcpBrowseError: string | null;
mcpInstalledServers: InstalledMcpEntry[]; mcpInstalledServers: InstalledMcpEntry[];
mcpInstalledServersByProjectPath: Record<string, InstalledMcpEntry[]>;
mcpInstalledProjectPath: string | null; mcpInstalledProjectPath: string | null;
mcpDiagnostics: Record<string, McpServerDiagnostic>; mcpDiagnostics: Record<string, McpServerDiagnostic>;
mcpDiagnosticsByProjectPath: Record<string, Record<string, McpServerDiagnostic>>;
mcpDiagnosticsLoading: boolean; mcpDiagnosticsLoading: boolean;
mcpDiagnosticsLoadingByProjectPath: Record<string, boolean>;
mcpDiagnosticsError: string | null; mcpDiagnosticsError: string | null;
mcpDiagnosticsErrorByProjectPath: Record<string, string | null>;
mcpDiagnosticsLastCheckedAt: number | null; mcpDiagnosticsLastCheckedAt: number | null;
mcpDiagnosticsLastCheckedAtByProjectPath: Record<string, number | null>;
// ── Install progress ── // ── Install progress ──
pluginInstallProgress: Record<string, ExtensionOperationState>; pluginInstallProgress: Record<string, ExtensionOperationState>;
@ -137,7 +143,7 @@ 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>>(); 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 skillsCatalogRequestSeq = 0;
let skillsDetailRequestSeq = 0; let skillsDetailRequestSeq = 0;
const latestSkillsCatalogRequestByKey = new Map<string, number>(); const latestSkillsCatalogRequestByKey = new Map<string, number>();
@ -227,10 +233,26 @@ function schedulePluginSuccessReset(
pluginSuccessResetTimers.set(operationKey, timer); 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}`; 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 { function clearMcpSuccessResetTimer(operationKey: string): void {
const timer = mcpSuccessResetTimers.get(operationKey); const timer = mcpSuccessResetTimers.get(operationKey);
if (!timer) { if (!timer) {
@ -274,7 +296,7 @@ function clearMcpProjectScopedOperationState(
for (const operationKey of Object.keys(nextMcpInstallProgress)) { for (const operationKey of Object.keys(nextMcpInstallProgress)) {
if ( if (
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
(operationKey.endsWith(':project') || operationKey.endsWith(':local')) isProjectScopedMcpOperationKey(operationKey)
) { ) {
delete nextMcpInstallProgress[operationKey]; delete nextMcpInstallProgress[operationKey];
} }
@ -283,7 +305,7 @@ function clearMcpProjectScopedOperationState(
for (const operationKey of Object.keys(nextInstallErrors)) { for (const operationKey of Object.keys(nextInstallErrors)) {
if ( if (
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) && (operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
(operationKey.endsWith(':project') || operationKey.endsWith(':local')) isProjectScopedMcpOperationKey(operationKey)
) { ) {
delete nextInstallErrors[operationKey]; delete nextInstallErrors[operationKey];
} }
@ -297,7 +319,7 @@ function clearMcpProjectScopedOperationState(
function clearMcpProjectScopedSuccessResetTimers(): void { function clearMcpProjectScopedSuccessResetTimers(): void {
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) { for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) { if (isProjectScopedMcpOperationKey(operationKey)) {
clearMcpSuccessResetTimer(operationKey); clearMcpSuccessResetTimer(operationKey);
} }
} }
@ -336,11 +358,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
mcpBrowseLoading: false, mcpBrowseLoading: false,
mcpBrowseError: null, mcpBrowseError: null,
mcpInstalledServers: [], mcpInstalledServers: [],
mcpInstalledServersByProjectPath: {},
mcpInstalledProjectPath: null, mcpInstalledProjectPath: null,
mcpDiagnostics: {}, mcpDiagnostics: {},
mcpDiagnosticsByProjectPath: {},
mcpDiagnosticsLoading: false, mcpDiagnosticsLoading: false,
mcpDiagnosticsLoadingByProjectPath: {},
mcpDiagnosticsError: null, mcpDiagnosticsError: null,
mcpDiagnosticsErrorByProjectPath: {},
mcpDiagnosticsLastCheckedAt: null, mcpDiagnosticsLastCheckedAt: null,
mcpDiagnosticsLastCheckedAtByProjectPath: {},
pluginInstallProgress: {}, pluginInstallProgress: {},
mcpInstallProgress: {}, mcpInstallProgress: {},
@ -519,6 +546,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const installed = await api.mcpRegistry.getInstalled(projectPath); const installed = await api.mcpRegistry.getInstalled(projectPath);
set((prev) => { set((prev) => {
const nextProjectPath = projectPath ?? null; const nextProjectPath = projectPath ?? null;
const stateKey = getMcpProjectStateKey(nextProjectPath);
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath; const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
const nextOperationState = isSameProjectContext const nextOperationState = isSameProjectContext
? { ? {
@ -533,6 +561,10 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
return { return {
mcpInstalledServers: installed, mcpInstalledServers: installed,
mcpInstalledServersByProjectPath: {
...prev.mcpInstalledServersByProjectPath,
[stateKey]: installed,
},
mcpInstalledProjectPath: nextProjectPath, mcpInstalledProjectPath: nextProjectPath,
mcpInstallProgress: nextOperationState.mcpInstallProgress, mcpInstallProgress: nextOperationState.mcpInstallProgress,
installErrors: nextOperationState.installErrors, installErrors: nextOperationState.installErrors,
@ -546,38 +578,72 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
runMcpDiagnostics: async (projectPath?: string) => { runMcpDiagnostics: async (projectPath?: string) => {
const mcpRegistry = api.mcpRegistry; const mcpRegistry = api.mcpRegistry;
if (!mcpRegistry) return; if (!mcpRegistry) return;
const projectStateKey = getMcpProjectStateKey(projectPath);
if (mcpDiagnosticsInFlight) { const existing = mcpDiagnosticsInFlightByKey.get(projectStateKey);
await mcpDiagnosticsInFlight; if (existing) {
await existing;
return; 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 () => { const promise = (async () => {
try { try {
const diagnostics = await mcpRegistry.diagnose(projectPath); 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({ set({
mcpDiagnostics: Object.fromEntries( mcpDiagnostics: diagnosticsRecord,
diagnostics.map(
(entry) => [getMcpDiagnosticKey(entry.name, entry.scope), entry] as const
)
),
mcpDiagnosticsLoading: false, mcpDiagnosticsLoading: false,
mcpDiagnosticsLastCheckedAt: Date.now(), mcpDiagnosticsByProjectPath: {
...get().mcpDiagnosticsByProjectPath,
[projectStateKey]: diagnosticsRecord,
},
mcpDiagnosticsLoadingByProjectPath: {
...get().mcpDiagnosticsLoadingByProjectPath,
[projectStateKey]: false,
},
mcpDiagnosticsLastCheckedAt: checkedAt,
mcpDiagnosticsLastCheckedAtByProjectPath: {
...get().mcpDiagnosticsLastCheckedAtByProjectPath,
[projectStateKey]: checkedAt,
},
}); });
} catch (err) { } catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to check MCP server health';
set({ set({
mcpDiagnosticsLoading: false, mcpDiagnosticsLoading: false,
mcpDiagnosticsError: mcpDiagnosticsError: errorMessage,
err instanceof Error ? err.message : 'Failed to check MCP server health', mcpDiagnosticsLoadingByProjectPath: {
...get().mcpDiagnosticsLoadingByProjectPath,
[projectStateKey]: false,
},
mcpDiagnosticsErrorByProjectPath: {
...get().mcpDiagnosticsErrorByProjectPath,
[projectStateKey]: errorMessage,
},
}); });
} finally { } finally {
mcpDiagnosticsInFlight = null; mcpDiagnosticsInFlightByKey.delete(projectStateKey);
} }
})(); })();
mcpDiagnosticsInFlight = promise; mcpDiagnosticsInFlightByKey.set(projectStateKey, promise);
await promise; await promise;
}, },
@ -935,7 +1001,7 @@ 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); const operationKey = getMcpOperationKey(request.registryId, request.scope, request.projectPath);
if (!api.mcpRegistry) { if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey); clearMcpSuccessResetTimer(operationKey);
set((prev) => ({ set((prev) => ({
@ -967,8 +1033,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
} }
await Promise.all([ await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined), get().mcpFetchInstalled(request.projectPath),
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined), get().runMcpDiagnostics(request.projectPath),
]); ]);
set((prev) => ({ set((prev) => ({
@ -989,7 +1055,11 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
// ── MCP custom install ── // ── MCP custom install ──
installCustomMcpServer: async (request: McpCustomInstallRequest) => { installCustomMcpServer: async (request: McpCustomInstallRequest) => {
const operationScope = request.scope; const operationScope = request.scope;
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope); const progressKey = getCustomMcpOperationKey(
request.serverName,
operationScope,
request.projectPath
);
if (!api.mcpRegistry) { if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(progressKey); clearMcpSuccessResetTimer(progressKey);
set((prev) => ({ set((prev) => ({
@ -1015,8 +1085,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
} }
await Promise.all([ await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined), get().mcpFetchInstalled(request.projectPath),
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined), get().runMcpDiagnostics(request.projectPath),
]); ]);
set((prev) => ({ set((prev) => ({
@ -1043,7 +1113,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
) => { ) => {
const operationScope: InstallScope = const operationScope: InstallScope =
scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user'; scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user';
const operationKey = getMcpOperationKey(registryId, operationScope); const operationKey = getMcpOperationKey(registryId, operationScope, projectPath);
if (!api.mcpRegistry) { if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey); clearMcpSuccessResetTimer(operationKey);
set((prev) => ({ set((prev) => ({
@ -1072,8 +1142,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
} }
await Promise.all([ await Promise.all([
get().mcpFetchInstalled(get().mcpInstalledProjectPath ?? undefined), get().mcpFetchInstalled(projectPath),
get().runMcpDiagnostics(get().mcpInstalledProjectPath ?? undefined), get().runMcpDiagnostics(projectPath),
]); ]);
set((prev) => ({ 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. * 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}`; 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}`; 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. * Check whether a plugin has an installation for the selected scope.
*/ */

View file

@ -255,11 +255,12 @@ describe('McpServerCard direct action safety', () => {
}; };
storeState.mcpInstallProgress = { 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', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending',
}; };
storeState.installErrors = { 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', [getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed',
}; };

View file

@ -581,10 +581,11 @@ describe('McpServerDetailDialog installed entry handling', () => {
const root = createRoot(host); const root = createRoot(host);
storeState.mcpInstallProgress = { storeState.mcpInstallProgress = {
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success', [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 = { storeState.installErrors = {
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed', [getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')]:
'Project failed',
}; };
await act(async () => { await act(async () => {

View file

@ -18,11 +18,19 @@ interface StoreState {
mcpBrowseError: string | null; mcpBrowseError: string | null;
mcpBrowse: ReturnType<typeof vi.fn>; mcpBrowse: ReturnType<typeof vi.fn>;
mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>; mcpInstalledServers: Array<{ name: string; scope: 'local' | 'user' | 'project' }>;
mcpInstalledServersByProjectPath?: Record<
string,
Array<{ name: string; scope: 'local' | 'user' | 'project' }>
>;
fetchMcpGitHubStars: ReturnType<typeof vi.fn>; fetchMcpGitHubStars: ReturnType<typeof vi.fn>;
mcpDiagnostics: Record<string, never>; mcpDiagnostics: Record<string, never>;
mcpDiagnosticsByProjectPath?: Record<string, Record<string, never>>;
mcpDiagnosticsLoading: boolean; mcpDiagnosticsLoading: boolean;
mcpDiagnosticsLoadingByProjectPath?: Record<string, boolean>;
mcpDiagnosticsError: string | null; mcpDiagnosticsError: string | null;
mcpDiagnosticsErrorByProjectPath?: Record<string, string | null>;
mcpDiagnosticsLastCheckedAt: number | null; mcpDiagnosticsLastCheckedAt: number | null;
mcpDiagnosticsLastCheckedAtByProjectPath?: Record<string, number | null>;
runMcpDiagnostics: ReturnType<typeof vi.fn>; runMcpDiagnostics: ReturnType<typeof vi.fn>;
} }
@ -121,11 +129,16 @@ describe('McpServersPanel initial browse loading', () => {
storeState.mcpBrowseError = null; storeState.mcpBrowseError = null;
storeState.mcpBrowse = vi.fn(); storeState.mcpBrowse = vi.fn();
storeState.mcpInstalledServers = []; storeState.mcpInstalledServers = [];
storeState.mcpInstalledServersByProjectPath = undefined;
storeState.fetchMcpGitHubStars = vi.fn(); storeState.fetchMcpGitHubStars = vi.fn();
storeState.mcpDiagnostics = {}; storeState.mcpDiagnostics = {};
storeState.mcpDiagnosticsByProjectPath = undefined;
storeState.mcpDiagnosticsLoading = false; storeState.mcpDiagnosticsLoading = false;
storeState.mcpDiagnosticsLoadingByProjectPath = undefined;
storeState.mcpDiagnosticsError = null; storeState.mcpDiagnosticsError = null;
storeState.mcpDiagnosticsErrorByProjectPath = undefined;
storeState.mcpDiagnosticsLastCheckedAt = null; storeState.mcpDiagnosticsLastCheckedAt = null;
storeState.mcpDiagnosticsLastCheckedAtByProjectPath = undefined;
storeState.runMcpDiagnostics = vi.fn(); storeState.runMcpDiagnostics = vi.fn();
}); });

View file

@ -56,6 +56,7 @@ vi.mock('../../../src/renderer/api', () => ({
import { api } from '../../../src/renderer/api'; import { api } from '../../../src/renderer/api';
import { import {
getMcpDiagnosticKey, getMcpDiagnosticKey,
getMcpProjectStateKey,
getMcpOperationKey, getMcpOperationKey,
getPluginOperationKey, getPluginOperationKey,
} from '../../../src/shared/utils/extensionNormalizers'; } from '../../../src/shared/utils/extensionNormalizers';
@ -154,8 +155,11 @@ 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') => const mcpOperationKey = (
getMcpOperationKey(registryId, scope); registryId: string,
scope: 'user' | 'project' | 'local' | 'global' = 'user',
projectPath?: string
) => getMcpOperationKey(registryId, scope, projectPath);
describe('extensionsSlice', () => { describe('extensionsSlice', () => {
let store: TestStore; let store: TestStore;
@ -393,20 +397,34 @@ describe('extensionsSlice', () => {
expect(store.getState().mcpInstalledServers).toEqual(installed); 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 () => { it('clears stale project- and local-scoped MCP operation state when project changes', async () => {
store.setState({ store.setState({
mcpInstalledProjectPath: '/tmp/project-a', mcpInstalledProjectPath: '/tmp/project-a',
mcpInstallProgress: { mcpInstallProgress: {
[mcpOperationKey('project-server', 'project')]: 'error', [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'error',
[mcpOperationKey('local-server', 'local')]: 'success', [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'success',
[mcpOperationKey('user-server', 'user')]: 'pending', [mcpOperationKey('user-server', 'user')]: 'pending',
}, },
installErrors: { installErrors: {
[mcpOperationKey('project-server', 'project')]: 'Project failed', [mcpOperationKey('project-server', 'project', '/tmp/project-a')]: 'Project failed',
[mcpOperationKey('local-server', 'local')]: 'Local failed', [mcpOperationKey('local-server', 'local', '/tmp/project-a')]: 'Local failed',
[mcpOperationKey('user-server', 'user')]: 'Keep user state', [mcpOperationKey('user-server', 'user')]: 'Keep user state',
'plugin:test@marketplace:user': 'Keep plugin 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([]); (api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
@ -414,13 +432,21 @@ describe('extensionsSlice', () => {
await store.getState().mcpFetchInstalled('/tmp/project-b'); await store.getState().mcpFetchInstalled('/tmp/project-b');
expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b'); expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b');
expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined(); expect(
expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined(); 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( expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe(
'pending', 'pending',
); );
expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined(); expect(
expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined(); 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( expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe(
'Keep user state', 'Keep user state',
); );
@ -749,16 +775,20 @@ describe('extensionsSlice', () => {
headers: [], headers: [],
}); });
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe( expect(
'success', store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')]
); ).toBe('success');
await store.getState().mcpFetchInstalled('/tmp/project-b'); 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); 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', () => { describe('skills state hardening', () => {

View file

@ -166,8 +166,8 @@ describe('getPluginOperationKey', () => {
describe('getMcpOperationKey', () => { describe('getMcpOperationKey', () => {
it('namespaces MCP operation keys by scope', () => { it('namespaces MCP operation keys by scope', () => {
expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe( expect(getMcpOperationKey('io.github.upstash/context7', 'project', '/tmp/project')).toBe(
'mcp:io.github.upstash/context7:project', 'mcp:io.github.upstash/context7:project:/tmp/project',
); );
}); });
}); });