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.
|
* 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 { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -92,11 +92,15 @@ export const CustomMcpServerDialog = ({
|
||||||
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [installing, setInstalling] = useState(false);
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||||
const envVarLookupNames = envVars
|
const envVarLookupNames = envVars
|
||||||
.map((entry) => entry.key.trim())
|
.map((entry) => entry.key.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.sort()
|
.sort()
|
||||||
.join('\0');
|
.join('\0');
|
||||||
|
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
|
||||||
|
? (projectPath ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Reset on open
|
// Reset on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -112,6 +116,7 @@ export const CustomMcpServerDialog = ({
|
||||||
setEnvVars([]);
|
setEnvVars([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
|
autoFilledValuesRef.current = {};
|
||||||
}
|
}
|
||||||
}, [defaultSharedScope, open]);
|
}, [defaultSharedScope, open]);
|
||||||
|
|
||||||
|
|
@ -128,19 +133,50 @@ export const CustomMcpServerDialog = ({
|
||||||
const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean);
|
const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean);
|
||||||
if (envVarNames.length === 0) return;
|
if (envVarNames.length === 0) return;
|
||||||
|
|
||||||
void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then(
|
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
|
||||||
(results) => {
|
(results) => {
|
||||||
if (results.length === 0) return;
|
const previousAutoFilledValues = autoFilledValuesRef.current;
|
||||||
const lookup = new Map(results.map((r) => [r.envVarName, r.value]));
|
const nextAutoFilledValues = Object.fromEntries(
|
||||||
setEnvVars((prev) =>
|
results.map((result) => [result.envVarName, result.value])
|
||||||
prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e))
|
|
||||||
);
|
);
|
||||||
|
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
|
// 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 () => {
|
const handleInstall = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Uses Radix UI Kit for all form elements.
|
* 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 { api } from '@renderer/api';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
|
@ -92,6 +92,7 @@ export const McpServerDetailDialog = ({
|
||||||
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
||||||
|
const autoFilledValuesRef = useRef<Record<string, string>>({});
|
||||||
const normalizedInstalledEntries = installedEntries.length
|
const normalizedInstalledEntries = installedEntries.length
|
||||||
? installedEntries
|
? installedEntries
|
||||||
: installedEntry
|
: installedEntry
|
||||||
|
|
@ -115,6 +116,9 @@ export const McpServerDetailDialog = ({
|
||||||
.map((entry) => entry.name)
|
.map((entry) => entry.name)
|
||||||
.sort()
|
.sort()
|
||||||
.join('\0') ?? '';
|
.join('\0') ?? '';
|
||||||
|
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
|
||||||
|
? (projectPath ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Initialize form when dialog opens or server changes
|
// Initialize form when dialog opens or server changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -138,6 +142,7 @@ export const McpServerDetailDialog = ({
|
||||||
setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope);
|
setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope);
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
setAutoFilledFields(new Set());
|
setAutoFilledFields(new Set());
|
||||||
|
autoFilledValuesRef.current = {};
|
||||||
}, [
|
}, [
|
||||||
defaultSharedScope,
|
defaultSharedScope,
|
||||||
open,
|
open,
|
||||||
|
|
@ -165,23 +170,38 @@ export const McpServerDetailDialog = ({
|
||||||
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
||||||
|
|
||||||
const envVarNames = server.envVars.map((e) => e.name);
|
const envVarNames = server.envVars.map((e) => e.name);
|
||||||
void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then(
|
void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then(
|
||||||
(results) => {
|
(results) => {
|
||||||
if (results.length === 0) return;
|
const previousAutoFilledValues = autoFilledValuesRef.current;
|
||||||
const filled = new Set<string>();
|
const nextAutoFilledValues: Record<string, string> = {};
|
||||||
const values: Record<string, string> = {};
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
values[r.envVarName] = r.value;
|
nextAutoFilledValues[r.envVarName] = r.value;
|
||||||
filled.add(r.envVarName);
|
|
||||||
}
|
}
|
||||||
setEnvValues((prev) => ({ ...prev, ...values }));
|
setEnvValues((prev) => {
|
||||||
setAutoFilledFields(filled);
|
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
|
// 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 <></>;
|
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');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
|
|
@ -213,7 +213,88 @@ describe('CustomMcpServerDialog project scope', () => {
|
||||||
await Promise.resolve();
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
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');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
const root = createRoot(host);
|
const root = createRoot(host);
|
||||||
|
|
@ -302,7 +302,81 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
await Promise.resolve();
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue