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 { 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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
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 {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Reference in a new issue