fix(extensions): require project context for local mcp scope

This commit is contained in:
777genius 2026-04-16 22:51:05 +03:00
parent a3c5b7dca9
commit 94291f50f0
6 changed files with 205 additions and 17 deletions

View file

@ -37,6 +37,10 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
const TIMEOUT_MS = 30_000;
function scopeRequiresProjectPath(scope?: string): boolean {
return scope === 'local' || scope === 'project';
}
export class McpInstallService {
constructor(private readonly aggregator: McpCatalogAggregator) {}
@ -59,10 +63,10 @@ export class McpInstallService {
};
}
if (scope === 'project' && !projectPath) {
if (scopeRequiresProjectPath(scope) && !projectPath) {
return {
state: 'error',
error: 'projectPath is required for project scope',
error: `projectPath is required for ${scope} scope`,
};
}
@ -219,8 +223,8 @@ export class McpInstallService {
return { state: 'error', error: `Invalid scope: "${scope}".` };
}
if (scope === 'project' && !projectPath) {
return { state: 'error', error: 'projectPath is required for project scope' };
if (scopeRequiresProjectPath(scope) && !projectPath) {
return { state: 'error', error: `projectPath is required for ${scope} scope` };
}
for (const key of Object.keys(envValues)) {
@ -330,10 +334,10 @@ export class McpInstallService {
};
}
if (scope === 'project' && !projectPath) {
if (scopeRequiresProjectPath(scope) && !projectPath) {
return {
state: 'error',
error: 'projectPath is required for project scope',
error: `projectPath is required for ${scope} scope`,
};
}

View file

@ -105,7 +105,7 @@ export const CustomMcpServerDialog = ({
}, [open]);
useEffect(() => {
if (open && scope === 'project' && !projectPath) {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
}
}, [open, projectPath, scope]);
@ -177,7 +177,7 @@ export const CustomMcpServerDialog = ({
const request: McpCustomInstallRequest = {
serverName,
scope,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
installSpec,
envValues,
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
@ -207,7 +207,7 @@ export const CustomMcpServerDialog = ({
const canSubmit =
serverName.trim() &&
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
!(scope === 'project' && !projectPath) &&
!(scope !== 'user' && !projectPath) &&
!installing;
return (
@ -386,7 +386,7 @@ export const CustomMcpServerDialog = ({
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value === 'project' && !projectPath}
disabled={opt.value !== 'user' && !projectPath}
>
{opt.label}
</SelectItem>

View file

@ -132,7 +132,7 @@ export const McpServerDetailDialog = ({
}, [open, scope, selectedInstalledEntry?.name, server]);
useEffect(() => {
if (open && scope === 'project' && !projectPath) {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
}
}, [open, projectPath, scope]);
@ -179,7 +179,7 @@ export const McpServerDetailDialog = ({
const isInstalledForScope = selectedInstalledEntry !== null;
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
const scopeRequiresProjectPath = scope === 'project' && !projectPath;
const scopeRequiresProjectPath = scope !== 'user' && !projectPath;
const installDisabled =
!serverName.trim() ||
missingRequiredEnvVars ||
@ -199,7 +199,7 @@ export const McpServerDetailDialog = ({
registryId: server.id,
serverName,
scope,
projectPath: scope === 'project' ? (projectPath ?? undefined) : undefined,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
envValues,
headers,
});
@ -210,7 +210,7 @@ export const McpServerDetailDialog = ({
server.id,
uninstallServerName,
uninstallScope,
uninstallScope === 'project' ? (projectPath ?? undefined) : undefined
uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined
);
};
@ -417,7 +417,7 @@ export const McpServerDetailDialog = ({
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value === 'project' && !projectPath}
disabled={opt.value !== 'user' && !projectPath}
>
{opt.label}
</SelectItem>

View file

@ -130,6 +130,7 @@ describe('McpInstallService', () => {
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'local',
projectPath: '/tmp/test',
envValues: {},
headers: [],
});
@ -230,6 +231,20 @@ describe('McpInstallService', () => {
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('rejects local scope install without project path', async () => {
const result = await service.install({
registryId: 'upstash/context7-mcp',
serverName: 'context7',
scope: 'local',
envValues: {},
headers: [],
});
expect(result.state).toBe('error');
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
});
// ── install: error masking ──────────────────────────────────────────────────
@ -290,6 +305,23 @@ describe('McpInstallService', () => {
expect(result.error).toContain('projectPath is required');
expect(mockExecCli).not.toHaveBeenCalled();
});
it('rejects local scope custom install without project path', async () => {
const result = await service.installCustom({
serverName: 'custom-context7',
scope: 'local',
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 ───────────────────────────────────────────────────────────────
@ -334,6 +366,14 @@ describe('McpInstallService', () => {
expect(mockExecCli).not.toHaveBeenCalled();
});
it('rejects local scope uninstall without project path', async () => {
const result = await service.uninstall('context7', 'local');
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'));

View file

@ -125,7 +125,7 @@ describe('CustomMcpServerDialog project scope', () => {
vi.unstubAllGlobals();
});
it('disables project scope without an active project', async () => {
it('disables non-user scopes without an active project', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
@ -142,7 +142,9 @@ describe('CustomMcpServerDialog project scope', () => {
});
const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement;
const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement;
expect(projectOption.disabled).toBe(true);
expect(localOption.disabled).toBe(true);
await act(async () => {
root.unmount();
@ -203,4 +205,58 @@ describe('CustomMcpServerDialog project scope', () => {
await Promise.resolve();
});
});
it('passes projectPath for local-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, 'local-context7', 'input');
setNativeValue(packageInput, '@upstash/context7-mcp', 'input');
setNativeValue(scopeSelect, 'local', '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: 'local-context7',
scope: 'local',
projectPath,
})
);
expect(onClose).toHaveBeenCalledTimes(1);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -217,7 +217,7 @@ describe('McpServerDetailDialog installed entry handling', () => {
'io.github.upstash/context7',
'context7-local',
'local',
undefined
'/tmp/project'
);
await act(async () => {
@ -258,6 +258,10 @@ describe('McpServerDetailDialog installed entry handling', () => {
expect(lookupMock).toHaveBeenCalledTimes(1);
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']);
const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement;
const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement;
expect(projectOption.disabled).toBe(true);
expect(localOption.disabled).toBe(true);
await act(async () => {
root.unmount();
@ -352,6 +356,90 @@ describe('McpServerDetailDialog installed entry handling', () => {
});
});
it('passes project path for local-scoped installs and uninstalls', async () => {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const projectPath = '/tmp/local-context7';
const installedEntry: InstalledMcpEntry = {
name: 'context7-local',
scope: 'local',
};
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: true,
installedEntry,
installedEntries: [installedEntry],
diagnostic: null,
diagnosticsLoading: false,
projectPath,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
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',
projectPath
);
await act(async () => {
root.render(
React.createElement(McpServerDetailDialog, {
server: makeServer(),
isInstalled: false,
installedEntry: null,
installedEntries: [],
diagnostic: null,
diagnosticsLoading: false,
projectPath,
open: true,
onClose: vi.fn(),
})
);
await Promise.resolve();
});
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
await act(async () => {
scopeSelect.value = 'local';
scopeSelect.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: 'local',
projectPath,
})
);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses selected scope instead of aggregated installed state', async () => {
const host = document.createElement('div');
document.body.appendChild(host);