From 0d6276ea0ba5d41abda1d81c4aefc88dfa7e7284 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:31:56 +0300 Subject: [PATCH] fix(extensions): honor installed MCP targets in manage actions --- .../extensions/mcp/McpServerCard.tsx | 26 +- .../extensions/mcp/McpServerDetailDialog.tsx | 20 +- .../extensions/mcp/McpServersPanel.tsx | 2 + .../extensions/mcp/McpServerCard.test.ts | 193 +++++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 222 ++++++++++++++++++ 5 files changed, 453 insertions(+), 10 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/McpServerCard.test.ts create mode 100644 test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index e2f04f87..d6586ff3 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -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 = ({ )} - {canAutoInstall && !requiresConfiguration && ( + {shouldShowDirectInstallButton && (
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} />
)} - {canAutoInstall && requiresConfiguration && ( + {canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index 1a831f0f..c21b181c 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -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 = ({ ; + installMcpServer: ReturnType; + uninstallMcpServer: ReturnType; + installErrors: Record; + mcpGitHubStars: Record; +} + +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) => 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) => 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(); + }); + }); +}); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts new file mode 100644 index 00000000..5329963a --- /dev/null +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -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; + installMcpServer: ReturnType; + uninstallMcpServer: ReturnType; + installErrors: Record; + mcpGitHubStars: Record; +} + +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) => + 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) => 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) => 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(); + }); + }); +});