fix(extensions): support project-scoped mcp installs
This commit is contained in:
parent
3f03bc720a
commit
0420428281
9 changed files with 415 additions and 10 deletions
|
|
@ -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)
|
// 3. Validate env var keys (prevent command injection)
|
||||||
for (const key of Object.keys(envValues)) {
|
for (const key of Object.keys(envValues)) {
|
||||||
if (!ENV_KEY_RE.test(key)) {
|
if (!ENV_KEY_RE.test(key)) {
|
||||||
|
|
@ -212,6 +219,10 @@ export class McpInstallService {
|
||||||
return { state: 'error', error: `Invalid scope: "${scope}".` };
|
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)) {
|
for (const key of Object.keys(envValues)) {
|
||||||
if (!ENV_KEY_RE.test(key)) {
|
if (!ENV_KEY_RE.test(key)) {
|
||||||
return { state: 'error', error: `Invalid env var name: "${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)) {
|
if (projectPath && !path.isAbsolute(projectPath)) {
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
state: 'error',
|
||||||
|
|
|
||||||
|
|
@ -340,6 +340,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
|
||||||
<TabsContent value="mcp-servers" className="mt-0 pt-4">
|
<TabsContent value="mcp-servers" className="mt-0 pt-4">
|
||||||
<McpServersPanel
|
<McpServersPanel
|
||||||
|
projectPath={projectPath}
|
||||||
mcpSearchQuery={tabState.mcpSearchQuery}
|
mcpSearchQuery={tabState.mcpSearchQuery}
|
||||||
mcpSearch={tabState.mcpSearch}
|
mcpSearch={tabState.mcpSearch}
|
||||||
mcpSearchResults={tabState.mcpSearchResults}
|
mcpSearchResults={tabState.mcpSearchResults}
|
||||||
|
|
@ -372,6 +373,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
<CustomMcpServerDialog
|
<CustomMcpServerDialog
|
||||||
open={customMcpDialogOpen}
|
open={customMcpDialogOpen}
|
||||||
onClose={() => setCustomMcpDialogOpen(false)}
|
onClose={() => setCustomMcpDialogOpen(false)}
|
||||||
|
projectPath={projectPath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,14 +37,16 @@ const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
||||||
interface CustomMcpServerDialogProps {
|
interface CustomMcpServerDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
projectPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransportMode = 'stdio' | 'http';
|
type TransportMode = 'stdio' | 'http';
|
||||||
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
||||||
type Scope = 'local' | 'user';
|
type Scope = 'local' | 'user' | 'project';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
{ value: 'user', label: 'User (global)' },
|
{ value: 'user', label: 'User (global)' },
|
||||||
|
{ value: 'project', label: 'Project' },
|
||||||
{ value: 'local', label: 'Local' },
|
{ value: 'local', label: 'Local' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -62,6 +64,7 @@ interface EnvEntry {
|
||||||
export const CustomMcpServerDialog = ({
|
export const CustomMcpServerDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
projectPath,
|
||||||
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
||||||
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
||||||
|
|
||||||
|
|
@ -101,6 +104,12 @@ export const CustomMcpServerDialog = ({
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && scope === 'project' && !projectPath) {
|
||||||
|
setScope('user');
|
||||||
|
}
|
||||||
|
}, [open, projectPath, scope]);
|
||||||
|
|
||||||
// Auto-fill env vars from saved API keys
|
// Auto-fill env vars from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || envVars.length === 0 || !api.apiKeys) return;
|
if (!open || envVars.length === 0 || !api.apiKeys) return;
|
||||||
|
|
@ -168,6 +177,7 @@ export const CustomMcpServerDialog = ({
|
||||||
const request: McpCustomInstallRequest = {
|
const request: McpCustomInstallRequest = {
|
||||||
serverName,
|
serverName,
|
||||||
scope,
|
scope,
|
||||||
|
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||||
installSpec,
|
installSpec,
|
||||||
envValues,
|
envValues,
|
||||||
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
|
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
|
||||||
|
|
@ -197,6 +207,7 @@ export const CustomMcpServerDialog = ({
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
serverName.trim() &&
|
serverName.trim() &&
|
||||||
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
||||||
|
!(scope === 'project' && !projectPath) &&
|
||||||
!installing;
|
!installing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -372,7 +383,11 @@ export const CustomMcpServerDialog = ({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{SCOPE_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
disabled={opt.value === 'project' && !projectPath}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -44,14 +44,16 @@ interface McpServerDetailDialogProps {
|
||||||
installedEntry?: InstalledMcpEntry | null;
|
installedEntry?: InstalledMcpEntry | null;
|
||||||
diagnostic?: McpServerDiagnostic | null;
|
diagnostic?: McpServerDiagnostic | null;
|
||||||
diagnosticsLoading?: boolean;
|
diagnosticsLoading?: boolean;
|
||||||
|
projectPath: string | null;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Scope = 'local' | 'user';
|
type Scope = 'local' | 'user' | 'project';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
||||||
{ value: 'user', label: 'User (global)' },
|
{ value: 'user', label: 'User (global)' },
|
||||||
|
{ value: 'project', label: 'Project' },
|
||||||
{ value: 'local', label: 'Local' },
|
{ value: 'local', label: 'Local' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -61,6 +63,7 @@ export const McpServerDetailDialog = ({
|
||||||
installedEntry,
|
installedEntry,
|
||||||
diagnostic,
|
diagnostic,
|
||||||
diagnosticsLoading,
|
diagnosticsLoading,
|
||||||
|
projectPath,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: McpServerDetailDialogProps): React.JSX.Element => {
|
}: McpServerDetailDialogProps): React.JSX.Element => {
|
||||||
|
|
@ -100,11 +103,17 @@ export const McpServerDetailDialog = ({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name));
|
setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name));
|
||||||
setScope(installedEntry?.scope === 'local' ? 'local' : 'user');
|
setScope(installedEntry?.scope ?? 'user');
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
setAutoFilledFields(new Set());
|
setAutoFilledFields(new Set());
|
||||||
}, [installedEntry?.name, installedEntry?.scope, open, server?.id]);
|
}, [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
|
// Auto-fill env values from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
||||||
|
|
@ -144,9 +153,15 @@ export const McpServerDetailDialog = ({
|
||||||
const missingRequiredHeaders = headers.some(
|
const missingRequiredHeaders = headers.some(
|
||||||
(header) => header.isRequired && !header.value.trim()
|
(header) => header.isRequired && !header.value.trim()
|
||||||
);
|
);
|
||||||
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
|
|
||||||
const uninstallServerName = installedEntry?.name ?? serverName;
|
const uninstallServerName = installedEntry?.name ?? serverName;
|
||||||
const uninstallScope = installedEntry?.scope ?? scope;
|
const uninstallScope = installedEntry?.scope ?? scope;
|
||||||
|
const scopeRequiresProjectPath =
|
||||||
|
(scope === 'project' || uninstallScope === 'project') && !projectPath;
|
||||||
|
const installDisabled =
|
||||||
|
!serverName.trim() ||
|
||||||
|
missingRequiredEnvVars ||
|
||||||
|
missingRequiredHeaders ||
|
||||||
|
scopeRequiresProjectPath;
|
||||||
const diagnosticBadgeClass =
|
const diagnosticBadgeClass =
|
||||||
diagnostic?.status === 'connected'
|
diagnostic?.status === 'connected'
|
||||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||||
|
|
@ -161,13 +176,19 @@ export const McpServerDetailDialog = ({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
serverName,
|
serverName,
|
||||||
scope,
|
scope,
|
||||||
|
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
|
||||||
envValues,
|
envValues,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = () => {
|
const handleUninstall = () => {
|
||||||
uninstallMcpServer(server.id, uninstallServerName, uninstallScope);
|
uninstallMcpServer(
|
||||||
|
server.id,
|
||||||
|
uninstallServerName,
|
||||||
|
uninstallScope,
|
||||||
|
uninstallScope === 'project' ? (projectPath ?? undefined) : undefined
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
|
|
@ -370,7 +391,11 @@ export const McpServerDetailDialog = ({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{SCOPE_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
disabled={opt.value === 'project' && !projectPath}
|
||||||
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatal
|
||||||
}
|
}
|
||||||
|
|
||||||
interface McpServersPanelProps {
|
interface McpServersPanelProps {
|
||||||
|
projectPath: string | null;
|
||||||
mcpSearchQuery: string;
|
mcpSearchQuery: string;
|
||||||
mcpSearch: (query: string) => void;
|
mcpSearch: (query: string) => void;
|
||||||
mcpSearchResults: McpCatalogItem[];
|
mcpSearchResults: McpCatalogItem[];
|
||||||
|
|
@ -65,6 +66,7 @@ interface McpServersPanelProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const McpServersPanel = ({
|
export const McpServersPanel = ({
|
||||||
|
projectPath,
|
||||||
mcpSearchQuery,
|
mcpSearchQuery,
|
||||||
mcpSearch,
|
mcpSearch,
|
||||||
mcpSearchResults,
|
mcpSearchResults,
|
||||||
|
|
@ -404,6 +406,7 @@ export const McpServersPanel = ({
|
||||||
installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null}
|
installedEntry={selectedServer ? getInstalledEntry(selectedServer) : null}
|
||||||
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
|
diagnostic={selectedServer ? getDiagnostic(selectedServer) : null}
|
||||||
diagnosticsLoading={mcpDiagnosticsLoading}
|
diagnosticsLoading={mcpDiagnosticsLoading}
|
||||||
|
projectPath={projectPath}
|
||||||
open={selectedMcpServerId !== null}
|
open={selectedMcpServerId !== null}
|
||||||
onClose={() => setSelectedMcpServerId(null)}
|
onClose={() => setSelectedMcpServerId(null)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,20 @@ describe('McpInstallService', () => {
|
||||||
expect(result.state).toBe('error');
|
expect(result.state).toBe('error');
|
||||||
expect(result.error).toContain('Manual setup required');
|
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 ──────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────────────
|
// ── uninstall ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('uninstall', () => {
|
describe('uninstall', () => {
|
||||||
|
|
@ -293,6 +326,14 @@ describe('McpInstallService', () => {
|
||||||
expect(mockExecCli).not.toHaveBeenCalled();
|
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 () => {
|
it('returns error on CLI failure', async () => {
|
||||||
mockExecCli.mockRejectedValue(new Error('Not found'));
|
mockExecCli.mockRejectedValue(new Error('Not found'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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,
|
||||||
|
disabled,
|
||||||
|
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
||||||
|
React.createElement('option', { value, disabled }, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => {
|
||||||
|
const Icon = (props: React.SVGProps<SVGSVGElement>) => 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -93,8 +93,12 @@ vi.mock('@renderer/components/ui/select', () => ({
|
||||||
SelectValue: () => null,
|
SelectValue: () => null,
|
||||||
SelectContent: ({ children }: React.PropsWithChildren) =>
|
SelectContent: ({ children }: React.PropsWithChildren) =>
|
||||||
React.createElement(React.Fragment, null, children),
|
React.createElement(React.Fragment, null, children),
|
||||||
SelectItem: ({ children, value }: React.PropsWithChildren<{ value: string }>) =>
|
SelectItem: ({
|
||||||
React.createElement('option', { value }, children),
|
children,
|
||||||
|
value,
|
||||||
|
disabled,
|
||||||
|
}: React.PropsWithChildren<{ value: string; disabled?: boolean }>) =>
|
||||||
|
React.createElement('option', { value, disabled }, children),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
vi.mock('@renderer/components/extensions/common/InstallButton', () => ({
|
||||||
|
|
@ -187,6 +191,7 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
installedEntry,
|
installedEntry,
|
||||||
diagnostic: null,
|
diagnostic: null,
|
||||||
diagnosticsLoading: false,
|
diagnosticsLoading: false,
|
||||||
|
projectPath: '/tmp/project',
|
||||||
open: true,
|
open: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -211,7 +216,8 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
|
expect(storeState.uninstallMcpServer).toHaveBeenCalledWith(
|
||||||
'io.github.upstash/context7',
|
'io.github.upstash/context7',
|
||||||
'context7-local',
|
'context7-local',
|
||||||
'local'
|
'local',
|
||||||
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -241,6 +247,7 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
installedEntry: null,
|
installedEntry: null,
|
||||||
diagnostic: null,
|
diagnostic: null,
|
||||||
diagnosticsLoading: false,
|
diagnosticsLoading: false,
|
||||||
|
projectPath: null,
|
||||||
open: true,
|
open: true,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
})
|
})
|
||||||
|
|
@ -257,4 +264,89 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
await Promise.resolve();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ describe('McpServersPanel initial browse loading', () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(McpServersPanel, {
|
React.createElement(McpServersPanel, {
|
||||||
|
projectPath: null,
|
||||||
mcpSearchQuery: '',
|
mcpSearchQuery: '',
|
||||||
mcpSearch: vi.fn(),
|
mcpSearch: vi.fn(),
|
||||||
mcpSearchResults: [],
|
mcpSearchResults: [],
|
||||||
|
|
@ -171,6 +172,7 @@ describe('McpServersPanel initial browse loading', () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(McpServersPanel, {
|
React.createElement(McpServersPanel, {
|
||||||
|
projectPath: null,
|
||||||
mcpSearchQuery: '',
|
mcpSearchQuery: '',
|
||||||
mcpSearch: vi.fn(),
|
mcpSearch: vi.fn(),
|
||||||
mcpSearchResults: [],
|
mcpSearchResults: [],
|
||||||
|
|
@ -189,6 +191,7 @@ describe('McpServersPanel initial browse loading', () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(
|
root.render(
|
||||||
React.createElement(McpServersPanel, {
|
React.createElement(McpServersPanel, {
|
||||||
|
projectPath: null,
|
||||||
mcpSearchQuery: '',
|
mcpSearchQuery: '',
|
||||||
mcpSearch: vi.fn(),
|
mcpSearch: vi.fn(),
|
||||||
mcpSearchResults: [],
|
mcpSearchResults: [],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue