fix(extensions): enforce runtime capability guards for mcp writes
This commit is contained in:
parent
14a38212c2
commit
8075ed10e7
5 changed files with 359 additions and 43 deletions
|
|
@ -21,6 +21,7 @@ import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
|
||||||
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||||
|
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||||
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
import { AlertTriangle, BookOpen, Info, Key, Plus, Puzzle, RefreshCw, Server } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -165,6 +166,16 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
|
|
||||||
const isRefreshing =
|
const isRefreshing =
|
||||||
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading;
|
||||||
|
const mcpMutationDisableReason = useMemo(
|
||||||
|
() =>
|
||||||
|
getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
cliStatus,
|
||||||
|
cliStatusLoading,
|
||||||
|
section: 'mcp',
|
||||||
|
}),
|
||||||
|
[cliStatus, cliStatusLoading]
|
||||||
|
);
|
||||||
const cliStatusBanner = useMemo(() => {
|
const cliStatusBanner = useMemo(() => {
|
||||||
const providers = cliStatus?.providers ?? [];
|
const providers = cliStatus?.providers ?? [];
|
||||||
const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini');
|
const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini');
|
||||||
|
|
@ -388,15 +399,25 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{tabState.activeSubTab === 'mcp-servers' && (
|
{tabState.activeSubTab === 'mcp-servers' && (
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="outline"
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<span tabIndex={mcpMutationDisableReason ? 0 : -1}>
|
||||||
onClick={() => setCustomMcpDialogOpen(true)}
|
<Button
|
||||||
className="mb-1 whitespace-nowrap"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<Plus className="mr-1 size-3.5" />
|
onClick={() => setCustomMcpDialogOpen(true)}
|
||||||
Add Custom
|
className="mb-1 whitespace-nowrap"
|
||||||
</Button>
|
disabled={Boolean(mcpMutationDisableReason)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 size-3.5" />
|
||||||
|
Add Custom
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{mcpMutationDisableReason && (
|
||||||
|
<TooltipContent>{mcpMutationDisableReason}</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers';
|
||||||
import {
|
import {
|
||||||
getDefaultMcpSharedScope,
|
getDefaultMcpSharedScope,
|
||||||
getMcpScopeLabel,
|
getMcpScopeLabel,
|
||||||
|
|
@ -67,6 +68,7 @@ export const CustomMcpServerDialog = ({
|
||||||
}: 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 cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||||
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
|
||||||
const scopeOptions: { value: Scope; label: string }[] = [
|
const scopeOptions: { value: Scope; label: string }[] = [
|
||||||
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },
|
{ value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) },
|
||||||
|
|
@ -101,6 +103,12 @@ export const CustomMcpServerDialog = ({
|
||||||
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
|
const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope)
|
||||||
? (projectPath ?? undefined)
|
? (projectPath ?? undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const mutationDisableReason = getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
cliStatus,
|
||||||
|
cliStatusLoading,
|
||||||
|
section: 'mcp',
|
||||||
|
});
|
||||||
|
|
||||||
// Reset on open
|
// Reset on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -181,6 +189,11 @@ export const CustomMcpServerDialog = ({
|
||||||
const handleInstall = async () => {
|
const handleInstall = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (mutationDisableReason) {
|
||||||
|
setError(mutationDisableReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!serverName.trim()) {
|
if (!serverName.trim()) {
|
||||||
setError('Server name is required');
|
setError('Server name is required');
|
||||||
return;
|
return;
|
||||||
|
|
@ -255,6 +268,7 @@ export const CustomMcpServerDialog = ({
|
||||||
serverName.trim() &&
|
serverName.trim() &&
|
||||||
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
(transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) &&
|
||||||
!(isProjectScopedMcpScope(scope) && !projectPath) &&
|
!(isProjectScopedMcpScope(scope) && !projectPath) &&
|
||||||
|
!mutationDisableReason &&
|
||||||
!installing;
|
!installing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -483,6 +497,11 @@ export const CustomMcpServerDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
|
{mutationDisableReason && (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
|
||||||
|
{mutationDisableReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
||||||
{error}
|
{error}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli';
|
|
||||||
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes';
|
||||||
import {
|
import {
|
||||||
|
getExtensionActionDisableReason,
|
||||||
getMcpDiagnosticKey,
|
getMcpDiagnosticKey,
|
||||||
getMcpProjectStateKey,
|
getMcpProjectStateKey,
|
||||||
getMcpOperationKey,
|
getMcpOperationKey,
|
||||||
|
|
@ -338,12 +338,6 @@ function getSkillsCatalogKey(projectPath?: string): string {
|
||||||
|
|
||||||
/** Duration to show "success" state before returning to idle */
|
/** Duration to show "success" state before returning to idle */
|
||||||
const SUCCESS_DISPLAY_MS = 2_000;
|
const SUCCESS_DISPLAY_MS = 2_000;
|
||||||
const CLI_AUTH_REQUIRED_MESSAGE =
|
|
||||||
'Claude CLI is installed but not signed in. Go to the Dashboard and sign in to enable plugin installs.';
|
|
||||||
const CLI_HEALTHCHECK_FAILED_MESSAGE =
|
|
||||||
'Claude CLI was found but failed its startup health check. Open the Dashboard to repair or reinstall it before retrying.';
|
|
||||||
const CLI_STATUS_UNKNOWN_MESSAGE =
|
|
||||||
'Unable to verify Claude CLI status. Open the Dashboard and check the CLI before retrying.';
|
|
||||||
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
const PROJECT_SCOPE_REQUIRED_MESSAGE =
|
||||||
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
'Project- and local-scoped plugins require an active project in the Extensions tab.';
|
||||||
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSlice> = (
|
||||||
|
|
@ -898,15 +892,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
const preflightError =
|
const preflightError =
|
||||||
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
|
||||||
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
? PROJECT_SCOPE_REQUIRED_MESSAGE
|
||||||
: cliStatus === null
|
: getExtensionActionDisableReason({
|
||||||
? CLI_STATUS_UNKNOWN_MESSAGE
|
isInstalled: false,
|
||||||
: !cliStatus.installed
|
cliStatus,
|
||||||
? cliStatus.binaryPath && cliStatus.launchError
|
cliStatusLoading: get().cliStatusLoading,
|
||||||
? CLI_HEALTHCHECK_FAILED_MESSAGE
|
section: 'plugins',
|
||||||
: CLI_NOT_FOUND_MESSAGE
|
});
|
||||||
: !cliStatus.authLoggedIn
|
|
||||||
? CLI_AUTH_REQUIRED_MESSAGE
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (preflightError) {
|
if (preflightError) {
|
||||||
clearPluginSuccessResetTimer(operationKey);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
|
|
@ -979,6 +970,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preflightState = get();
|
||||||
|
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||||
|
try {
|
||||||
|
await preflightState.fetchCliStatus();
|
||||||
|
} catch {
|
||||||
|
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uninstallDisableReason = getExtensionActionDisableReason({
|
||||||
|
isInstalled: true,
|
||||||
|
cliStatus: get().cliStatus,
|
||||||
|
cliStatusLoading: get().cliStatusLoading,
|
||||||
|
section: 'plugins',
|
||||||
|
});
|
||||||
|
if (uninstallDisableReason) {
|
||||||
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
|
set((prev) => ({
|
||||||
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearPluginSuccessResetTimer(operationKey);
|
clearPluginSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
|
||||||
|
|
@ -1033,6 +1048,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preflightState = get();
|
||||||
|
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||||
|
try {
|
||||||
|
await preflightState.fetchCliStatus();
|
||||||
|
} catch {
|
||||||
|
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installDisableReason = getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
cliStatus: get().cliStatus,
|
||||||
|
cliStatusLoading: get().cliStatusLoading,
|
||||||
|
section: 'mcp',
|
||||||
|
});
|
||||||
|
if (installDisableReason) {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
|
set((prev) => ({
|
||||||
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [operationKey]: installDisableReason },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearMcpSuccessResetTimer(operationKey);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||||
|
|
@ -1079,28 +1118,38 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
operationScope,
|
operationScope,
|
||||||
request.projectPath
|
request.projectPath
|
||||||
);
|
);
|
||||||
if (!api.mcpRegistry) {
|
try {
|
||||||
|
if (!api.mcpRegistry) {
|
||||||
|
throw new Error('MCP Registry not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const preflightState = get();
|
||||||
|
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||||
|
try {
|
||||||
|
await preflightState.fetchCliStatus();
|
||||||
|
} catch {
|
||||||
|
// fetchCliStatus stores the error in cliStatusError; map to a user-facing install error below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const installDisableReason = getExtensionActionDisableReason({
|
||||||
|
isInstalled: false,
|
||||||
|
cliStatus: get().cliStatus,
|
||||||
|
cliStatusLoading: get().cliStatusLoading,
|
||||||
|
section: 'mcp',
|
||||||
|
});
|
||||||
|
if (installDisableReason) {
|
||||||
|
throw new Error(installDisableReason);
|
||||||
|
}
|
||||||
|
|
||||||
clearMcpSuccessResetTimer(progressKey);
|
clearMcpSuccessResetTimer(progressKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
||||||
installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' },
|
|
||||||
}));
|
}));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearMcpSuccessResetTimer(progressKey);
|
|
||||||
set((prev) => ({
|
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' },
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.mcpRegistry.installCustom(request);
|
const result = await api.mcpRegistry.installCustom(request);
|
||||||
if (result.state === 'error') {
|
if (result.state === 'error') {
|
||||||
set((prev) => ({
|
throw new Error(result.error ?? 'Install failed');
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
|
||||||
installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' },
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|
@ -1120,6 +1169,7 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
|
||||||
installErrors: { ...prev.installErrors, [progressKey]: message },
|
installErrors: { ...prev.installErrors, [progressKey]: message },
|
||||||
}));
|
}));
|
||||||
|
throw err instanceof Error ? err : new Error(message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -1142,6 +1192,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preflightState = get();
|
||||||
|
if (preflightState.cliStatus === null || preflightState.cliStatusLoading) {
|
||||||
|
try {
|
||||||
|
await preflightState.fetchCliStatus();
|
||||||
|
} catch {
|
||||||
|
// fetchCliStatus stores the error in cliStatusError; map to a user-facing uninstall error below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uninstallDisableReason = getExtensionActionDisableReason({
|
||||||
|
isInstalled: true,
|
||||||
|
cliStatus: get().cliStatus,
|
||||||
|
cliStatusLoading: get().cliStatusLoading,
|
||||||
|
section: 'mcp',
|
||||||
|
});
|
||||||
|
if (uninstallDisableReason) {
|
||||||
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
|
set((prev) => ({
|
||||||
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' },
|
||||||
|
installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason },
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
clearMcpSuccessResetTimer(operationKey);
|
clearMcpSuccessResetTimer(operationKey);
|
||||||
set((prev) => ({
|
set((prev) => ({
|
||||||
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ 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;
|
cliStatus?: Record<string, unknown> | null;
|
||||||
|
cliStatusLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeState = {} as StoreState;
|
const storeState = {} as StoreState;
|
||||||
|
|
@ -117,7 +118,15 @@ 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;
|
storeState.cliStatus = {
|
||||||
|
flavor: 'claude',
|
||||||
|
installed: true,
|
||||||
|
authLoggedIn: true,
|
||||||
|
binaryPath: '/usr/local/bin/claude',
|
||||||
|
launchError: null,
|
||||||
|
providers: [],
|
||||||
|
};
|
||||||
|
storeState.cliStatusLoading = false;
|
||||||
lookupMock.mockReset();
|
lookupMock.mockReset();
|
||||||
lookupMock.mockResolvedValue([]);
|
lookupMock.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
@ -180,6 +189,68 @@ describe('CustomMcpServerDialog project scope', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables installation when the runtime declares MCP writes unavailable', async () => {
|
||||||
|
storeState.cliStatus = {
|
||||||
|
flavor: 'agent_teams_orchestrator',
|
||||||
|
installed: true,
|
||||||
|
authLoggedIn: true,
|
||||||
|
binaryPath: '/usr/local/bin/claude-multimodel',
|
||||||
|
launchError: null,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'anthropic',
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
verificationState: 'verified',
|
||||||
|
models: [],
|
||||||
|
canLoginFromUi: true,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
extensions: {
|
||||||
|
plugins: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
mcp: {
|
||||||
|
status: 'read-only',
|
||||||
|
ownership: 'shared',
|
||||||
|
reason: 'MCP writes unavailable',
|
||||||
|
},
|
||||||
|
skills: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
apiKeys: { status: 'supported', ownership: 'shared', reason: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('MCP writes unavailable');
|
||||||
|
const installButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent?.includes('Install')
|
||||||
|
) as HTMLButtonElement | undefined;
|
||||||
|
expect(installButton).toBeDefined();
|
||||||
|
expect(installButton?.disabled).toBe(true);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('looks up project-scoped API keys only when project scope is selected', async () => {
|
it('looks up project-scoped API keys only when project scope is selected', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ vi.mock('../../../src/renderer/api', () => ({
|
||||||
getInstalled: vi.fn(),
|
getInstalled: vi.fn(),
|
||||||
diagnose: vi.fn(),
|
diagnose: vi.fn(),
|
||||||
install: vi.fn(),
|
install: vi.fn(),
|
||||||
|
installCustom: vi.fn(),
|
||||||
uninstall: vi.fn(),
|
uninstall: vi.fn(),
|
||||||
},
|
},
|
||||||
skills: {
|
skills: {
|
||||||
|
|
@ -153,6 +154,52 @@ const makeReadyCliStatus = () => ({
|
||||||
providers: [],
|
providers: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeLimitedMultimodelCliStatus = (section: 'plugins' | 'mcp', reason: string) => ({
|
||||||
|
flavor: 'agent_teams_orchestrator' as const,
|
||||||
|
displayName: 'Claude Multimodel',
|
||||||
|
supportsSelfUpdate: false,
|
||||||
|
showVersionDetails: true,
|
||||||
|
showBinaryPath: true,
|
||||||
|
installed: true,
|
||||||
|
installedVersion: '1.0.0',
|
||||||
|
binaryPath: '/usr/local/bin/claude-multimodel',
|
||||||
|
latestVersion: '1.0.0',
|
||||||
|
updateAvailable: false,
|
||||||
|
authLoggedIn: true,
|
||||||
|
authStatusChecking: false,
|
||||||
|
authMethod: null,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
providerId: 'anthropic' as const,
|
||||||
|
displayName: 'Anthropic',
|
||||||
|
supported: true,
|
||||||
|
authenticated: true,
|
||||||
|
authMethod: 'oauth_token',
|
||||||
|
verificationState: 'verified' as const,
|
||||||
|
models: [],
|
||||||
|
canLoginFromUi: true,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: true,
|
||||||
|
oneShot: true,
|
||||||
|
extensions: {
|
||||||
|
plugins: {
|
||||||
|
status: section === 'plugins' ? 'unsupported' : 'supported',
|
||||||
|
ownership: 'shared' as const,
|
||||||
|
reason: section === 'plugins' ? reason : null,
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
status: section === 'mcp' ? 'read-only' : 'supported',
|
||||||
|
ownership: 'shared' as const,
|
||||||
|
reason: section === 'mcp' ? reason : null,
|
||||||
|
},
|
||||||
|
skills: { status: 'supported', ownership: 'shared' as const, reason: null },
|
||||||
|
apiKeys: { status: 'supported', ownership: 'shared' as const, reason: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const pluginOperationKey = (
|
const pluginOperationKey = (
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
scope: 'user' | 'project' | 'local' = 'user',
|
scope: 'user' | 'project' | 'local' = 'user',
|
||||||
|
|
@ -618,6 +665,22 @@ describe('extensionsSlice', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails fast when multimodel runtime declares plugin installs unsupported', async () => {
|
||||||
|
store.setState({
|
||||||
|
cliStatus: makeLimitedMultimodelCliStatus('plugins', 'Plugin writes unavailable'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().installPlugin({ pluginId: 'unsupported@m', scope: 'user' });
|
||||||
|
|
||||||
|
expect(api.plugins!.install).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().pluginInstallProgress[pluginOperationKey('unsupported@m')]).toBe(
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
expect(store.getState().installErrors[pluginOperationKey('unsupported@m')]).toContain(
|
||||||
|
'Plugin writes unavailable',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('fills missing projectPath for local scope from the active Extensions project context', async () => {
|
it('fills missing projectPath for local scope from the active Extensions project context', async () => {
|
||||||
store.setState({
|
store.setState({
|
||||||
cliStatus: makeReadyCliStatus(),
|
cliStatus: makeReadyCliStatus(),
|
||||||
|
|
@ -682,6 +745,7 @@ describe('extensionsSlice', () => {
|
||||||
|
|
||||||
describe('uninstallPlugin', () => {
|
describe('uninstallPlugin', () => {
|
||||||
it('sets progress to pending then success', async () => {
|
it('sets progress to pending then success', async () => {
|
||||||
|
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||||
const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })];
|
const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })];
|
||||||
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
|
(api.plugins!.getAll as ReturnType<typeof vi.fn>).mockResolvedValue(plugins);
|
||||||
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
|
|
@ -785,6 +849,7 @@ describe('extensionsSlice', () => {
|
||||||
|
|
||||||
describe('installMcpServer', () => {
|
describe('installMcpServer', () => {
|
||||||
it('sets progress to pending then success', async () => {
|
it('sets progress to pending then success', async () => {
|
||||||
|
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||||
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
(api.mcpRegistry!.install as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
@ -840,10 +905,60 @@ describe('extensionsSlice', () => {
|
||||||
store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')]
|
store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')]
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails fast when multimodel runtime exposes MCP as read-only', async () => {
|
||||||
|
store.setState({
|
||||||
|
cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().installMcpServer({
|
||||||
|
registryId: 'test-id',
|
||||||
|
serverName: 'test-server',
|
||||||
|
scope: 'global',
|
||||||
|
envValues: {},
|
||||||
|
headers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(api.mcpRegistry!.install).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe(
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain(
|
||||||
|
'MCP writes unavailable',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('installCustomMcpServer', () => {
|
||||||
|
it('rejects and records an error when MCP writes are unavailable', async () => {
|
||||||
|
store.setState({
|
||||||
|
cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.getState().installCustomMcpServer({
|
||||||
|
serverName: 'custom-server',
|
||||||
|
scope: 'global',
|
||||||
|
installSpec: {
|
||||||
|
type: 'stdio',
|
||||||
|
npmPackage: '@example/custom-mcp',
|
||||||
|
},
|
||||||
|
envValues: {},
|
||||||
|
headers: [],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('MCP writes unavailable');
|
||||||
|
|
||||||
|
expect(api.mcpRegistry!.installCustom).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().mcpInstallProgress['mcp-custom:custom-server:global']).toBe('error');
|
||||||
|
expect(store.getState().installErrors['mcp-custom:custom-server:global']).toContain(
|
||||||
|
'MCP writes unavailable',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('uninstallMcpServer', () => {
|
describe('uninstallMcpServer', () => {
|
||||||
it('sets progress to pending then success', async () => {
|
it('sets progress to pending then success', async () => {
|
||||||
|
store.setState({ cliStatus: makeReadyCliStatus() });
|
||||||
(api.mcpRegistry!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
(api.mcpRegistry!.uninstall as ReturnType<typeof vi.fn>).mockResolvedValue({ state: 'success' });
|
||||||
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
|
@ -859,6 +974,22 @@ describe('extensionsSlice', () => {
|
||||||
'success',
|
'success',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails fast when multimodel runtime exposes MCP as read-only', async () => {
|
||||||
|
store.setState({
|
||||||
|
cliStatus: makeLimitedMultimodelCliStatus('mcp', 'MCP writes unavailable'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().uninstallMcpServer('test-id', 'test-server', 'global');
|
||||||
|
|
||||||
|
expect(api.mcpRegistry!.uninstall).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'global')]).toBe(
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
expect(store.getState().installErrors[mcpOperationKey('test-id', 'global')]).toContain(
|
||||||
|
'MCP writes unavailable',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('provider-aware runtime refresh', () => {
|
describe('provider-aware runtime refresh', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue