fix(extensions): honor installed MCP targets in manage actions

This commit is contained in:
777genius 2026-04-16 22:31:56 +03:00
parent acabe52ae7
commit 0d6276ea0b
5 changed files with 453 additions and 10 deletions

View file

@ -18,11 +18,16 @@ import { Github as GithubIcon } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import type { McpCatalogItem, McpServerDiagnostic } from '@shared/types/extensions';
import type {
InstalledMcpEntry,
McpCatalogItem,
McpServerDiagnostic,
} from '@shared/types/extensions';
interface McpServerCardProps {
server: McpCatalogItem;
isInstalled: boolean;
installedEntry?: InstalledMcpEntry | null;
diagnostic?: McpServerDiagnostic | null;
diagnosticsLoading?: boolean;
onClick: (serverId: string) => void;
@ -31,6 +36,7 @@ interface McpServerCardProps {
export const McpServerCard = ({
server,
isInstalled,
installedEntry,
diagnostic,
diagnosticsLoading,
onClick,
@ -48,6 +54,14 @@ export const McpServerCard = ({
server.envVars.length > 0 ||
server.requiresAuth ||
(server.authHeaders?.length ?? 0) > 0;
const defaultServerName = sanitizeMcpServerName(server.name);
const supportsDirectInstalledAction =
isInstalled &&
installedEntry?.scope === 'user' &&
installedEntry.name === defaultServerName &&
!requiresConfiguration;
const shouldShowDirectInstallButton =
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
const [imgError, setImgError] = useState(false);
const hasIcon = !!server.iconUrl && !imgError;
const diagnosticBadgeClass =
@ -224,7 +238,7 @@ export const McpServerCard = ({
</Tooltip>
)}
</div>
{canAutoInstall && !requiresConfiguration && (
{shouldShowDirectInstallButton && (
<div className="shrink-0">
<InstallButton
state={installProgress}
@ -232,19 +246,21 @@ export const McpServerCard = ({
onInstall={() =>
installMcpServer({
registryId: server.id,
serverName: sanitizeMcpServerName(server.name),
serverName: defaultServerName,
scope: 'user',
envValues: {},
headers: [],
})
}
onUninstall={() => uninstallMcpServer(server.id, sanitizeMcpServerName(server.name))}
onUninstall={() =>
uninstallMcpServer(server.id, installedEntry?.name ?? defaultServerName, 'user')
}
size="sm"
errorMessage={installError}
/>
</div>
)}
{canAutoInstall && requiresConfiguration && (
{canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
<div className="shrink-0">
<Button
size="sm"

View file

@ -31,11 +31,17 @@ import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
import { InstallButton } from '../common/InstallButton';
import { SourceBadge } from '../common/SourceBadge';
import type { McpCatalogItem, McpHeaderDef, McpServerDiagnostic } from '@shared/types/extensions';
import type {
InstalledMcpEntry,
McpCatalogItem,
McpHeaderDef,
McpServerDiagnostic,
} from '@shared/types/extensions';
interface McpServerDetailDialogProps {
server: McpCatalogItem | null;
isInstalled: boolean;
installedEntry?: InstalledMcpEntry | null;
diagnostic?: McpServerDiagnostic | null;
diagnosticsLoading?: boolean;
open: boolean;
@ -52,6 +58,7 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
export const McpServerDetailDialog = ({
server,
isInstalled,
installedEntry,
diagnostic,
diagnosticsLoading,
open,
@ -80,7 +87,6 @@ export const McpServerDetailDialog = ({
return;
}
setServerName(sanitizeMcpServerName(server.name));
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
setHeaders(
(server.authHeaders ?? []).map((header) => ({
@ -93,10 +99,11 @@ export const McpServerDetailDialog = ({
locked: true,
}))
);
setScope('user');
setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name));
setScope(installedEntry?.scope === 'local' ? 'local' : 'user');
setImgError(false);
setAutoFilledFields(new Set());
}, [server?.id, open]);
}, [installedEntry?.name, installedEntry?.scope, open, server?.id]);
// Auto-fill env values from saved API keys
useEffect(() => {
@ -170,6 +177,8 @@ export const McpServerDetailDialog = ({
(header) => header.isRequired && !header.value.trim()
);
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
const uninstallServerName = installedEntry?.name ?? serverName;
const uninstallScope = installedEntry?.scope ?? scope;
const diagnosticBadgeClass =
diagnostic?.status === 'connected'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
@ -190,7 +199,7 @@ export const McpServerDetailDialog = ({
};
const handleUninstall = () => {
uninstallMcpServer(server.id, serverName, scope);
uninstallMcpServer(server.id, uninstallServerName, uninstallScope);
};
const addHeader = () => {
@ -380,6 +389,7 @@ export const McpServerDetailDialog = ({
onChange={(e) => setServerName(e.target.value)}
placeholder="my-server"
className="h-8 text-sm"
disabled={isInstalled}
/>
</div>

View file

@ -374,6 +374,7 @@ export const McpServersPanel = ({
key={server.id}
server={server}
isInstalled={isServerInstalled(server)}
installedEntry={getInstalledEntry(server)}
diagnostic={getDiagnostic(server)}
diagnosticsLoading={mcpDiagnosticsLoading}
onClick={setSelectedMcpServerId}
@ -400,6 +401,7 @@ export const McpServersPanel = ({
<McpServerDetailDialog
server={selectedServer}
isInstalled={selectedServer ? isServerInstalled(selectedServer) : false}
installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null}
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
diagnosticsLoading={mcpDiagnosticsLoading}
open={selectedMcpServerId !== null}

View file

@ -0,0 +1,193 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
interface StoreState {
mcpInstallProgress: Record<string, string>;
installMcpServer: ReturnType<typeof vi.fn>;
uninstallMcpServer: ReturnType<typeof vi.fn>;
installErrors: Record<string, string>;
mcpGitHubStars: Record<string, number>;
}
const storeState = {} as StoreState;
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/api', () => ({
api: {
openExternal: vi.fn(),
},
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
}: React.PropsWithChildren<{
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
type?: 'button' | 'submit' | 'reset';
}>) =>
React.createElement(
'button',
{
type,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
InstallButton: () => React.createElement('button', { type: 'button', 'data-testid': 'install-button' }, 'Install'),
}));
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source),
}));
vi.mock('@renderer/utils/formatters', () => ({
formatCompactNumber: (value: number) => String(value),
formatRelativeTime: () => 'recently',
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
Clock: Icon,
Cloud: Icon,
Globe: Icon,
KeyRound: Icon,
Lock: Icon,
Monitor: Icon,
Star: Icon,
Tag: Icon,
Wrench: Icon,
Github: Icon,
};
});
import { McpServerCard } from '@renderer/components/extensions/mcp/McpServerCard';
function makeServer(): McpCatalogItem {
return {
id: 'io.github.upstash/context7',
name: 'Context7',
description: 'Docs server',
source: 'official',
installSpec: {
type: 'stdio',
npmPackage: '@upstash/context7-mcp',
},
envVars: [],
tools: [],
requiresAuth: false,
authHeaders: [],
};
}
describe('McpServerCard direct action safety', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.mcpInstallProgress = {};
storeState.installMcpServer = vi.fn();
storeState.uninstallMcpServer = vi.fn();
storeState.installErrors = {};
storeState.mcpGitHubStars = {};
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('falls back to Manage for installed entries that cannot be safely uninstalled directly', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onClick = vi.fn();
const installedEntry: InstalledMcpEntry = {
name: 'context7-local',
scope: 'local',
};
await act(async () => {
root.render(
React.createElement(McpServerCard, {
server: makeServer(),
isInstalled: true,
installedEntry,
diagnostic: null,
diagnosticsLoading: false,
onClick,
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="install-button"]')).toBeNull();
const manageButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Manage'
) as HTMLButtonElement | undefined;
expect(manageButton).toBeDefined();
await act(async () => {
manageButton?.click();
await Promise.resolve();
});
expect(onClick).toHaveBeenCalledWith('io.github.upstash/context7');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('keeps direct actions for standard user-scope installs', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntry: InstalledMcpEntry = {
name: 'context7',
scope: 'user',
};
await act(async () => {
root.render(
React.createElement(McpServerCard, {
server: makeServer(),
isInstalled: true,
installedEntry,
diagnostic: null,
diagnosticsLoading: false,
onClick: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,222 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledMcpEntry, McpCatalogItem } from '@shared/types/extensions';
interface StoreState {
mcpInstallProgress: Record<string, string>;
installMcpServer: ReturnType<typeof vi.fn>;
uninstallMcpServer: ReturnType<typeof vi.fn>;
installErrors: Record<string, string>;
mcpGitHubStars: Record<string, number>;
}
const storeState = {} as StoreState;
const lookupMock = vi.fn();
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
}));
vi.mock('@renderer/api', () => ({
api: {
openExternal: vi.fn(),
apiKeys: {
lookup: (...args: unknown[]) => lookupMock(...args),
},
},
}));
vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: React.PropsWithChildren) => React.createElement('span', null, children),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
type = 'button',
disabled,
}: React.PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
}>) =>
React.createElement(
'button',
{
type,
disabled,
onClick,
},
children
),
}));
vi.mock('@renderer/components/ui/dialog', () => ({
Dialog: ({ open, children }: React.PropsWithChildren<{ open: boolean }>) =>
open ? React.createElement('div', null, children) : null,
DialogContent: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogHeader: ({ children }: React.PropsWithChildren) => React.createElement('div', null, children),
DialogTitle: ({ children }: React.PropsWithChildren) => React.createElement('h2', null, children),
DialogDescription: ({ children }: React.PropsWithChildren) =>
React.createElement('p', null, children),
}));
vi.mock('@renderer/components/ui/input', () => ({
Input: (props: React.InputHTMLAttributes<HTMLInputElement>) =>
React.createElement('input', props),
}));
vi.mock('@renderer/components/ui/label', () => ({
Label: ({ children }: React.PropsWithChildren) => React.createElement('label', null, children),
}));
vi.mock('@renderer/components/ui/select', () => ({
Select: ({
children,
value,
onValueChange,
}: React.PropsWithChildren<{ value: string; onValueChange: (value: string) => void }>) =>
React.createElement(
'select',
{
'data-testid': 'scope-select',
value,
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => onValueChange(event.target.value),
},
children
),
SelectTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectValue: () => null,
SelectContent: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children),
SelectItem: ({ children, value }: React.PropsWithChildren<{ value: string }>) =>
React.createElement('option', { value }, children),
}));
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
InstallButton: ({
isInstalled,
onInstall,
onUninstall,
}: {
isInstalled: boolean;
onInstall: () => void;
onUninstall: () => void;
}) =>
React.createElement(
'button',
{
type: 'button',
'data-testid': 'install-button',
onClick: () => (isInstalled ? onUninstall() : onInstall()),
},
isInstalled ? 'Uninstall' : 'Install'
),
}));
vi.mock('@renderer/components/extensions/common/SourceBadge', () => ({
SourceBadge: ({ source }: { source: string }) => React.createElement('span', null, source),
}));
vi.mock('lucide-react', () => {
const Icon = (props: React.SVGProps<SVGSVGElement>) => React.createElement('svg', props);
return {
ExternalLink: Icon,
Lock: Icon,
Plus: Icon,
Star: Icon,
Trash2: Icon,
Wrench: Icon,
};
});
import { McpServerDetailDialog } from '@renderer/components/extensions/mcp/McpServerDetailDialog';
function makeServer(): McpCatalogItem {
return {
id: 'io.github.upstash/context7',
name: 'Context7',
description: 'Docs server',
source: 'official',
installSpec: {
type: 'stdio',
npmPackage: '@upstash/context7-mcp',
},
envVars: [],
tools: [],
requiresAuth: false,
authHeaders: [],
};
}
describe('McpServerDetailDialog installed entry handling', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.mcpInstallProgress = {};
storeState.installMcpServer = vi.fn();
storeState.uninstallMcpServer = vi.fn();
storeState.installErrors = {};
storeState.mcpGitHubStars = {};
lookupMock.mockReset();
lookupMock.mockResolvedValue([]);
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('uninstalls using the real installed server name and scope', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const installedEntry: InstalledMcpEntry = {
name: 'context7-local',
scope: 'local',
};
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: true,
installedEntry,
diagnostic: null,
diagnosticsLoading: false,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const serverNameInput = host.querySelector('#server-name') as HTMLInputElement;
expect(serverNameInput).not.toBeNull();
expect(serverNameInput.value).toBe('context7-local');
expect(serverNameInput.disabled).toBe(true);
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
expect(scopeSelect.value).toBe('local');
const uninstallButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement;
await act(async () => {
uninstallButton.click();
await Promise.resolve();
});
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
'io.github.upstash/context7',
'context7-local',
'local'
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});