From 22209ba95870df74dc3d1824d4d79a08316d645f Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 17 Apr 2026 13:23:30 +0300 Subject: [PATCH] feat(extensions): support multimodel global mcp scope --- .../extensions/install/McpInstallService.ts | 9 ++-- .../extensions/runtime/mcpRuntimeJson.ts | 8 ++- .../extensions/mcp/CustomMcpServerDialog.tsx | 40 ++++++++------ .../extensions/mcp/McpServerCard.tsx | 19 ++++--- .../extensions/mcp/McpServerDetailDialog.tsx | 52 ++++++++++++------- src/renderer/store/slices/extensionsSlice.ts | 4 +- src/shared/types/extensions/common.ts | 2 +- src/shared/types/extensions/mcp.ts | 4 +- src/shared/utils/extensionNormalizers.ts | 2 + src/shared/utils/mcpScopes.ts | 32 ++++++++++++ .../mcp/CustomMcpServerDialog.test.ts | 28 ++++++++++ .../extensions/mcp/McpServerCard.test.ts | 35 +++++++++++++ .../mcp/McpServerDetailDialog.test.ts | 34 ++++++++++++ .../shared/utils/extensionNormalizers.test.ts | 4 ++ 14 files changed, 219 insertions(+), 54 deletions(-) create mode 100644 src/shared/utils/mcpScopes.ts diff --git a/src/main/services/extensions/install/McpInstallService.ts b/src/main/services/extensions/install/McpInstallService.ts index 70daa65e..31e89219 100644 --- a/src/main/services/extensions/install/McpInstallService.ts +++ b/src/main/services/extensions/install/McpInstallService.ts @@ -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.`, }; } diff --git a/src/main/services/extensions/runtime/mcpRuntimeJson.ts b/src/main/services/extensions/runtime/mcpRuntimeJson.ts index f6a52f45..858f532c 100644 --- a/src/main/services/extensions/runtime/mcpRuntimeJson.ts +++ b/src/main/services/extensions/runtime/mcpRuntimeJson.ts @@ -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(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(output); return (parsed.servers ?? []).flatMap((entry) => { - if (typeof entry.name !== 'string' || !isSupportedScope(entry.scope)) { + if (typeof entry.name !== 'string' || !isInstalledMcpScope(entry.scope)) { return []; } diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 7cb8e740..59c28f6f 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -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('stdio'); - const [scope, setScope] = useState('user'); + const [scope, setScope] = useState(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 = ({ - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx index 4f58b343..90a34c4c 100644 --- a/src/renderer/components/extensions/mcp/McpServerCard.tsx +++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx @@ -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} diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 412be5a8..c6515f8d 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -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('user'); + const cliStatus = useStore((s) => s.cliStatus); + const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); + const [scope, setScope] = useState(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 = ({ - {SCOPE_OPTIONS.map((opt) => ( + {scopeOptions.map((opt) => ( {opt.label} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index e1ed7a2d..c73b6b70 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -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 { - 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); diff --git a/src/shared/types/extensions/common.ts b/src/shared/types/extensions/common.ts index b5fd304f..23f31ee8 100644 --- a/src/shared/types/extensions/common.ts +++ b/src/shared/types/extensions/common.ts @@ -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 { diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts index cc971734..ec92a6b9 100644 --- a/src/shared/types/extensions/mcp.ts +++ b/src/shared/types/extensions/mcp.ts @@ -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) diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts index 4bb90652..b0cdb96b 100644 --- a/src/shared/utils/extensionNormalizers.ts +++ b/src/shared/utils/extensionNormalizers.ts @@ -160,6 +160,7 @@ export function getInstallationSummaryLabel( const MCP_SCOPE_PRIORITY: Record = { 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': diff --git a/src/shared/utils/mcpScopes.ts b/src/shared/utils/mcpScopes.ts new file mode 100644 index 00000000..44903816 --- /dev/null +++ b/src/shared/utils/mcpScopes.ts @@ -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; + +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; + } +} diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 31ef3d9f..9947c28d 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { installCustomMcpServer: ReturnType; + 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); diff --git a/test/renderer/components/extensions/mcp/McpServerCard.test.ts b/test/renderer/components/extensions/mcp/McpServerCard.test.ts index 3a5509ec..85ccf6d5 100644 --- a/test/renderer/components/extensions/mcp/McpServerCard.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerCard.test.ts @@ -11,6 +11,7 @@ interface StoreState { uninstallMcpServer: ReturnType; installErrors: Record; mcpGitHubStars: Record; + 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(); + }); + }); }); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index c6f4c204..a5e9ea00 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -11,6 +11,7 @@ interface StoreState { uninstallMcpServer: ReturnType; installErrors: Record; mcpGitHubStars: Record; + 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); diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts index 39ba92fc..de60b2ed 100644 --- a/test/shared/utils/extensionNormalizers.test.ts +++ b/test/shared/utils/extensionNormalizers.test.ts @@ -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([