feat(extensions): support multimodel global mcp scope
This commit is contained in:
parent
e01858ac98
commit
22209ba958
14 changed files with 219 additions and 54 deletions
|
|
@ -11,6 +11,7 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { execCli } from '@main/utils/childProcess';
|
import { execCli } from '@main/utils/childProcess';
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
|
||||||
|
|
@ -29,7 +30,7 @@ const logger = createLogger('Extensions:McpInstall');
|
||||||
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
|
||||||
|
|
||||||
/** Allowed scope values (prevent command injection) */
|
/** Allowed scope values (prevent command injection) */
|
||||||
const VALID_SCOPES = new Set(['local', 'user', 'project']);
|
const VALID_SCOPES = new Set(['local', 'user', 'project', 'global']);
|
||||||
|
|
||||||
/** Env var key must be safe shell identifier */
|
/** Env var key must be safe shell identifier */
|
||||||
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
|
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i;
|
||||||
|
|
@ -40,7 +41,7 @@ const HEADER_KEY_RE = /^[A-Za-z][\w-]{0,100}$/;
|
||||||
const TIMEOUT_MS = 30_000;
|
const TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
function scopeRequiresProjectPath(scope?: string): boolean {
|
function scopeRequiresProjectPath(scope?: string): boolean {
|
||||||
return scope === 'local' || scope === 'project';
|
return isProjectScopedMcpScope(scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class McpInstallService {
|
export class McpInstallService {
|
||||||
|
|
@ -64,7 +65,7 @@ export class McpInstallService {
|
||||||
if (scope && !VALID_SCOPES.has(scope)) {
|
if (scope && !VALID_SCOPES.has(scope)) {
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
|
error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -337,7 +338,7 @@ export class McpInstallService {
|
||||||
if (scope && !VALID_SCOPES.has(scope)) {
|
if (scope && !VALID_SCOPES.has(scope)) {
|
||||||
return {
|
return {
|
||||||
state: 'error',
|
state: 'error',
|
||||||
error: `Invalid scope: "${scope}". Must be one of: local, user, project.`,
|
error: `Invalid scope: "${scope}". Must be one of: local, user, project, global.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { isInstalledMcpScope } from '@shared/utils/mcpScopes';
|
||||||
|
|
||||||
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
interface McpListJsonServer {
|
interface McpListJsonServer {
|
||||||
|
|
@ -24,15 +26,11 @@ function extractJsonObject<T>(raw: string): T {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSupportedScope(scope: unknown): scope is InstalledMcpEntry['scope'] {
|
|
||||||
return scope === 'user' || scope === 'project' || scope === 'local';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] {
|
export function parseInstalledMcpJsonOutput(output: string): InstalledMcpEntry[] {
|
||||||
const parsed = extractJsonObject<McpListJsonPayload>(output);
|
const parsed = extractJsonObject<McpListJsonPayload>(output);
|
||||||
|
|
||||||
return (parsed.servers ?? []).flatMap<InstalledMcpEntry>((entry) => {
|
return (parsed.servers ?? []).flatMap<InstalledMcpEntry>((entry) => {
|
||||||
if (typeof entry.name !== 'string' || !isSupportedScope(entry.scope)) {
|
if (typeof entry.name !== 'string' || !isInstalledMcpScope(entry.scope)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import {
|
||||||
|
getDefaultMcpSharedScope,
|
||||||
|
getMcpScopeLabel,
|
||||||
|
isProjectScopedMcpScope,
|
||||||
|
} from '@shared/utils/mcpScopes';
|
||||||
import { Plus, Server, Trash2 } from 'lucide-react';
|
import { Plus, Server, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -42,13 +47,7 @@ interface CustomMcpServerDialogProps {
|
||||||
|
|
||||||
type TransportMode = 'stdio' | 'http';
|
type TransportMode = 'stdio' | 'http';
|
||||||
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
type HttpTransport = 'streamable-http' | 'sse' | 'http';
|
||||||
type Scope = 'local' | 'user' | 'project';
|
type Scope = 'local' | 'user' | 'project' | 'global';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
|
||||||
{ value: 'user', label: 'User (global)' },
|
|
||||||
{ value: 'project', label: 'Project' },
|
|
||||||
{ value: 'local', label: 'Local' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
|
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
|
||||||
{ value: 'streamable-http', label: 'Streamable HTTP' },
|
{ value: 'streamable-http', label: 'Streamable HTTP' },
|
||||||
|
|
@ -67,11 +66,18 @@ export const CustomMcpServerDialog = ({
|
||||||
projectPath,
|
projectPath,
|
||||||
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
}: CustomMcpServerDialogProps): React.JSX.Element => {
|
||||||
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
||||||
|
const scopeOptions: { value: Scope; label: string }[] = [
|
||||||
|
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },
|
||||||
|
{ value: 'project', label: 'Project' },
|
||||||
|
{ value: 'local', label: 'Local' },
|
||||||
|
];
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [serverName, setServerName] = useState('');
|
const [serverName, setServerName] = useState('');
|
||||||
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
|
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
const [scope, setScope] = useState<Scope>(defaultSharedScope);
|
||||||
|
|
||||||
// Stdio fields
|
// Stdio fields
|
||||||
const [npmPackage, setNpmPackage] = useState('');
|
const [npmPackage, setNpmPackage] = useState('');
|
||||||
|
|
@ -92,7 +98,7 @@ export const CustomMcpServerDialog = ({
|
||||||
if (open) {
|
if (open) {
|
||||||
setServerName('');
|
setServerName('');
|
||||||
setTransportMode('stdio');
|
setTransportMode('stdio');
|
||||||
setScope('user');
|
setScope(defaultSharedScope);
|
||||||
setNpmPackage('');
|
setNpmPackage('');
|
||||||
setNpmVersion('');
|
setNpmVersion('');
|
||||||
setHttpUrl('');
|
setHttpUrl('');
|
||||||
|
|
@ -102,13 +108,13 @@ export const CustomMcpServerDialog = ({
|
||||||
setError(null);
|
setError(null);
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [defaultSharedScope, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && scope !== 'user' && !projectPath) {
|
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
|
||||||
setScope('user');
|
setScope(defaultSharedScope);
|
||||||
}
|
}
|
||||||
}, [open, projectPath, scope]);
|
}, [defaultSharedScope, open, projectPath, scope]);
|
||||||
|
|
||||||
// Auto-fill env vars from saved API keys
|
// Auto-fill env vars from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -177,7 +183,7 @@ export const CustomMcpServerDialog = ({
|
||||||
const request: McpCustomInstallRequest = {
|
const request: McpCustomInstallRequest = {
|
||||||
serverName,
|
serverName,
|
||||||
scope,
|
scope,
|
||||||
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
|
projectPath: isProjectScopedMcpScope(scope) ? (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()),
|
||||||
|
|
@ -207,7 +213,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 !== 'user' && !projectPath) &&
|
!(isProjectScopedMcpScope(scope) && !projectPath) &&
|
||||||
!installing;
|
!installing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -382,11 +388,11 @@ export const CustomMcpServerDialog = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{scopeOptions.map((opt) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value !== 'user' && !projectPath}
|
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
||||||
|
import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes';
|
||||||
import {
|
import {
|
||||||
getMcpInstallationSummaryLabel,
|
getMcpInstallationSummaryLabel,
|
||||||
getMcpOperationKey,
|
getMcpOperationKey,
|
||||||
|
|
@ -47,7 +48,9 @@ export const McpServerCard = ({
|
||||||
diagnosticsLoading,
|
diagnosticsLoading,
|
||||||
onClick,
|
onClick,
|
||||||
}: McpServerCardProps): React.JSX.Element => {
|
}: McpServerCardProps): React.JSX.Element => {
|
||||||
const operationKey = getMcpOperationKey(server.id, 'user');
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
||||||
|
const operationKey = getMcpOperationKey(server.id, sharedScope);
|
||||||
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
|
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
|
||||||
const installMcpServer = useStore((s) => s.installMcpServer);
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
||||||
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
||||||
|
|
@ -67,13 +70,13 @@ export const McpServerCard = ({
|
||||||
server.requiresAuth ||
|
server.requiresAuth ||
|
||||||
(server.authHeaders?.length ?? 0) > 0;
|
(server.authHeaders?.length ?? 0) > 0;
|
||||||
const defaultServerName = sanitizeMcpServerName(server.name);
|
const defaultServerName = sanitizeMcpServerName(server.name);
|
||||||
const userInstallEntry =
|
const sharedInstallEntry =
|
||||||
normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null;
|
normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null;
|
||||||
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
||||||
const supportsDirectInstalledAction =
|
const supportsDirectInstalledAction =
|
||||||
isInstalled &&
|
isInstalled &&
|
||||||
normalizedInstalledEntries.length === 1 &&
|
normalizedInstalledEntries.length === 1 &&
|
||||||
userInstallEntry?.name === defaultServerName &&
|
sharedInstallEntry?.name === defaultServerName &&
|
||||||
!requiresConfiguration;
|
!requiresConfiguration;
|
||||||
const shouldShowDirectInstallButton =
|
const shouldShowDirectInstallButton =
|
||||||
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
|
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
|
||||||
|
|
@ -263,13 +266,17 @@ export const McpServerCard = ({
|
||||||
installMcpServer({
|
installMcpServer({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
serverName: defaultServerName,
|
serverName: defaultServerName,
|
||||||
scope: 'user',
|
scope: sharedScope,
|
||||||
envValues: {},
|
envValues: {},
|
||||||
headers: [],
|
headers: [],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onUninstall={() =>
|
onUninstall={() =>
|
||||||
uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user')
|
uninstallMcpServer(
|
||||||
|
server.id,
|
||||||
|
sharedInstallEntry?.name ?? defaultServerName,
|
||||||
|
sharedScope
|
||||||
|
)
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
errorMessage={installError}
|
errorMessage={installError}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import {
|
||||||
|
getDefaultMcpSharedScope,
|
||||||
|
getMcpScopeLabel,
|
||||||
|
isProjectScopedMcpScope,
|
||||||
|
} from '@shared/utils/mcpScopes';
|
||||||
import {
|
import {
|
||||||
getMcpInstallationSummaryLabel,
|
getMcpInstallationSummaryLabel,
|
||||||
getMcpOperationKey,
|
getMcpOperationKey,
|
||||||
|
|
@ -55,13 +60,7 @@ interface McpServerDetailDialogProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Scope = 'local' | 'user' | 'project';
|
type Scope = 'local' | 'user' | 'project' | 'global';
|
||||||
|
|
||||||
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
|
||||||
{ value: 'user', label: 'User (global)' },
|
|
||||||
{ value: 'project', label: 'Project' },
|
|
||||||
{ value: 'local', label: 'Local' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const McpServerDetailDialog = ({
|
export const McpServerDetailDialog = ({
|
||||||
server,
|
server,
|
||||||
|
|
@ -74,7 +73,9 @@ export const McpServerDetailDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
}: McpServerDetailDialogProps): React.JSX.Element => {
|
}: McpServerDetailDialogProps): React.JSX.Element => {
|
||||||
const [scope, setScope] = useState<Scope>('user');
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
||||||
|
const [scope, setScope] = useState<Scope>(defaultSharedScope);
|
||||||
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
|
const operationKey = server ? getMcpOperationKey(server.id, scope) : null;
|
||||||
const installProgress = useStore(
|
const installProgress = useStore(
|
||||||
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
|
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
|
||||||
|
|
@ -96,6 +97,15 @@ export const McpServerDetailDialog = ({
|
||||||
: installedEntry
|
: installedEntry
|
||||||
? [installedEntry]
|
? [installedEntry]
|
||||||
: [];
|
: [];
|
||||||
|
const scopeOptions: { value: Scope; label: string }[] = [
|
||||||
|
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },
|
||||||
|
...(defaultSharedScope !== 'user' &&
|
||||||
|
normalizedInstalledEntries.some((entry) => entry.scope === 'user')
|
||||||
|
? [{ value: 'user' as const, label: getMcpScopeLabel('user', cliStatus?.flavor) }]
|
||||||
|
: []),
|
||||||
|
{ value: 'project', label: 'Project' },
|
||||||
|
{ value: 'local', label: 'Local' },
|
||||||
|
];
|
||||||
const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
|
const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
|
||||||
const selectedInstalledEntry =
|
const selectedInstalledEntry =
|
||||||
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
|
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
|
||||||
|
|
@ -120,10 +130,16 @@ export const McpServerDetailDialog = ({
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
|
||||||
setScope(preferredInstalledEntry?.scope ?? 'user');
|
setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope);
|
||||||
setImgError(false);
|
setImgError(false);
|
||||||
setAutoFilledFields(new Set());
|
setAutoFilledFields(new Set());
|
||||||
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
|
}, [
|
||||||
|
defaultSharedScope,
|
||||||
|
open,
|
||||||
|
preferredInstalledEntry?.name,
|
||||||
|
preferredInstalledEntry?.scope,
|
||||||
|
server?.id,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!server || !open) {
|
if (!server || !open) {
|
||||||
|
|
@ -134,10 +150,10 @@ export const McpServerDetailDialog = ({
|
||||||
}, [open, scope, selectedInstalledEntry?.name, server]);
|
}, [open, scope, selectedInstalledEntry?.name, server]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && scope !== 'user' && !projectPath) {
|
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
|
||||||
setScope('user');
|
setScope(defaultSharedScope);
|
||||||
}
|
}
|
||||||
}, [open, projectPath, scope]);
|
}, [defaultSharedScope, open, projectPath, scope]);
|
||||||
|
|
||||||
// Auto-fill env values from saved API keys
|
// Auto-fill env values from saved API keys
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -181,7 +197,7 @@ export const McpServerDetailDialog = ({
|
||||||
const isInstalledForScope = selectedInstalledEntry !== null;
|
const isInstalledForScope = selectedInstalledEntry !== null;
|
||||||
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
|
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
|
||||||
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
|
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
|
||||||
const scopeRequiresProjectPath = scope !== 'user' && !projectPath;
|
const scopeRequiresProjectPath = isProjectScopedMcpScope(scope) && !projectPath;
|
||||||
const installDisabled =
|
const installDisabled =
|
||||||
!serverName.trim() ||
|
!serverName.trim() ||
|
||||||
missingRequiredEnvVars ||
|
missingRequiredEnvVars ||
|
||||||
|
|
@ -201,7 +217,7 @@ export const McpServerDetailDialog = ({
|
||||||
registryId: server.id,
|
registryId: server.id,
|
||||||
serverName,
|
serverName,
|
||||||
scope,
|
scope,
|
||||||
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
|
projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined,
|
||||||
envValues,
|
envValues,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
@ -212,7 +228,7 @@ export const McpServerDetailDialog = ({
|
||||||
server.id,
|
server.id,
|
||||||
uninstallServerName,
|
uninstallServerName,
|
||||||
uninstallScope,
|
uninstallScope,
|
||||||
uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined
|
isProjectScopedMcpScope(uninstallScope) ? (projectPath ?? undefined) : undefined
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -415,11 +431,11 @@ export const McpServerDetailDialog = ({
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{SCOPE_OPTIONS.map((opt) => (
|
{scopeOptions.map((opt) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
disabled={opt.value !== 'user' && !projectPath}
|
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
||||||
|
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
||||||
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
|
||||||
|
|
||||||
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
|
||||||
|
|
@ -1034,7 +1035,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
scope?: string,
|
scope?: string,
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
) => {
|
) => {
|
||||||
const operationScope: InstallScope = scope === 'project' || scope === 'local' ? scope : 'user';
|
const operationScope: InstallScope =
|
||||||
|
scope === 'global' || scope === 'user' || isProjectScopedMcpScope(scope) ? scope : 'user';
|
||||||
const operationKey = getMcpOperationKey(registryId, operationScope);
|
const operationKey = getMcpOperationKey(registryId, operationScope);
|
||||||
if (!api.mcpRegistry) {
|
if (!api.mcpRegistry) {
|
||||||
clearMcpSuccessResetTimer(operationKey);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
|
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
|
||||||
|
|
||||||
/** Installation scope — where the extension is installed */
|
/** Installation scope — where the extension is installed */
|
||||||
export type InstallScope = 'local' | 'user' | 'project';
|
export type InstallScope = 'local' | 'user' | 'project' | 'global';
|
||||||
|
|
||||||
/** Result of a mutation operation */
|
/** Result of a mutation operation */
|
||||||
export interface OperationResult<T = void> {
|
export interface OperationResult<T = void> {
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export interface McpHeaderDef {
|
||||||
|
|
||||||
export interface InstalledMcpEntry {
|
export interface InstalledMcpEntry {
|
||||||
name: string;
|
name: string;
|
||||||
scope: 'local' | 'user' | 'project';
|
scope: 'local' | 'user' | 'project' | 'global';
|
||||||
transport?: string;
|
transport?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +100,7 @@ export interface McpServerDiagnostic {
|
||||||
|
|
||||||
// ── Install request (renderer → main, minimal trusted data) ────────────────
|
// ── Install request (renderer → main, minimal trusted data) ────────────────
|
||||||
|
|
||||||
export type McpInstallScope = 'local' | 'user' | 'project';
|
export type McpInstallScope = 'local' | 'user' | 'project' | 'global';
|
||||||
|
|
||||||
export interface McpInstallRequest {
|
export interface McpInstallRequest {
|
||||||
registryId: string; // server ID from registry (NOT full catalog item)
|
registryId: string; // server ID from registry (NOT full catalog item)
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ export function getInstallationSummaryLabel(
|
||||||
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
|
||||||
local: 0,
|
local: 0,
|
||||||
project: 1,
|
project: 1,
|
||||||
|
global: 2,
|
||||||
user: 2,
|
user: 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -195,6 +196,7 @@ export function getMcpInstallationSummaryLabel(
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (scopes[0]) {
|
switch (scopes[0]) {
|
||||||
|
case 'global':
|
||||||
case 'user':
|
case 'user':
|
||||||
return 'Installed globally';
|
return 'Installed globally';
|
||||||
case 'project':
|
case 'project':
|
||||||
|
|
|
||||||
32
src/shared/utils/mcpScopes.ts
Normal file
32
src/shared/utils/mcpScopes.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { CliFlavor } from '@shared/types';
|
||||||
|
import type { InstalledMcpEntry } from '@shared/types/extensions';
|
||||||
|
|
||||||
|
export type McpInstalledScope = InstalledMcpEntry['scope'];
|
||||||
|
export type McpSharedScope = Extract<McpInstalledScope, 'user' | 'global'>;
|
||||||
|
|
||||||
|
export function getDefaultMcpSharedScope(flavor?: CliFlavor | null): McpSharedScope {
|
||||||
|
return flavor === 'agent_teams_orchestrator' ? 'global' : 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProjectScopedMcpScope(scope?: string): scope is 'project' | 'local' {
|
||||||
|
return scope === 'project' || scope === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInstalledMcpScope(scope: unknown): scope is McpInstalledScope {
|
||||||
|
return scope === 'user' || scope === 'global' || scope === 'project' || scope === 'local';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMcpScopeLabel(scope: McpInstalledScope, flavor?: CliFlavor | null): string {
|
||||||
|
switch (scope) {
|
||||||
|
case 'global':
|
||||||
|
return 'Global';
|
||||||
|
case 'user':
|
||||||
|
return flavor === 'agent_teams_orchestrator' ? 'User (legacy)' : 'User (global)';
|
||||||
|
case 'project':
|
||||||
|
return 'Project';
|
||||||
|
case 'local':
|
||||||
|
return 'Local';
|
||||||
|
default:
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
interface StoreState {
|
interface StoreState {
|
||||||
installCustomMcpServer: ReturnType<typeof vi.fn>;
|
installCustomMcpServer: ReturnType<typeof vi.fn>;
|
||||||
|
cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeState = {} as StoreState;
|
const storeState = {} as StoreState;
|
||||||
|
|
@ -116,6 +117,7 @@ describe('CustomMcpServerDialog project scope', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined);
|
storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined);
|
||||||
|
storeState.cliStatus = null;
|
||||||
lookupMock.mockReset();
|
lookupMock.mockReset();
|
||||||
lookupMock.mockResolvedValue([]);
|
lookupMock.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
@ -152,6 +154,32 @@ describe('CustomMcpServerDialog project scope', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to global scope in multimodel mode', async () => {
|
||||||
|
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
|
||||||
|
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 scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||||
|
expect(scopeSelect.value).toBe('global');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('passes projectPath for project-scoped custom installs', async () => {
|
it('passes projectPath for project-scoped custom installs', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface StoreState {
|
||||||
uninstallMcpServer: ReturnType<typeof vi.fn>;
|
uninstallMcpServer: ReturnType<typeof vi.fn>;
|
||||||
installErrors: Record<string, string>;
|
installErrors: Record<string, string>;
|
||||||
mcpGitHubStars: Record<string, number>;
|
mcpGitHubStars: Record<string, number>;
|
||||||
|
cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeState = {} as StoreState;
|
const storeState = {} as StoreState;
|
||||||
|
|
@ -127,6 +128,7 @@ describe('McpServerCard direct action safety', () => {
|
||||||
storeState.uninstallMcpServer = vi.fn();
|
storeState.uninstallMcpServer = vi.fn();
|
||||||
storeState.installErrors = {};
|
storeState.installErrors = {};
|
||||||
storeState.mcpGitHubStars = {};
|
storeState.mcpGitHubStars = {};
|
||||||
|
storeState.cliStatus = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -285,4 +287,37 @@ describe('McpServerCard direct action safety', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps direct actions for standard global installs in multimodel mode', async () => {
|
||||||
|
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
const installedEntry: InstalledMcpEntry = {
|
||||||
|
name: 'context7',
|
||||||
|
scope: 'global',
|
||||||
|
};
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(McpServerCard, {
|
||||||
|
server: makeServer(),
|
||||||
|
isInstalled: true,
|
||||||
|
installedEntry,
|
||||||
|
installedEntries: [installedEntry],
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
onClick: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.querySelector('[data-testid="install-button"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ interface StoreState {
|
||||||
uninstallMcpServer: ReturnType<typeof vi.fn>;
|
uninstallMcpServer: ReturnType<typeof vi.fn>;
|
||||||
installErrors: Record<string, string>;
|
installErrors: Record<string, string>;
|
||||||
mcpGitHubStars: Record<string, number>;
|
mcpGitHubStars: Record<string, number>;
|
||||||
|
cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeState = {} as StoreState;
|
const storeState = {} as StoreState;
|
||||||
|
|
@ -172,6 +173,7 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
storeState.uninstallMcpServer = vi.fn();
|
storeState.uninstallMcpServer = vi.fn();
|
||||||
storeState.installErrors = {};
|
storeState.installErrors = {};
|
||||||
storeState.mcpGitHubStars = {};
|
storeState.mcpGitHubStars = {};
|
||||||
|
storeState.cliStatus = null;
|
||||||
lookupMock.mockReset();
|
lookupMock.mockReset();
|
||||||
lookupMock.mockResolvedValue([]);
|
lookupMock.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
@ -276,6 +278,38 @@ describe('McpServerDetailDialog installed entry handling', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults to global scope in multimodel mode', async () => {
|
||||||
|
storeState.cliStatus = { flavor: 'agent_teams_orchestrator' };
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(McpServerDetailDialog, {
|
||||||
|
server: makeServer(),
|
||||||
|
isInstalled: false,
|
||||||
|
installedEntry: null,
|
||||||
|
installedEntries: [],
|
||||||
|
diagnostic: null,
|
||||||
|
diagnosticsLoading: false,
|
||||||
|
projectPath: null,
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const scopeSelect = host.querySelector('[data-testid="scope-select"]') as HTMLSelectElement;
|
||||||
|
expect(scopeSelect.value).toBe('global');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('passes project path for project-scoped installs and uninstalls', async () => {
|
it('passes project path for project-scoped installs and uninstalls', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,10 @@ describe('getMcpInstallationSummaryLabel', () => {
|
||||||
expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally');
|
expect(getMcpInstallationSummaryLabel([{ scope: 'local' }])).toBe('Installed locally');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('describes a single global MCP installation', () => {
|
||||||
|
expect(getMcpInstallationSummaryLabel([{ scope: 'global' }])).toBe('Installed globally');
|
||||||
|
});
|
||||||
|
|
||||||
it('summarizes multiple MCP scopes', () => {
|
it('summarizes multiple MCP scopes', () => {
|
||||||
expect(
|
expect(
|
||||||
getMcpInstallationSummaryLabel([
|
getMcpInstallationSummaryLabel([
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue