From 0420428281f4771ecb332551655c74bf27bca51c Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:39:03 +0300 Subject: [PATCH] fix(extensions): support project-scoped mcp installs --- .../extensions/install/McpInstallService.ts | 18 ++ .../extensions/ExtensionStoreView.tsx | 2 + .../extensions/mcp/CustomMcpServerDialog.tsx | 19 +- .../extensions/mcp/McpServerDetailDialog.tsx | 35 ++- .../extensions/mcp/McpServersPanel.tsx | 3 + .../extensions/McpInstallService.test.ts | 41 ++++ .../mcp/CustomMcpServerDialog.test.ts | 206 ++++++++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 98 ++++++++- .../extensions/mcp/McpServersPanel.test.ts | 3 + 9 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 3c83c0fe..779e6178 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -59,6 +59,13 @@ export class McpInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project scope', + }; + } + // 3. Validate env var keys (prevent command injection) for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { @@ -212,6 +219,10 @@ export class McpInstallService { return { state: 'error', error: `Invalid scope: "${scope}".` }; } + if (scope === 'project' && !projectPath) { + return { state: 'error', error: 'projectPath is required for project scope' }; + } + for (const key of Object.keys(envValues)) { if (!ENV_KEY_RE.test(key)) { return { state: 'error', error: `Invalid env var name: "${key}".` }; @@ -319,6 +330,13 @@ export class McpInstallService { }; } + if (scope === 'project' && !projectPath) { + return { + state: 'error', + error: 'projectPath is required for project scope', + }; + } + if (projectPath && !path.isAbsolute(projectPath)) { return { state: 'error', diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 84648b44..7f1331c3 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -340,6 +340,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { { setCustomMcpDialogOpen(false)} + projectPath={projectPath} /> diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 35624aad..b096287e 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -37,14 +37,16 @@ const SERVER_NAME_RE = /^[\w.-]{1,100}$/; interface CustomMcpServerDialogProps { open: boolean; onClose: () => void; + projectPath: string | null; } type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; -type Scope = 'local' | 'user'; +type Scope = 'local' | 'user' | 'project'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, + { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; @@ -62,6 +64,7 @@ interface EnvEntry { export const CustomMcpServerDialog = ({ open, onClose, + projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); @@ -101,6 +104,12 @@ export const CustomMcpServerDialog = ({ } }, [open]); + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + // Auto-fill env vars from saved API keys useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; @@ -168,6 +177,7 @@ export const CustomMcpServerDialog = ({ const request: McpCustomInstallRequest = { serverName, scope, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), @@ -197,6 +207,7 @@ export const CustomMcpServerDialog = ({ const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && + !(scope === 'project' && !projectPath) && !installing; return ( @@ -372,7 +383,11 @@ export const CustomMcpServerDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 256b4cc2..391e8239 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -44,14 +44,16 @@ interface McpServerDetailDialogProps { installedEntry?: InstalledMcpEntry | null; diagnostic?: McpServerDiagnostic | null; diagnosticsLoading?: boolean; + projectPath: string | null; open: boolean; onClose: () => void; } -type Scope = 'local' | 'user'; +type Scope = 'local' | 'user' | 'project'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, + { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; @@ -61,6 +63,7 @@ export const McpServerDetailDialog = ({ installedEntry, diagnostic, diagnosticsLoading, + projectPath, open, onClose, }: McpServerDetailDialogProps): React.JSX.Element => { @@ -100,11 +103,17 @@ export const McpServerDetailDialog = ({ })) ); setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name)); - setScope(installedEntry?.scope === 'local' ? 'local' : 'user'); + setScope(installedEntry?.scope ?? 'user'); setImgError(false); setAutoFilledFields(new Set()); }, [installedEntry?.name, installedEntry?.scope, open, server?.id]); + useEffect(() => { + if (open && scope === 'project' && !projectPath) { + setScope('user'); + } + }, [open, projectPath, scope]); + // Auto-fill env values from saved API keys useEffect(() => { if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; @@ -144,9 +153,15 @@ export const McpServerDetailDialog = ({ const missingRequiredHeaders = headers.some( (header) => header.isRequired && !header.value.trim() ); - const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders; const uninstallServerName = installedEntry?.name ?? serverName; const uninstallScope = installedEntry?.scope ?? scope; + const scopeRequiresProjectPath = + (scope === 'project' || uninstallScope === 'project') && !projectPath; + const installDisabled = + !serverName.trim() || + missingRequiredEnvVars || + missingRequiredHeaders || + scopeRequiresProjectPath; const diagnosticBadgeClass = diagnostic?.status === 'connected' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' @@ -161,13 +176,19 @@ export const McpServerDetailDialog = ({ registryId: server.id, serverName, scope, + projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined, envValues, headers, }); }; const handleUninstall = () => { - uninstallMcpServer(server.id, uninstallServerName, uninstallScope); + uninstallMcpServer( + server.id, + uninstallServerName, + uninstallScope, + uninstallScope === 'project' ? (projectPath ?? undefined) : undefined + ); }; const addHeader = () => { @@ -370,7 +391,11 @@ export const McpServerDetailDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - + {opt.label} ))} diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx index c21b181c..fcc3d6f1 100644 --- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx +++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx @@ -55,6 +55,7 @@ function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatal } interface McpServersPanelProps { + projectPath: string | null; mcpSearchQuery: string; mcpSearch: (query: string) => void; mcpSearchResults: McpCatalogItem[]; @@ -65,6 +66,7 @@ interface McpServersPanelProps { } export const McpServersPanel = ({ + projectPath, mcpSearchQuery, mcpSearch, mcpSearchResults, @@ -404,6 +406,7 @@ export const McpServersPanel = ({ installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null} diagnostic={selectedServer ? getDiagnostic(selectedServer) : null} diagnosticsLoading={mcpDiagnosticsLoading} + projectPath={projectPath} open={selectedMcpServerId !== null} onClose={() => setSelectedMcpServerId(null)} /> diff --git a/test/main/services/extensions/McpInstallService.test.ts b/test/main/services/extensions/McpInstallService.test.ts index 40f1dc5a..1320d359 100644 --- a/test/main/services/extensions/McpInstallService.test.ts +++ b/test/main/services/extensions/McpInstallService.test.ts @@ -216,6 +216,20 @@ describe('McpInstallService', () => { expect(result.state).toBe('error'); expect(result.error).toContain('Manual setup required'); }); + + it('rejects project scope install without project path', async () => { + const result = await service.install({ + registryId: 'upstash/context7-mcp', + serverName: 'context7', + scope: 'project', + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); }); // ── install: error masking ────────────────────────────────────────────────── @@ -259,6 +273,25 @@ describe('McpInstallService', () => { }); }); + describe('installCustom (validation)', () => { + it('rejects project scope custom install without project path', async () => { + const result = await service.installCustom({ + serverName: 'custom-context7', + scope: 'project', + installSpec: { + type: 'stdio', + npmPackage: '@upstash/context7-mcp', + }, + envValues: {}, + headers: [], + }); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + }); + // ── uninstall ─────────────────────────────────────────────────────────────── describe('uninstall', () => { @@ -293,6 +326,14 @@ describe('McpInstallService', () => { expect(mockExecCli).not.toHaveBeenCalled(); }); + it('rejects project scope uninstall without project path', async () => { + const result = await service.uninstall('context7', 'project'); + + expect(result.state).toBe('error'); + expect(result.error).toContain('projectPath is required'); + expect(mockExecCli).not.toHaveBeenCalled(); + }); + it('returns error on CLI failure', async () => { mockExecCli.mockRejectedValue(new Error('Not found')); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts new file mode 100644 index 00000000..9dd675ef --- /dev/null +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -0,0 +1,206 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface StoreState { + installCustomMcpServer: ReturnType; +} + +const storeState = {} as StoreState; +const lookupMock = vi.fn(); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('@renderer/api', () => ({ + api: { + apiKeys: { + lookup: (...args: unknown[]) => lookupMock(...args), + }, + }, +})); + +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, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Plus: Icon, + Server: Icon, + Trash2: Icon, + }; +}); + +import { CustomMcpServerDialog } from '@renderer/components/extensions/mcp/CustomMcpServerDialog'; + +function setNativeValue( + element: HTMLInputElement | HTMLSelectElement, + value: string, + eventName: 'input' | 'change' +): void { + const prototype = element instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); + descriptor?.set?.call(element, value); + element.dispatchEvent(new Event(eventName, { bubbles: true })); +} + +describe('CustomMcpServerDialog project scope', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined); + lookupMock.mockReset(); + lookupMock.mockResolvedValue([]); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('disables project scope without an active project', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: null, + }) + ); + await Promise.resolve(); + }); + + const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; + expect(projectOption.disabled).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('passes projectPath for project-scoped custom installs', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const onClose = vi.fn(); + const projectPath = '/tmp/custom-mcp-project'; + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose, + projectPath, + }) + ); + await Promise.resolve(); + }); + + const nameInput = host.querySelector('#custom-name') as HTMLInputElement; + const packageInput = host.querySelector('#custom-npm') as HTMLInputElement; + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + + await act(async () => { + setNativeValue(nameInput, 'custom-context7', 'input'); + setNativeValue(packageInput, '@upstash/context7-mcp', 'input'); + setNativeValue(scopeSelect, 'project', 'change'); + await Promise.resolve(); + }); + + const installButton = Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent === 'Install' + ) as HTMLButtonElement; + expect(installButton.disabled).toBe(false); + + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installCustomMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'custom-context7', + scope: 'project', + projectPath, + }) + ); + expect(onClose).toHaveBeenCalledTimes(1); + + 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 index 8835a9dd..42d178b4 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -93,8 +93,12 @@ vi.mock('@renderer/components/ui/select', () => ({ SelectValue: () => null, SelectContent: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), - SelectItem: ({ children, value }: React.PropsWithChildren<{ value: string }>) => - React.createElement('option', { value }, children), + SelectItem: ({ + children, + value, + disabled, + }: React.PropsWithChildren<{ value: string; disabled?: boolean }>) => + React.createElement('option', { value, disabled }, children), })); vi.mock('@renderer/components/extensions/common/InstallButton', () => ({ @@ -187,6 +191,7 @@ describe('McpServerDetailDialog installed entry handling', () => { installedEntry, diagnostic: null, diagnosticsLoading: false, + projectPath: '/tmp/project', open: true, onClose: vi.fn(), }) @@ -211,7 +216,8 @@ describe('McpServerDetailDialog installed entry handling', () => { expect(storeState.uninstallMcpServer).toHaveBeenCalledWith( 'io.github.upstash/context7', 'context7-local', - 'local' + 'local', + undefined ); await act(async () => { @@ -241,6 +247,7 @@ describe('McpServerDetailDialog installed entry handling', () => { installedEntry: null, diagnostic: null, diagnosticsLoading: false, + projectPath: null, open: true, onClose: vi.fn(), }) @@ -257,4 +264,89 @@ describe('McpServerDetailDialog installed entry handling', () => { await Promise.resolve(); }); }); + + it('passes project path for project-scoped installs and uninstalls', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const projectPath = '/tmp/project-context7'; + const installedEntry: InstalledMcpEntry = { + name: 'context7-project', + scope: 'project', + }; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: true, + installedEntry, + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + expect(scopeSelect.value).toBe('project'); + + 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-project', + 'project', + projectPath + ); + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server: makeServer(), + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath, + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + const installScopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement; + await act(async () => { + installScopeSelect.value = 'project'; + installScopeSelect.dispatchEvent(new Event('change', { bubbles: true })); + await Promise.resolve(); + }); + + const installButton = host.querySelector('[data-testid="install-button"]') as HTMLButtonElement; + await act(async () => { + installButton.click(); + await Promise.resolve(); + }); + + expect(storeState.installMcpServer).toHaveBeenCalledWith( + expect.objectContaining({ + registryId: 'io.github.upstash/context7', + scope: 'project', + projectPath, + }) + ); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts index 7c5956f7..0d9451df 100644 --- a/test/renderer/components/extensions/mcp/McpServersPanel.test.ts +++ b/test/renderer/components/extensions/mcp/McpServersPanel.test.ts @@ -142,6 +142,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], @@ -171,6 +172,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [], @@ -189,6 +191,7 @@ describe('McpServersPanel initial browse loading', () => { await act(async () => { root.render( React.createElement(McpServersPanel, { + projectPath: null, mcpSearchQuery: '', mcpSearch: vi.fn(), mcpSearchResults: [],