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();
+ });
+ });
+});