fix(extensions): scope mcp renderer state by project
This commit is contained in:
parent
81c59440bf
commit
489e3eb967
9 changed files with 277 additions and 61 deletions
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue