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', () => {