fix(extensions): scope mcp api key autofill by install target
This commit is contained in:
parent
24782411f3
commit
5007f3eebb
4 changed files with 232 additions and 21 deletions
|
|
@ -3,7 +3,7 @@
|
|||
* Supports stdio (npm package) and HTTP/SSE transports.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -92,11 +92,15 @@ export const CustomMcpServerDialog = ({
|
|||
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||
const envVarLookupNames = envVars
|
||||
.map((entry) => entry.key.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join('\0');
|
||||
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
|
||||
? (projectPath ?? undefined)
|
||||
: undefined;
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
|
|
@ -112,6 +116,7 @@ export const CustomMcpServerDialog = ({
|
|||
setEnvVars([]);
|
||||
setError(null);
|
||||
setInstalling(false);
|
||||
autoFilledValuesRef.current = {};
|
||||
}
|
||||
}, [defaultSharedScope, open]);
|
||||
|
||||
|
|
@ -128,19 +133,50 @@ export const CustomMcpServerDialog = ({
|
|||
const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean);
|
||||
if (envVarNames.length === 0) return;
|
||||
|
||||
void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then(
|
||||
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
|
||||
(results) => {
|
||||
if (results.length === 0) return;
|
||||
const lookup = new Map(results.map((r) => [r.envVarName, r.value]));
|
||||
setEnvVars((prev) =>
|
||||
prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e))
|
||||
const previousAutoFilledValues = autoFilledValuesRef.current;
|
||||
const nextAutoFilledValues = Object.fromEntries(
|
||||
results.map((result) => [result.envVarName, result.value])
|
||||
);
|
||||
setEnvVars((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((entry) => {
|
||||
const envVarName = entry.key.trim();
|
||||
if (!envVarName) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const previousValue = previousAutoFilledValues[envVarName];
|
||||
const nextValue = nextAutoFilledValues[envVarName];
|
||||
|
||||
if (!nextValue) {
|
||||
if (previousValue && entry.value === previousValue) {
|
||||
changed = true;
|
||||
return { ...entry, value: '' };
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (!entry.value || entry.value === previousValue) {
|
||||
if (entry.value !== nextValue) {
|
||||
changed = true;
|
||||
return { ...entry, value: nextValue };
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
});
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
autoFilledValuesRef.current = nextAutoFilledValues;
|
||||
},
|
||||
() => {
|
||||
// Silently fail
|
||||
}
|
||||
);
|
||||
}, [envVarLookupNames, envVars, open, projectPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [apiKeyLookupProjectPath, envVarLookupNames, open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleInstall = async () => {
|
||||
setError(null);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Uses Radix UI Kit for all form elements.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
|
|
@ -92,6 +92,7 @@ export const McpServerDetailDialog = ({
|
|||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||
const normalizedInstalledEntries = installedEntries.length
|
||||
? installedEntries
|
||||
: installedEntry
|
||||
|
|
@ -115,6 +116,9 @@ export const McpServerDetailDialog = ({
|
|||
.map((entry) => entry.name)
|
||||
.sort()
|
||||
.join('\0') ?? '';
|
||||
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
|
||||
? (projectPath ?? undefined)
|
||||
: undefined;
|
||||
|
||||
// Initialize form when dialog opens or server changes
|
||||
useEffect(() => {
|
||||
|
|
@ -138,6 +142,7 @@ export const McpServerDetailDialog = ({
|
|||
setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope);
|
||||
setImgError(false);
|
||||
setAutoFilledFields(new Set());
|
||||
autoFilledValuesRef.current = {};
|
||||
}, [
|
||||
defaultSharedScope,
|
||||
open,
|
||||
|
|
@ -165,23 +170,38 @@ export const McpServerDetailDialog = ({
|
|||
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
||||
|
||||
const envVarNames = server.envVars.map((e) => e.name);
|
||||
void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then(
|
||||
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
|
||||
(results) => {
|
||||
if (results.length === 0) return;
|
||||
const filled = new Set<string>();
|
||||
const values: Record<string, string> = {};
|
||||
const previousAutoFilledValues = autoFilledValuesRef.current;
|
||||
const nextAutoFilledValues: Record<string, string> = {};
|
||||
for (const r of results) {
|
||||
values[r.envVarName] = r.value;
|
||||
filled.add(r.envVarName);
|
||||
nextAutoFilledValues[r.envVarName] = r.value;
|
||||
}
|
||||
setEnvValues((prev) => ({ ...prev, ...values }));
|
||||
setAutoFilledFields(filled);
|
||||
setEnvValues((prev) => {
|
||||
const next = { ...prev };
|
||||
|
||||
for (const [envVarName, previousValue] of Object.entries(previousAutoFilledValues)) {
|
||||
if (!(envVarName in nextAutoFilledValues) && next[envVarName] === previousValue) {
|
||||
next[envVarName] = '';
|
||||
}
|
||||
}
|
||||
|
||||
for (const [envVarName, nextValue] of Object.entries(nextAutoFilledValues)) {
|
||||
if (!next[envVarName] || next[envVarName] === previousAutoFilledValues[envVarName]) {
|
||||
next[envVarName] = nextValue;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
setAutoFilledFields(new Set(Object.keys(nextAutoFilledValues)));
|
||||
autoFilledValuesRef.current = nextAutoFilledValues;
|
||||
},
|
||||
() => {
|
||||
// Silently fail — auto-fill is supplementary
|
||||
}
|
||||
);
|
||||
}, [envVarLookupNames, open, projectPath, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [apiKeyLookupProjectPath, envVarLookupNames, open, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!server) return <></>;
|
||||
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ describe('CustomMcpServerDialog project scope', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('passes projectPath into API key lookup for project-aware autofill', async () => {
|
||||
it('looks up project-scoped API keys only when project scope is selected', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -213,7 +213,88 @@ describe('CustomMcpServerDialog project scope', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project');
|
||||
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined);
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
setNativeValue(scopeSelect, 'project', 'change');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project');
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(scopeSelect, 'user', 'change');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stale project auto-filled values when switching back to user scope', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
lookupMock
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(CustomMcpServerDialog, {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
projectPath: '/tmp/custom-mcp-project',
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Add')
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
addEnvButton.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const envKeyInput = host.querySelector(
|
||||
'input[placeholder="ENV_VAR_NAME"]'
|
||||
) as HTMLInputElement;
|
||||
const envValueInput = host.querySelector(
|
||||
'input[placeholder="value"]'
|
||||
) as HTMLInputElement;
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(scopeSelect, 'project', 'change');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(envValueInput.value).toBe('project-secret');
|
||||
|
||||
await act(async () => {
|
||||
setNativeValue(scopeSelect, 'user', 'change');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(envValueInput.value).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('passes projectPath into API key lookup for project-aware autofill', async () => {
|
||||
it('looks up project-scoped API keys only when project scope is selected', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
|
@ -302,7 +302,81 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7');
|
||||
expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined);
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'project';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7');
|
||||
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'user';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(lookupMock).toHaveBeenLastCalledWith(['CONTEXT7_API_KEY'], undefined);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears stale project auto-filled values when switching back to user scope', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
const server = makeServer();
|
||||
server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }];
|
||||
lookupMock
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ envVarName: 'CONTEXT7_API_KEY', value: 'project-secret' }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(McpServerDetailDialog, {
|
||||
server,
|
||||
isInstalled: false,
|
||||
installedEntry: null,
|
||||
diagnostic: null,
|
||||
diagnosticsLoading: false,
|
||||
projectPath: '/tmp/project-context7',
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||
const envValueInput = host.querySelector('input[type="password"]') as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'project';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(envValueInput.value).toBe('project-secret');
|
||||
|
||||
await act(async () => {
|
||||
scopeSelect.value = 'user';
|
||||
scopeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(envValueInput.value).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
Loading…
Reference in a new issue