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 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'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => ({
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue