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 { 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue