fix(extensions): scope mcp operation state by install scope
This commit is contained in:
parent
94291f50f0
commit
66cf1443b2
8 changed files with 359 additions and 49 deletions
|
|
@ -13,6 +13,7 @@ import { useStore } from '@renderer/store';
|
||||||
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
import {
|
import {
|
||||||
getMcpInstallationSummaryLabel,
|
getMcpInstallationSummaryLabel,
|
||||||
|
getMcpOperationKey,
|
||||||
sanitizeMcpServerName,
|
sanitizeMcpServerName,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
||||||
|
|
@ -46,10 +47,11 @@ export const McpServerCard = ({
|
||||||
diagnosticsLoading,
|
diagnosticsLoading,
|
||||||
onClick,
|
onClick,
|
||||||
}: McpServerCardProps): React.JSX.Element => {
|
}: McpServerCardProps): React.JSX.Element => {
|
||||||
const installProgress = useStore((s) => s.mcpInstallProgress[server.id] ?? 'idle');
|
const operationKey = getMcpOperationKey(server.id, 'user');
|
||||||
|
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
const installError = useStore((s) => s.installErrors[server.id]);
|
const installError = useStore((s) => s.installErrors[operationKey]);
|
||||||
const stars = useStore((s) =>
|
const stars = useStore((s) =>
|
||||||
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import {
|
import {
|
||||||
getMcpInstallationSummaryLabel,
|
getMcpInstallationSummaryLabel,
|
||||||
|
getMcpOperationKey,
|
||||||
getPreferredMcpInstallationEntry,
|
getPreferredMcpInstallationEntry,
|
||||||
sanitizeMcpServerName,
|
sanitizeMcpServerName,
|
||||||
} from '@shared/utils/extensionNormalizers';
|
} from '@shared/utils/extensionNormalizers';
|
||||||
|
|
@ -73,17 +74,18 @@ export const McpServerDetailDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: McpServerDetailDialogProps): React.JSX.Element => {
|
}: McpServerDetailDialogProps): React.JSX.Element => {
|
||||||
|
const [scope, setScope] = useState<Scope>('user');
|
||||||
|
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
|
||||||
const installProgress = useStore(
|
const installProgress = useStore(
|
||||||
(s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle'
|
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||||
);
|
);
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
|
const installError = useStore((s) => (operationKey ? s.installErrors[operationKey] : undefined));
|
||||||
const stars = useStore((s) =>
|
const stars = useStore((s) =>
|
||||||
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
|
||||||
const [serverName, setServerName] = useState('');
|
const [serverName, setServerName] = useState('');
|
||||||
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
||||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
import { getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ export interface ExtensionsSlice {
|
||||||
// ── Install progress ──
|
// ── Install progress ──
|
||||||
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
pluginInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
installErrors: Record<string, string>; // keyed by pluginId or registryId
|
installErrors: Record<string, string>; // keyed by scoped operation key
|
||||||
|
|
||||||
// ── API Keys ──
|
// ── API Keys ──
|
||||||
apiKeys: ApiKeyEntry[];
|
apiKeys: ApiKeyEntry[];
|
||||||
|
|
@ -131,6 +131,7 @@ export interface ExtensionsSlice {
|
||||||
let pluginFetchInFlight: { key: string; promise: Promise<void> } | null = null;
|
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>>();
|
||||||
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
let mcpDiagnosticsInFlight: Promise<void> | null = null;
|
||||||
let skillsCatalogRequestSeq = 0;
|
let skillsCatalogRequestSeq = 0;
|
||||||
let skillsDetailRequestSeq = 0;
|
let skillsDetailRequestSeq = 0;
|
||||||
|
|
@ -221,6 +222,82 @@ function schedulePluginSuccessReset(
|
||||||
pluginSuccessResetTimers.set(operationKey, timer);
|
pluginSuccessResetTimers.set(operationKey, timer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCustomMcpOperationKey(serverName: string, scope: InstallScope): string {
|
||||||
|
return `mcp-custom:${serverName}:${scope}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMcpSuccessResetTimer(operationKey: string): void {
|
||||||
|
const timer = mcpSuccessResetTimers.get(operationKey);
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
mcpSuccessResetTimers.delete(operationKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMcpSuccessReset(
|
||||||
|
operationKey: string,
|
||||||
|
set: Parameters<StateCreator<AppState, [], [], ExtensionsSlice>>[0]
|
||||||
|
): void {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
mcpSuccessResetTimers.delete(operationKey);
|
||||||
|
set((prev) => {
|
||||||
|
if (prev.mcpInstallProgress[operationKey] !== 'success') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'idle' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, SUCCESS_DISPLAY_MS);
|
||||||
|
mcpSuccessResetTimers.set(operationKey, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMcpProjectScopedOperationState(
|
||||||
|
mcpInstallProgress: Record<string, ExtensionOperationState>,
|
||||||
|
installErrors: Record<string, string>
|
||||||
|
): {
|
||||||
|
mcpInstallProgress: Record<string, ExtensionOperationState>;
|
||||||
|
installErrors: Record<string, string>;
|
||||||
|
} {
|
||||||
|
const nextMcpInstallProgress = { ...mcpInstallProgress };
|
||||||
|
const nextInstallErrors = { ...installErrors };
|
||||||
|
|
||||||
|
for (const operationKey of Object.keys(nextMcpInstallProgress)) {
|
||||||
|
if (
|
||||||
|
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||||
|
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
||||||
|
) {
|
||||||
|
delete nextMcpInstallProgress[operationKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const operationKey of Object.keys(nextInstallErrors)) {
|
||||||
|
if (
|
||||||
|
(operationKey.startsWith('mcp:') || operationKey.startsWith('mcp-custom:')) &&
|
||||||
|
(operationKey.endsWith(':project') || operationKey.endsWith(':local'))
|
||||||
|
) {
|
||||||
|
delete nextInstallErrors[operationKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mcpInstallProgress: nextMcpInstallProgress,
|
||||||
|
installErrors: nextInstallErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMcpProjectScopedSuccessResetTimers(): void {
|
||||||
|
for (const operationKey of Array.from(mcpSuccessResetTimers.keys())) {
|
||||||
|
if (operationKey.endsWith(':project') || operationKey.endsWith(':local')) {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getSkillsCatalogKey(projectPath?: string): string {
|
function getSkillsCatalogKey(projectPath?: string): string {
|
||||||
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
return projectPath ?? USER_SKILLS_CATALOG_KEY;
|
||||||
}
|
}
|
||||||
|
|
@ -434,9 +511,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
const installed = await api.mcpRegistry.getInstalled(projectPath);
|
||||||
set({
|
set((prev) => {
|
||||||
mcpInstalledServers: installed,
|
const nextProjectPath = projectPath ?? null;
|
||||||
mcpInstalledProjectPath: projectPath ?? null,
|
const isSameProjectContext = prev.mcpInstalledProjectPath === nextProjectPath;
|
||||||
|
const nextOperationState = isSameProjectContext
|
||||||
|
? {
|
||||||
|
mcpInstallProgress: prev.mcpInstallProgress,
|
||||||
|
installErrors: prev.installErrors,
|
||||||
|
}
|
||||||
|
: clearMcpProjectScopedOperationState(prev.mcpInstallProgress, prev.installErrors);
|
||||||
|
|
||||||
|
if (!isSameProjectContext) {
|
||||||
|
clearMcpProjectScopedSuccessResetTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mcpInstalledServers: installed,
|
||||||
|
mcpInstalledProjectPath: nextProjectPath,
|
||||||
|
mcpInstallProgress: nextOperationState.mcpInstallProgress,
|
||||||
|
installErrors: nextOperationState.installErrors,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Silently fail — installed state is supplementary
|
// Silently fail — installed state is supplementary
|
||||||
|
|
@ -833,29 +927,32 @@ 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);
|
||||||
if (!api.mcpRegistry) {
|
if (!api.mcpRegistry) {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: {
|
installErrors: {
|
||||||
...prev.installErrors,
|
...prev.installErrors,
|
||||||
[request.registryId]: 'MCP Registry not available',
|
[operationKey]: 'MCP Registry not available',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.mcpRegistry.install(request);
|
const result = await api.mcpRegistry.install(request);
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: {
|
installErrors: {
|
||||||
...prev.installErrors,
|
...prev.installErrors,
|
||||||
[request.registryId]: result.error ?? 'Install failed',
|
[operationKey]: result.error ?? 'Install failed',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
|
|
@ -867,27 +964,26 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'success' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setTimeout(() => {
|
scheduleMcpSuccessReset(operationKey, set);
|
||||||
set((prev) => ({
|
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'idle' },
|
|
||||||
}));
|
|
||||||
}, SUCCESS_DISPLAY_MS);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
const message = err instanceof Error ? err.message : 'Install failed';
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [request.registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [request.registryId]: message },
|
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── MCP custom install ──
|
// ── MCP custom install ──
|
||||||
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
installCustomMcpServer: async (request: McpCustomInstallRequest) => {
|
||||||
|
const operationScope = request.scope;
|
||||||
|
const progressKey = getCustomMcpOperationKey(request.serverName, operationScope);
|
||||||
if (!api.mcpRegistry) {
|
if (!api.mcpRegistry) {
|
||||||
const progressKey = `custom:${request.serverName}`;
|
clearMcpSuccessResetTimer(progressKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
||||||
|
|
@ -895,7 +991,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressKey = `custom:${request.serverName}`;
|
clearMcpSuccessResetTimer(progressKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
||||||
}));
|
}));
|
||||||
|
|
@ -919,12 +1015,9 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setTimeout(() => {
|
scheduleMcpSuccessReset(progressKey, set);
|
||||||
set((prev) => ({
|
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'idle' },
|
|
||||||
}));
|
|
||||||
}, SUCCESS_DISPLAY_MS);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearMcpSuccessResetTimer(progressKey);
|
||||||
const message = err instanceof Error ? err.message : 'Install failed';
|
const message = err instanceof Error ? err.message : 'Install failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||||
|
|
@ -940,26 +1033,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
scope?: string,
|
scope?: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
) => {
|
) => {
|
||||||
|
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
|
||||||
|
const operationKey = getMcpOperationKey(registryId, operationScope);
|
||||||
if (!api.mcpRegistry) {
|
if (!api.mcpRegistry) {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [registryId]: 'MCP Registry not available' },
|
installErrors: { ...prev.installErrors, [operationKey]: 'MCP Registry not available' },
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.mcpRegistry.uninstall(name, scope, projectPath);
|
const result = await api.mcpRegistry.uninstall(name, scope, projectPath);
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: {
|
installErrors: {
|
||||||
...prev.installErrors,
|
...prev.installErrors,
|
||||||
[registryId]: result.error ?? 'Uninstall failed',
|
[operationKey]: result.error ?? 'Uninstall failed',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
|
|
@ -971,19 +1068,16 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
]);
|
]);
|
||||||
|
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'success' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'success' },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
setTimeout(() => {
|
scheduleMcpSuccessReset(operationKey, set);
|
||||||
set((prev) => ({
|
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'idle' },
|
|
||||||
}));
|
|
||||||
}, SUCCESS_DISPLAY_MS);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
const message = err instanceof Error ? err.message : 'Uninstall failed';
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [registryId]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [registryId]: message },
|
installErrors: { ...prev.installErrors, [operationKey]: message },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,13 @@ export function getPluginOperationKey(pluginId: string, scope: InstallScope): st
|
||||||
return `plugin:${pluginId}:${scope}`;
|
return `plugin:${pluginId}:${scope}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namespaced operation-state key for MCP install/uninstall UI state.
|
||||||
|
*/
|
||||||
|
export function getMcpOperationKey(registryId: string, scope: InstallScope): string {
|
||||||
|
return `mcp:${registryId}:${scope}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether a plugin has an installation for the selected scope.
|
* Check whether a plugin has an installation for the selected scope.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
|
||||||
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
|
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface StoreState {
|
interface StoreState {
|
||||||
|
|
@ -55,7 +56,23 @@ vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
InstallButton: () => React.createElement('button', { type: 'button', 'data-testid': 'install-button' }, 'Install'),
|
InstallButton: ({
|
||||||
|
state,
|
||||||
|
errorMessage,
|
||||||
|
}: {
|
||||||
|
state?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}) =>
|
||||||
|
React.createElement(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
'data-testid': 'install-button',
|
||||||
|
'data-state': state,
|
||||||
|
'data-error': errorMessage,
|
||||||
|
},
|
||||||
|
'Install'
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
|
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
|
||||||
|
|
@ -225,4 +242,47 @@ describe('McpServerCard direct action safety', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads direct-action state from the user-scope operation key', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const installedEntry: InstalledMcpEntry = {
|
||||||
|
name: 'context7',
|
||||||
|
scope: 'user',
|
||||||
|
};
|
||||||
|
|
||||||
|
storeState.mcpInstallProgress = {
|
||||||
|
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error',
|
||||||
|
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'pending',
|
||||||
|
};
|
||||||
|
storeState.installErrors = {
|
||||||
|
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed',
|
||||||
|
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'User failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(McpServerCard, {
|
||||||
|
server: makeServer(),
|
||||||
|
isInstalled: true,
|
||||||
|
installedEntry,
|
||||||
|
installedEntries: [installedEntry],
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
onClick: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||||
|
expect(installButton.dataset.state).toBe('pending');
|
||||||
|
expect(installButton.dataset.error).toBe('User failed');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { getMcpOperationKey } from '@shared/utils/extensionNormalizers';
|
||||||
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
|
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface StoreState {
|
interface StoreState {
|
||||||
|
|
@ -104,10 +105,14 @@ vi.mock('@renderer/components/ui/select', () => ({
|
||||||
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
InstallButton: ({
|
InstallButton: ({
|
||||||
isInstalled,
|
isInstalled,
|
||||||
|
state,
|
||||||
|
errorMessage,
|
||||||
onInstall,
|
onInstall,
|
||||||
onUninstall,
|
onUninstall,
|
||||||
}: {
|
}: {
|
||||||
isInstalled: boolean;
|
isInstalled: boolean;
|
||||||
|
state?: string;
|
||||||
|
errorMessage?: string;
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onUninstall: () => void;
|
onUninstall: () => void;
|
||||||
}) =>
|
}) =>
|
||||||
|
|
@ -116,6 +121,8 @@ vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
{
|
{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
'data-testid': 'install-button',
|
'data-testid': 'install-button',
|
||||||
|
'data-state': state,
|
||||||
|
'data-error': errorMessage,
|
||||||
onClick: () => (isInstalled ? onUninstall() : onInstall()),
|
onClick: () => (isInstalled ? onUninstall() : onInstall()),
|
||||||
},
|
},
|
||||||
isInstalled ? 'Uninstall' : 'Install'
|
isInstalled ? 'Uninstall' : 'Install'
|
||||||
|
|
@ -533,4 +540,53 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads install state from the selected scope operation key', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
storeState.mcpInstallProgress = {
|
||||||
|
[getMcpOperationKey('io.github.upstash/context7', 'user')]: 'success',
|
||||||
|
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'error',
|
||||||
|
};
|
||||||
|
storeState.installErrors = {
|
||||||
|
[getMcpOperationKey('io.github.upstash/context7', 'project')]: 'Project failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(McpServerDetailDialog, {
|
||||||
|
server: makeServer(),
|
||||||
|
isInstalled: false,
|
||||||
|
installedEntry: null,
|
||||||
|
installedEntries: [],
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
projectPath: '/tmp/project',
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
|
||||||
|
expect(installButton.dataset.state).toBe('success');
|
||||||
|
expect(installButton.dataset.error ?? '').toBe('');
|
||||||
|
|
||||||
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||||
|
await act(async () => {
|
||||||
|
scopeSelect.value = 'project';
|
||||||
|
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(installButton.dataset.state).toBe('error');
|
||||||
|
expect(installButton.dataset.error).toBe('Project failed');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,10 @@ vi.mock('../../../src/renderer/api', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { api } from '../../../src/renderer/api';
|
import { api } from '../../../src/renderer/api';
|
||||||
import { getPluginOperationKey } from '../../../src/shared/utils/extensionNormalizers';
|
import {
|
||||||
|
getMcpOperationKey,
|
||||||
|
getPluginOperationKey,
|
||||||
|
} from '../../../src/shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EnrichedPlugin,
|
EnrichedPlugin,
|
||||||
|
|
@ -136,6 +139,8 @@ 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') =>
|
||||||
|
getMcpOperationKey(registryId, scope);
|
||||||
|
|
||||||
describe('extensionsSlice', () => {
|
describe('extensionsSlice', () => {
|
||||||
let store: TestStore;
|
let store: TestStore;
|
||||||
|
|
@ -371,6 +376,43 @@ describe('extensionsSlice', () => {
|
||||||
|
|
||||||
expect(store.getState().mcpInstalledServers).toEqual(installed);
|
expect(store.getState().mcpInstalledServers).toEqual(installed);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears stale project- and local-scoped MCP operation state when project changes', async () => {
|
||||||
|
store.setState({
|
||||||
|
mcpInstalledProjectPath: '/tmp/project-a',
|
||||||
|
mcpInstallProgress: {
|
||||||
|
[mcpOperationKey('project-server', 'project')]: 'error',
|
||||||
|
[mcpOperationKey('local-server', 'local')]: 'success',
|
||||||
|
[mcpOperationKey('user-server', 'user')]: 'pending',
|
||||||
|
},
|
||||||
|
installErrors: {
|
||||||
|
[mcpOperationKey('project-server', 'project')]: 'Project failed',
|
||||||
|
[mcpOperationKey('local-server', 'local')]: 'Local failed',
|
||||||
|
[mcpOperationKey('user-server', 'user')]: 'Keep user state',
|
||||||
|
'plugin:test@marketplace:user': 'Keep plugin state',
|
||||||
|
'mcp-custom:custom-server:project': 'Clear custom project state',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await store.getState().mcpFetchInstalled('/tmp/project-b');
|
||||||
|
|
||||||
|
expect(store.getState().mcpInstalledProjectPath).toBe('/tmp/project-b');
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('project-server', 'project')]).toBeUndefined();
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('local-server', 'local')]).toBeUndefined();
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('user-server', 'user')]).toBe(
|
||||||
|
'pending',
|
||||||
|
);
|
||||||
|
expect(store.getState().installErrors[mcpOperationKey('project-server', 'project')]).toBeUndefined();
|
||||||
|
expect(store.getState().installErrors[mcpOperationKey('local-server', 'local')]).toBeUndefined();
|
||||||
|
expect(store.getState().installErrors[mcpOperationKey('user-server', 'user')]).toBe(
|
||||||
|
'Keep user state',
|
||||||
|
);
|
||||||
|
expect(store.getState().installErrors['mcp-custom:custom-server:project']).toBeUndefined();
|
||||||
|
expect(store.getState().installErrors['plugin:test@marketplace:user']).toBe(
|
||||||
|
'Keep plugin state',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('openExtensionsTab', () => {
|
describe('openExtensionsTab', () => {
|
||||||
|
|
@ -663,10 +705,44 @@ describe('extensionsSlice', () => {
|
||||||
headers: [],
|
headers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending');
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||||
|
'pending',
|
||||||
|
);
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restore idle state after project switch clears a pending project-scope success timer', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
store.setState({
|
||||||
|
mcpInstalledProjectPath: '/tmp/project-a',
|
||||||
|
});
|
||||||
|
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
|
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await store.getState().installMcpServer({
|
||||||
|
registryId: 'test-id',
|
||||||
|
serverName: 'test-server',
|
||||||
|
scope: 'project',
|
||||||
|
projectPath: '/tmp/project-a',
|
||||||
|
envValues: {},
|
||||||
|
headers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBe(
|
||||||
|
'success',
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.getState().mcpFetchInstalled('/tmp/project-b');
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined();
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(2_000);
|
||||||
|
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project')]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -678,10 +754,14 @@ describe('extensionsSlice', () => {
|
||||||
|
|
||||||
const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user');
|
const promise = store.getState().uninstallMcpServer('test-id', 'test-server', 'user');
|
||||||
|
|
||||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('pending');
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||||
|
'pending',
|
||||||
|
);
|
||||||
|
|
||||||
await promise;
|
await promise;
|
||||||
expect(store.getState().mcpInstallProgress['test-id']).toBe('success');
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'user')]).toBe(
|
||||||
|
'success',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
getCapabilityLabel,
|
getCapabilityLabel,
|
||||||
getInstallationSummaryLabel,
|
getInstallationSummaryLabel,
|
||||||
getMcpInstallationSummaryLabel,
|
getMcpInstallationSummaryLabel,
|
||||||
|
getMcpOperationKey,
|
||||||
getPreferredMcpInstallationEntry,
|
getPreferredMcpInstallationEntry,
|
||||||
getPluginOperationKey,
|
getPluginOperationKey,
|
||||||
getPrimaryCapabilityLabel,
|
getPrimaryCapabilityLabel,
|
||||||
|
|
@ -163,6 +164,14 @@ describe('getPluginOperationKey', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getMcpOperationKey', () => {
|
||||||
|
it('namespaces MCP operation keys by scope', () => {
|
||||||
|
expect(getMcpOperationKey('io.github.upstash/context7', 'project')).toBe(
|
||||||
|
'mcp:io.github.upstash/context7:project',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('hasInstallationInScope', () => {
|
describe('hasInstallationInScope', () => {
|
||||||
it('returns true when the selected scope exists', () => {
|
it('returns true when the selected scope exists', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue