Files
claudecodeui/src/components/settings/hooks/useSettingsController.ts
Haileyesus 358f47d020 refactor(settings): move MCP server management into provider module
Extract MCP server settings out of the settings controller and agents tab into a
dedicated frontend MCP module. The settings UI now delegates MCP rendering and
behavior to a single module that only needs the selected provider and current
projects.

Changes:
- Add `src/components/mcp` as the single frontend MCP module
- Move MCP server list rendering into `McpServers`
- Move MCP add/edit modal into `McpServerFormModal`
- Move MCP API/state logic into `useMcpServers`
- Move MCP form state/validation logic into `useMcpServerForm`
- Add provider-specific MCP constants, types, and formatting helpers
- Use the unified `/api/providers/:provider/mcp/servers` API for all providers
- Support MCP management for Claude, Cursor, Codex, and Gemini
- Remove old settings-owned Claude/Codex MCP modal components
- Remove old provider-specific `McpServersContent` branching from settings
- Strip MCP server state, fetch, save, delete, and modal ownership from
  `useSettingsController`
- Simplify agents settings props so MCP only receives `selectedProvider` and
  `currentProjects`
- Keep Claude working-directory unsupported while preserving cwd support for
  Cursor, Codex, and Gemini
- Add progressive MCP loading:
  - render user/global scope first
  - load project/local scopes in the background
  - append project results as they resolve
  - cache MCP lists briefly to avoid slow tab-switch refetches
  - ignore stale async responses after provider switches

Verification:
- `npx eslint src/components/mcp`
- `npm run typecheck`
- `npm run build:client`
2026-04-16 22:22:35 +03:00

388 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { useTheme } from '../../../contexts/ThemeContext';
import { authenticatedFetch } from '../../../utils/api';
import { useProviderAuthStatus } from '../../provider-auth/hooks/useProviderAuthStatus';
import {
DEFAULT_CODE_EDITOR_SETTINGS,
DEFAULT_CURSOR_PERMISSIONS,
} from '../constants/constants';
import type {
AgentProvider,
ClaudePermissionsState,
CodeEditorSettingsState,
CodexPermissionMode,
CursorPermissionsState,
GeminiPermissionMode,
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
} from '../types/types';
type ThemeContextValue = {
isDarkMode: boolean;
toggleDarkMode: () => void;
};
type UseSettingsControllerArgs = {
isOpen: boolean;
initialTab: string;
};
type ClaudeSettingsStorage = {
allowedTools?: string[];
disallowedTools?: string[];
skipPermissions?: boolean;
projectSortOrder?: ProjectSortOrder;
};
type CursorSettingsStorage = {
allowedCommands?: string[];
disallowedCommands?: string[];
skipPermissions?: boolean;
};
type CodexSettingsStorage = {
permissionMode?: CodexPermissionMode;
};
type NotificationPreferencesResponse = {
success?: boolean;
preferences?: NotificationPreferencesState;
};
type ActiveLoginProvider = AgentProvider | '';
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
const normalizeMainTab = (tab: string): SettingsMainTab => {
// Keep backwards compatibility with older callers that still pass "tools".
if (tab === 'tools') {
return 'agents';
}
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
};
const parseJson = <T>(value: string | null, fallback: T): T => {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
};
const toCodexPermissionMode = (value: unknown): CodexPermissionMode => {
if (value === 'acceptEdits' || value === 'bypassPermissions') {
return value;
}
return 'default';
};
const readCodeEditorSettings = (): CodeEditorSettingsState => ({
theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',
wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',
showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',
lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
});
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
});
const createEmptyCursorPermissions = (): CursorPermissionsState => ({
...DEFAULT_CURSOR_PERMISSIONS,
});
const createDefaultNotificationPreferences = (): NotificationPreferencesState => ({
channels: {
inApp: true,
webPush: false,
},
events: {
actionRequired: true,
stop: true,
error: true,
},
});
export function useSettingsController({ isOpen, initialTab }: UseSettingsControllerArgs) {
const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;
const closeTimerRef = useRef<number | null>(null);
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
readCodeEditorSettings()
));
const [claudePermissions, setClaudePermissions] = useState<ClaudePermissionsState>(() => (
createEmptyClaudePermissions()
));
const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (
createEmptyCursorPermissions()
));
const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (
createDefaultNotificationPreferences()
));
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const {
providerAuthStatus,
checkProviderAuthStatus,
refreshProviderAuthStatuses,
} = useProviderAuthStatus();
const loadSettings = useCallback(async () => {
try {
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
localStorage.getItem('claude-settings'),
{},
);
setClaudePermissions({
allowedTools: savedClaudeSettings.allowedTools || [],
disallowedTools: savedClaudeSettings.disallowedTools || [],
skipPermissions: Boolean(savedClaudeSettings.skipPermissions),
});
setProjectSortOrder(savedClaudeSettings.projectSortOrder === 'date' ? 'date' : 'name');
const savedCursorSettings = parseJson<CursorSettingsStorage>(
localStorage.getItem('cursor-tools-settings'),
{},
);
setCursorPermissions({
allowedCommands: savedCursorSettings.allowedCommands || [],
disallowedCommands: savedCursorSettings.disallowedCommands || [],
skipPermissions: Boolean(savedCursorSettings.skipPermissions),
});
const savedCodexSettings = parseJson<CodexSettingsStorage>(
localStorage.getItem('codex-settings'),
{},
);
setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));
const savedGeminiSettings = parseJson<{ permissionMode?: GeminiPermissionMode }>(
localStorage.getItem('gemini-settings'),
{},
);
setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');
try {
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');
if (notificationResponse.ok) {
const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);
if (notificationData.success && notificationData.preferences) {
setNotificationPreferences(notificationData.preferences);
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} else {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} catch {
setNotificationPreferences(createDefaultNotificationPreferences());
}
} catch (error) {
console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions());
setCursorPermissions(createEmptyCursorPermissions());
setNotificationPreferences(createDefaultNotificationPreferences());
setCodexPermissionMode('default');
setProjectSortOrder('name');
}
}, []);
const openLoginForProvider = useCallback((provider: AgentProvider) => {
setLoginProvider(provider);
setShowLoginModal(true);
}, []);
const handleLoginComplete = useCallback((exitCode: number) => {
if (exitCode !== 0 || !loginProvider) {
return;
}
setSaveStatus('success');
void checkProviderAuthStatus(loginProvider);
}, [checkProviderAuthStatus, loginProvider]);
const saveSettings = useCallback(async () => {
setSaveStatus(null);
try {
const now = new Date().toISOString();
localStorage.setItem('claude-settings', JSON.stringify({
allowedTools: claudePermissions.allowedTools,
disallowedTools: claudePermissions.disallowedTools,
skipPermissions: claudePermissions.skipPermissions,
projectSortOrder,
lastUpdated: now,
}));
localStorage.setItem('cursor-tools-settings', JSON.stringify({
allowedCommands: cursorPermissions.allowedCommands,
disallowedCommands: cursorPermissions.disallowedCommands,
skipPermissions: cursorPermissions.skipPermissions,
lastUpdated: now,
}));
localStorage.setItem('codex-settings', JSON.stringify({
permissionMode: codexPermissionMode,
lastUpdated: now,
}));
localStorage.setItem('gemini-settings', JSON.stringify({
permissionMode: geminiPermissionMode,
lastUpdated: now,
}));
const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {
method: 'PUT',
body: JSON.stringify(notificationPreferences),
});
if (!notificationResponse.ok) {
throw new Error('Failed to save notification preferences');
}
setSaveStatus('success');
} catch (error) {
console.error('Error saving settings:', error);
setSaveStatus('error');
}
}, [
claudePermissions.allowedTools,
claudePermissions.disallowedTools,
claudePermissions.skipPermissions,
codexPermissionMode,
cursorPermissions.allowedCommands,
cursorPermissions.disallowedCommands,
cursorPermissions.skipPermissions,
notificationPreferences,
geminiPermissionMode,
projectSortOrder,
]);
const updateCodeEditorSetting = useCallback(
<K extends keyof CodeEditorSettingsState>(key: K, value: CodeEditorSettingsState[K]) => {
setCodeEditorSettings((prev) => ({ ...prev, [key]: value }));
},
[],
);
useEffect(() => {
if (!isOpen) {
return;
}
setActiveTab(normalizeMainTab(initialTab));
void loadSettings();
void refreshProviderAuthStatuses();
}, [initialTab, isOpen, loadSettings, refreshProviderAuthStatuses]);
useEffect(() => {
localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);
localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));
localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));
localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));
localStorage.setItem('codeEditorFontSize', codeEditorSettings.fontSize);
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
}, [codeEditorSettings]);
// Auto-save permissions and sort order with debounce
const autoSaveTimerRef = useRef<number | null>(null);
const isInitialLoadRef = useRef(true);
useEffect(() => {
// Skip auto-save on initial load (settings are being loaded from localStorage)
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false;
return;
}
if (autoSaveTimerRef.current !== null) {
window.clearTimeout(autoSaveTimerRef.current);
}
autoSaveTimerRef.current = window.setTimeout(() => {
saveSettings();
}, 500);
return () => {
if (autoSaveTimerRef.current !== null) {
window.clearTimeout(autoSaveTimerRef.current);
}
};
}, [saveSettings]);
// Clear save status after 2 seconds
useEffect(() => {
if (saveStatus === null) {
return;
}
const timer = window.setTimeout(() => setSaveStatus(null), 2000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
// Reset initial load flag when settings dialog opens
useEffect(() => {
if (isOpen) {
isInitialLoadRef.current = true;
}
}, [isOpen]);
useEffect(() => () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
if (autoSaveTimerRef.current !== null) {
window.clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
}, []);
return {
activeTab,
setActiveTab,
isDarkMode,
toggleDarkMode,
saveStatus,
projectSortOrder,
setProjectSortOrder,
codeEditorSettings,
updateCodeEditorSetting,
claudePermissions,
setClaudePermissions,
cursorPermissions,
setCursorPermissions,
notificationPreferences,
setNotificationPreferences,
codexPermissionMode,
setCodexPermissionMode,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,
openLoginForProvider,
showLoginModal,
setShowLoginModal,
loginProvider,
handleLoginComplete,
};
}