feat(extensions): support multimodel global mcp scope

This commit is contained in:
777genius 2026-04-17 13:23:30 +03:00
parent e01858ac98
commit 22209ba958
14 changed files with 219 additions and 54 deletions

View file

@ -11,6 +11,7 @@ import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { execCli } from '@main/utils/childProcess';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { createLogger } from '@shared/utils/logger';
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
import path from 'path';
import { createExtensionsRuntimeAdapter } from '../runtime/ExtensionsRuntimeAdapter';
@ -29,7 +30,7 @@ const logger = createLogger('Extensions:McpInstall');
const SERVER_NAME_RE = /^[\w.-]{1,100}$/;
/** 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 */
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;
function scopeRequiresProjectPath(scope?: string): boolean {
return scope === 'local' || scope === 'project';
return isProjectScopedMcpScope(scope);
}
export class McpInstallService {
@ -64,7 +65,7 @@ export class McpInstallService {
if (scope && !VALID_SCOPES.has(scope)) {
return {
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)) {
return {
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.`,
};
}

View file

@ -1,3 +1,5 @@
import { isInstalledMcpScope } from '@shared/utils/mcpScopes';
import type { InstalledMcpEntry } from '@shared/types/extensions';
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[] {
const parsed = extractJsonObject<McpListJsonPayload>(output);
return (parsed.servers ?? []).flatMap<InstalledMcpEntry>((entry) => {
if (typeof entry.name !== 'string' || !isSupportedScope(entry.scope)) {
if (typeof entry.name !== 'string' || !isInstalledMcpScope(entry.scope)) {
return [];
}

View file

@ -24,6 +24,11 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import {
getDefaultMcpSharedScope,
getMcpScopeLabel,
isProjectScopedMcpScope,
} from '@shared/utils/mcpScopes';
import { Plus, Server, Trash2 } from 'lucide-react';
import type {
@ -42,13 +47,7 @@ interface CustomMcpServerDialogProps {
type TransportMode = 'stdio' | 'http';
type HttpTransport = 'streamable-http' | 'sse' | 'http';
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
type Scope = 'local' | 'user' | 'project' | 'global';
const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [
{ value: 'streamable-http', label: 'Streamable HTTP' },
@ -67,11 +66,18 @@ export const CustomMcpServerDialog = ({
projectPath,
}: CustomMcpServerDialogProps): React.JSX.Element => {
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
const [serverName, setServerName] = useState('');
const [transportMode, setTransportMode] = useState<TransportMode>('stdio');
const [scope, setScope] = useState<Scope>('user');
const [scope, setScope] = useState<Scope>(defaultSharedScope);
// Stdio fields
const [npmPackage, setNpmPackage] = useState('');
@ -92,7 +98,7 @@ export const CustomMcpServerDialog = ({
if (open) {
setServerName('');
setTransportMode('stdio');
setScope('user');
setScope(defaultSharedScope);
setNpmPackage('');
setNpmVersion('');
setHttpUrl('');
@ -102,13 +108,13 @@ export const CustomMcpServerDialog = ({
setError(null);
setInstalling(false);
}
}, [open]);
}, [defaultSharedScope, open]);
useEffect(() => {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
setScope(defaultSharedScope);
}
}, [open, projectPath, scope]);
}, [defaultSharedScope, open, projectPath, scope]);
// Auto-fill env vars from saved API keys
useEffect(() => {
@ -177,7 +183,7 @@ export const CustomMcpServerDialog = ({
const request: McpCustomInstallRequest = {
serverName,
scope,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined,
installSpec,
envValues,
headers: headers.filter((h) => h.key.trim() && h.value.trim()),
@ -207,7 +213,7 @@ export const CustomMcpServerDialog = ({
const canSubmit =
serverName.trim() &&
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
!(scope !== 'user' && !projectPath) &&
!(isProjectScopedMcpScope(scope) && !projectPath) &&
!installing;
return (
@ -382,11 +388,11 @@ export const CustomMcpServerDialog = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
{scopeOptions.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== 'user' && !projectPath}
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
>
{opt.label}
</SelectItem>

View file

@ -11,6 +11,7 @@ import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
import { getDefaultMcpSharedScope } from '@shared/utils/mcpScopes';
import {
getMcpInstallationSummaryLabel,
getMcpOperationKey,
@ -47,7 +48,9 @@ export const McpServerCard = ({
diagnosticsLoading,
onClick,
}: 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 installMcpServer = useStore((s) => s.installMcpServer);
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
@ -67,13 +70,13 @@ export const McpServerCard = ({
server.requiresAuth ||
(server.authHeaders?.length ?? 0) > 0;
const defaultServerName = sanitizeMcpServerName(server.name);
const userInstallEntry =
normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null;
const sharedInstallEntry =
normalizedInstalledEntries.find((entry) => entry.scope === sharedScope) ?? null;
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
const supportsDirectInstalledAction =
isInstalled &&
normalizedInstalledEntries.length === 1 &&
userInstallEntry?.name === defaultServerName &&
sharedInstallEntry?.name === defaultServerName &&
!requiresConfiguration;
const shouldShowDirectInstallButton =
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
@ -263,13 +266,17 @@ export const McpServerCard = ({
installMcpServer({
registryId: server.id,
serverName: defaultServerName,
scope: 'user',
scope: sharedScope,
envValues: {},
headers: [],
})
}
onUninstall={() =>
uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user')
uninstallMcpServer(
server.id,
sharedInstallEntry?.name ?? defaultServerName,
sharedScope
)
}
size="sm"
errorMessage={installError}

View file

@ -25,6 +25,11 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store';
import {
getDefaultMcpSharedScope,
getMcpScopeLabel,
isProjectScopedMcpScope,
} from '@shared/utils/mcpScopes';
import {
getMcpInstallationSummaryLabel,
getMcpOperationKey,
@ -55,13 +60,7 @@ interface McpServerDetailDialogProps {
onClose: () => void;
}
type Scope = 'local' | 'user' | 'project';
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
{ value: 'user', label: 'User (global)' },
{ value: 'project', label: 'Project' },
{ value: 'local', label: 'Local' },
];
type Scope = 'local' | 'user' | 'project' | 'global';
export const McpServerDetailDialog = ({
server,
@ -74,7 +73,9 @@ export const McpServerDetailDialog = ({
open,
onClose,
}: 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 installProgress = useStore(
(s) => (operationKey ? s.mcpInstallProgress[operationKey] : undefined) ?? 'idle'
@ -96,6 +97,15 @@ export const McpServerDetailDialog = ({
: 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 selectedInstalledEntry =
normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null;
@ -120,10 +130,16 @@ export const McpServerDetailDialog = ({
}))
);
setServerName(preferredInstalledEntry?.name ?? sanitizeMcpServerName(server.name));
setScope(preferredInstalledEntry?.scope ?? 'user');
setScope((preferredInstalledEntry?.scope as Scope | undefined) ?? defaultSharedScope);
setImgError(false);
setAutoFilledFields(new Set());
}, [open, preferredInstalledEntry?.name, preferredInstalledEntry?.scope, server?.id]);
}, [
defaultSharedScope,
open,
preferredInstalledEntry?.name,
preferredInstalledEntry?.scope,
server?.id,
]);
useEffect(() => {
if (!server || !open) {
@ -134,10 +150,10 @@ export const McpServerDetailDialog = ({
}, [open, scope, selectedInstalledEntry?.name, server]);
useEffect(() => {
if (open && scope !== 'user' && !projectPath) {
setScope('user');
if (open && isProjectScopedMcpScope(scope) && !projectPath) {
setScope(defaultSharedScope);
}
}, [open, projectPath, scope]);
}, [defaultSharedScope, open, projectPath, scope]);
// Auto-fill env values from saved API keys
useEffect(() => {
@ -181,7 +197,7 @@ export const McpServerDetailDialog = ({
const isInstalledForScope = selectedInstalledEntry !== null;
const uninstallServerName = selectedInstalledEntry?.name ?? serverName;
const uninstallScope = selectedInstalledEntry?.scope ?? scope;
const scopeRequiresProjectPath = scope !== 'user' && !projectPath;
const scopeRequiresProjectPath = isProjectScopedMcpScope(scope) && !projectPath;
const installDisabled =
!serverName.trim() ||
missingRequiredEnvVars ||
@ -201,7 +217,7 @@ export const McpServerDetailDialog = ({
registryId: server.id,
serverName,
scope,
projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined,
projectPath: isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined,
envValues,
headers,
});
@ -212,7 +228,7 @@ export const McpServerDetailDialog = ({
server.id,
uninstallServerName,
uninstallScope,
uninstallScope !== 'user' ? (projectPath ?? undefined) : undefined
isProjectScopedMcpScope(uninstallScope) ? (projectPath ?? undefined) : undefined
);
};
@ -415,11 +431,11 @@ export const McpServerDetailDialog = ({
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
{scopeOptions.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== 'user' && !projectPath}
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
>
{opt.label}
</SelectItem>

View file

@ -5,6 +5,7 @@
import { api } from '@renderer/api';
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
import { getMcpOperationKey, getPluginOperationKey } from '@shared/utils/extensionNormalizers';
import { findPaneByTabId, updatePane } from '../utils/paneHelpers';
@ -1034,7 +1035,8 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
scope?: 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);
if (!api.mcpRegistry) {
clearMcpSuccessResetTimer(operationKey);

View file

@ -6,7 +6,7 @@
export type ExtensionOperationState = 'idle' | 'pending' | 'success' | 'error';
/** 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 */
export interface OperationResult<T = void> {

View file

@ -83,7 +83,7 @@ export interface McpHeaderDef {
export interface InstalledMcpEntry {
name: string;
scope: 'local' | 'user' | 'project';
scope: 'local' | 'user' | 'project' | 'global';
transport?: string;
}
@ -100,7 +100,7 @@ export interface McpServerDiagnostic {
// ── Install request (renderer → main, minimal trusted data) ────────────────
export type McpInstallScope = 'local' | 'user' | 'project';
export type McpInstallScope = 'local' | 'user' | 'project' | 'global';
export interface McpInstallRequest {
registryId: string; // server ID from registry (NOT full catalog item)

View file

@ -160,6 +160,7 @@ export function getInstallationSummaryLabel(
const MCP_SCOPE_PRIORITY: Record<InstalledMcpEntry['scope'], number> = {
local: 0,
project: 1,
global: 2,
user: 2,
};
@ -195,6 +196,7 @@ export function getMcpInstallationSummaryLabel(
}
switch (scopes[0]) {
case 'global':
case 'user':
return 'Installed globally';
case 'project':

View 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;
}
}

View file

@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
interface StoreState {
installCustomMcpServer: ReturnType<typeof vi.fn>;
cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null;
}
const storeState = {} as StoreState;
@ -116,6 +117,7 @@ describe('CustomMcpServerDialog project scope', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.installCustomMcpServer = vi.fn().mockResolvedValue(undefined);
storeState.cliStatus = null;
lookupMock.mockReset();
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 () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -11,6 +11,7 @@ interface StoreState {
uninstallMcpServer: ReturnType<typeof vi.fn>;
installErrors: Record<string, string>;
mcpGitHubStars: Record<string, number>;
cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null;
}
const storeState = {} as StoreState;
@ -127,6 +128,7 @@ describe('McpServerCard direct action safety', () => {
storeState.uninstallMcpServer = vi.fn();
storeState.installErrors = {};
storeState.mcpGitHubStars = {};
storeState.cliStatus = null;
});
afterEach(() => {
@ -285,4 +287,37 @@ describe('McpServerCard direct action safety', () => {
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();
});
});
});

View file

@ -11,6 +11,7 @@ interface StoreState {
uninstallMcpServer: ReturnType<typeof vi.fn>;
installErrors: Record<string, string>;
mcpGitHubStars: Record<string, number>;
cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null;
}
const storeState = {} as StoreState;
@ -172,6 +173,7 @@ describe('McpServerDetailDialog installed entry handling', () => {
storeState.uninstallMcpServer = vi.fn();
storeState.installErrors = {};
storeState.mcpGitHubStars = {};
storeState.cliStatus = null;
lookupMock.mockReset();
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 () => {
const host = document.createElement('div');
document.body.appendChild(host);

View file

@ -241,6 +241,10 @@ describe('getMcpInstallationSummaryLabel', () => {
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', () => {
expect(
getMcpInstallationSummaryLabel([