diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 396271ab..4d76908b 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -21,6 +21,7 @@ import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; import { useExtensionsTabState } from '@renderer/hooks/useExtensionsTabState'; import { useStore } from '@renderer/store'; 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 { useShallow } from 'zustand/react/shallow'; @@ -165,6 +166,16 @@ export const ExtensionStoreView = (): React.JSX.Element => { const isRefreshing = cliStatusLoading || apiKeysLoading || pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const mcpMutationDisableReason = useMemo( + () => + getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }), + [cliStatus, cliStatusLoading] + ); const cliStatusBanner = useMemo(() => { const providers = cliStatus?.providers ?? []; const visibleProviders = providers.filter((provider) => provider.providerId !== 'gemini'); @@ -388,15 +399,25 @@ export const ExtensionStoreView = (): React.JSX.Element => { ))} {tabState.activeSubTab === 'mcp-servers' && ( - + + + + + + + {mcpMutationDisableReason && ( + {mcpMutationDisableReason} + )} + )} diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 66fc266d..4bfa4c0b 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -24,6 +24,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; +import { getExtensionActionDisableReason } from '@shared/utils/extensionNormalizers'; import { getDefaultMcpSharedScope, getMcpScopeLabel, @@ -67,6 +68,7 @@ export const CustomMcpServerDialog = ({ }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); const cliStatus = useStore((s) => s.cliStatus); + const cliStatusLoading = useStore((s) => s.cliStatusLoading); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const scopeOptions: { value: Scope; label: string }[] = [ { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, @@ -101,6 +103,12 @@ export const CustomMcpServerDialog = ({ const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined; + const mutationDisableReason = getExtensionActionDisableReason({ + isInstalled: false, + cliStatus, + cliStatusLoading, + section: 'mcp', + }); // Reset on open useEffect(() => { @@ -181,6 +189,11 @@ export const CustomMcpServerDialog = ({ const handleInstall = async () => { setError(null); + if (mutationDisableReason) { + setError(mutationDisableReason); + return; + } + if (!serverName.trim()) { setError('Server name is required'); return; @@ -255,6 +268,7 @@ export const CustomMcpServerDialog = ({ serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && !(isProjectScopedMcpScope(scope) && !projectPath) && + !mutationDisableReason && !installing; return ( @@ -483,6 +497,11 @@ export const CustomMcpServerDialog = ({ {/* Error */} + {mutationDisableReason && ( +
+ {mutationDisableReason} +
+ )} {error && (
{error} diff --git a/src/renderer/store/slices/extensionsSlice.ts b/src/renderer/store/slices/extensionsSlice.ts index 4ae2855a..a0301d00 100644 --- a/src/renderer/store/slices/extensionsSlice.ts +++ b/src/renderer/store/slices/extensionsSlice.ts @@ -4,9 +4,9 @@ */ import { api } from '@renderer/api'; -import { CLI_NOT_FOUND_MESSAGE } from '@shared/constants/cli'; import { isProjectScopedMcpScope } from '@shared/utils/mcpScopes'; import { + getExtensionActionDisableReason, getMcpDiagnosticKey, getMcpProjectStateKey, getMcpOperationKey, @@ -338,12 +338,6 @@ function getSkillsCatalogKey(projectPath?: string): string { /** Duration to show "success" state before returning to idle */ 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 = 'Project- and local-scoped plugins require an active project in the Extensions tab.'; export const createExtensionsSlice: StateCreator = ( @@ -898,15 +892,12 @@ export const createExtensionsSlice: StateCreator ({ + pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason }, + })); + return; + } + clearPluginSuccessResetTimer(operationKey); set((prev) => ({ pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' }, @@ -1033,6 +1048,30 @@ export const createExtensionsSlice: StateCreator ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: installDisableReason }, + })); + return; + } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, @@ -1079,28 +1118,38 @@ export const createExtensionsSlice: StateCreator ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, - installErrors: { ...prev.installErrors, [progressKey]: 'MCP Registry not available' }, + mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, })); - return; - } - clearMcpSuccessResetTimer(progressKey); - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'pending' }, - })); - - try { const result = await api.mcpRegistry.installCustom(request); if (result.state === 'error') { - set((prev) => ({ - mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' }, - installErrors: { ...prev.installErrors, [progressKey]: result.error ?? 'Install failed' }, - })); - return; + throw new Error(result.error ?? 'Install failed'); } await Promise.all([ @@ -1120,6 +1169,7 @@ export const createExtensionsSlice: StateCreator ({ + mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'error' }, + installErrors: { ...prev.installErrors, [operationKey]: uninstallDisableReason }, + })); + return; + } + clearMcpSuccessResetTimer(operationKey); set((prev) => ({ mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' }, diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 177ffa11..c8adaada 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; interface StoreState { installCustomMcpServer: ReturnType; - cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null; + cliStatus?: Record | null; + cliStatusLoading?: boolean; } const storeState = {} as StoreState; @@ -117,7 +118,15 @@ describe('CustomMcpServerDialog project scope', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); 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.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 () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts index 065b6555..27790d32 100644 --- a/test/renderer/store/extensionsSlice.test.ts +++ b/test/renderer/store/extensionsSlice.test.ts @@ -22,6 +22,7 @@ vi.mock('../../../src/renderer/api', () => ({ getInstalled: vi.fn(), diagnose: vi.fn(), install: vi.fn(), + installCustom: vi.fn(), uninstall: vi.fn(), }, skills: { @@ -153,6 +154,52 @@ const makeReadyCliStatus = () => ({ 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 = ( pluginId: string, 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 () => { store.setState({ cliStatus: makeReadyCliStatus(), @@ -682,6 +745,7 @@ describe('extensionsSlice', () => { describe('uninstallPlugin', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); const plugins = [makePlugin({ pluginId: 'a@m', isInstalled: false })]; (api.plugins!.getAll as ReturnType).mockResolvedValue(plugins); (api.plugins!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); @@ -785,6 +849,7 @@ describe('extensionsSlice', () => { describe('installMcpServer', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.install as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); @@ -840,10 +905,60 @@ describe('extensionsSlice', () => { store.getState().mcpInstallProgress[mcpOperationKey('test-id', 'project', '/tmp/project-a')] ).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', () => { it('sets progress to pending then success', async () => { + store.setState({ cliStatus: makeReadyCliStatus() }); (api.mcpRegistry!.uninstall as ReturnType).mockResolvedValue({ state: 'success' }); (api.mcpRegistry!.getInstalled as ReturnType).mockResolvedValue([]); (api.mcpRegistry!.diagnose as ReturnType).mockResolvedValue([]); @@ -859,6 +974,22 @@ describe('extensionsSlice', () => { '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', () => {