fix(extensions): enforce runtime capability guards for mcp writes

This commit is contained in:
777genius 2026-04-17 14:58:09 +03:00
parent 14a38212c2
commit 8075ed10e7
5 changed files with 359 additions and 43 deletions

View file

@ -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 => {
))}
</TabsList>
{tabState.activeSubTab === 'mcp-servers' && (
<Button
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="mb-1 whitespace-nowrap"
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={mcpMutationDisableReason ? 0 : -1}>
<Button
variant="outline"
size="sm"
onClick={() => setCustomMcpDialogOpen(true)}
className="mb-1 whitespace-nowrap"
disabled={Boolean(mcpMutationDisableReason)}
>
<Plus className="mr-1 size-3.5" />
Add Custom
</Button>
</span>
</TooltipTrigger>
{mcpMutationDisableReason && (
<TooltipContent>{mcpMutationDisableReason}</TooltipContent>
)}
</Tooltip>
)}
</div>

View file

@ -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 = ({
</div>
{/* 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 && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}

View file

@ -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<AppState, [], [], ExtensionsSlice> = (
@ -898,15 +892,12 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
const preflightError =
effectiveRequest.scope !== 'user' && !effectiveRequest.projectPath
? PROJECT_SCOPE_REQUIRED_MESSAGE
: cliStatus === null
? CLI_STATUS_UNKNOWN_MESSAGE
: !cliStatus.installed
? cliStatus.binaryPath && cliStatus.launchError
? CLI_HEALTHCHECK_FAILED_MESSAGE
: CLI_NOT_FOUND_MESSAGE
: !cliStatus.authLoggedIn
? CLI_AUTH_REQUIRED_MESSAGE
: null;
: getExtensionActionDisableReason({
isInstalled: false,
cliStatus,
cliStatusLoading: get().cliStatusLoading,
section: 'plugins',
});
if (preflightError) {
clearPluginSuccessResetTimer(operationKey);
@ -979,6 +970,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
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);
set((prev) => ({
pluginInstallProgress: { ...prev.pluginInstallProgress, [operationKey]: 'pending' },
@ -1033,6 +1048,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
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);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },
@ -1079,28 +1118,38 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
operationScope,
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);
set((prev) => ({
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<AppState, [], [], ExtensionsSli
mcpInstallProgress: { ...prev.mcpInstallProgress, [progressKey]: 'error' },
installErrors: { ...prev.installErrors, [progressKey]: message },
}));
throw err instanceof Error ? err : new Error(message);
}
},
@ -1142,6 +1192,30 @@ export const createExtensionsSlice: StateCreator<AppState, [], [], ExtensionsSli
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);
set((prev) => ({
mcpInstallProgress: { ...prev.mcpInstallProgress, [operationKey]: 'pending' },

View file

@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
interface StoreState {
installCustomMcpServer: ReturnType<typeof vi.fn>;
cliStatus?: { flavor: 'claude' | 'agent_teams_orchestrator' } | null;
cliStatus?: Record<string, unknown> | 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);

View file

@ -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<typeof vi.fn>).mockResolvedValue(plugins);
(api.plugins!.uninstall as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled 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')]
).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<typeof vi.fn>).mockResolvedValue({ state: 'success' });
(api.mcpRegistry!.getInstalled as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(api.mcpRegistry!.diagnose as ReturnType<typeof vi.fn>).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', () => {