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`
This commit is contained in:
Haileyesus
2026-04-16 22:22:35 +03:00
parent 5c53100651
commit 358f47d020
18 changed files with 1573 additions and 1591 deletions

View File

@@ -8,14 +8,11 @@ import {
} from '../constants/constants';
import type {
AgentProvider,
ClaudeMcpFormState,
ClaudePermissionsState,
CodeEditorSettingsState,
CodexMcpFormState,
CodexPermissionMode,
CursorPermissionsState,
GeminiPermissionMode,
McpServer,
NotificationPreferencesState,
ProjectSortOrder,
SettingsMainTab,
@@ -31,31 +28,6 @@ type UseSettingsControllerArgs = {
initialTab: string;
};
type JsonResult = {
success?: boolean;
error?: string;
};
type McpReadResponse = {
success?: boolean;
servers?: McpServer[];
};
type McpCliServer = {
name: string;
type?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
url?: string;
headers?: Record<string, string>;
};
type McpCliReadResponse = {
success?: boolean;
servers?: McpCliServer[];
};
type ClaudeSettingsStorage = {
allowedTools?: string[];
disallowedTools?: string[];
@@ -91,10 +63,6 @@ const normalizeMainTab = (tab: string): SettingsMainTab => {
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
};
const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
const parseJson = <T>(value: string | null, fallback: T): T => {
if (!value) {
return fallback;
@@ -123,25 +91,6 @@ const readCodeEditorSettings = (): CodeEditorSettingsState => ({
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
});
const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (
servers.map((server) => ({
id: server.name,
name: server.name,
type: server.type || 'stdio',
scope: 'user',
config: {
command: server.command || '',
args: server.args || [],
env: server.env || {},
url: server.url || '',
headers: server.headers || {},
timeout: 30000,
},
created: new Date().toISOString(),
updated: new Date().toISOString(),
}))
);
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
@@ -172,7 +121,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
readCodeEditorSettings()
@@ -190,15 +138,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);
const [showMcpForm, setShowMcpForm] = useState(false);
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
const [editingCodexMcpServer, setEditingCodexMcpServer] = useState<McpServer | null>(null);
const [showLoginModal, setShowLoginModal] = useState(false);
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
const {
@@ -207,284 +146,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
refreshProviderAuthStatuses,
} = useProviderAuthStatus();
const fetchCursorMcpServers = useCallback(async () => {
try {
const response = await authenticatedFetch('/api/cursor/mcp');
if (!response.ok) {
console.error('Failed to fetch Cursor MCP servers');
return;
}
const data = await toResponseJson<{ servers?: McpServer[] }>(response);
setCursorMcpServers(data.servers || []);
} catch (error) {
console.error('Error fetching Cursor MCP servers:', error);
}
}, []);
const fetchCodexMcpServers = useCallback(async () => {
try {
const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
if (configResponse.ok) {
const configData = await toResponseJson<McpReadResponse>(configResponse);
if (configData.success && configData.servers) {
setCodexMcpServers(configData.servers);
return;
}
}
const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
if (!cliResponse.ok) {
return;
}
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
if (!cliData.success || !cliData.servers) {
return;
}
setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));
} catch (error) {
console.error('Error fetching Codex MCP servers:', error);
}
}, []);
const fetchMcpServers = useCallback(async () => {
try {
const configResponse = await authenticatedFetch('/api/mcp/config/read');
if (configResponse.ok) {
const configData = await toResponseJson<McpReadResponse>(configResponse);
if (configData.success && configData.servers) {
setMcpServers(configData.servers);
return;
}
}
const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
if (cliResponse.ok) {
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
if (cliData.success && cliData.servers) {
setMcpServers(mapCliServersToMcpServers(cliData.servers));
return;
}
}
console.error('Failed to fetch MCP servers');
} catch (error) {
console.error('Error fetching MCP servers:', error);
}
}, []);
const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {
const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to delete server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to delete server via Claude CLI');
}
}, []);
const saveMcpServer = useCallback(
async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {
const newServerScope = serverData.scope || 'user';
const response = await authenticatedFetch('/api/mcp/cli/add', {
method: 'POST',
body: JSON.stringify({
name: serverData.name,
type: serverData.type,
scope: newServerScope,
projectPath: serverData.projectPath,
command: serverData.config.command,
args: serverData.config.args || [],
url: serverData.config.url,
headers: serverData.config.headers || {},
env: serverData.config.env || {},
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to save server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to save server via Claude CLI');
}
if (!editingServer?.id) {
return;
}
const previousServerScope = editingServer.scope || 'user';
const didServerIdentityChange =
editingServer.id !== serverData.name || previousServerScope !== newServerScope;
if (!didServerIdentityChange) {
return;
}
try {
await deleteMcpServer(editingServer.id, previousServerScope);
} catch (error) {
console.warn('Saved MCP server update but failed to remove the previous server entry.', {
previousServerId: editingServer.id,
previousServerScope,
error: getErrorMessage(error),
});
}
},
[deleteMcpServer],
);
const submitMcpForm = useCallback(
async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {
if (formData.importMode === 'json') {
const response = await authenticatedFetch('/api/mcp/cli/add-json', {
method: 'POST',
body: JSON.stringify({
name: formData.name,
jsonConfig: formData.jsonInput,
scope: formData.scope,
projectPath: formData.projectPath,
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to add server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to add server via JSON');
}
} else {
await saveMcpServer(formData, editingServer);
}
await fetchMcpServers();
setSaveStatus('success');
setShowMcpForm(false);
setEditingMcpServer(null);
},
[fetchMcpServers, saveMcpServer],
);
const handleMcpDelete = useCallback(
async (serverId: string, scope = 'user') => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteMcpServer(serverId, scope);
await fetchMcpServers();
setDeleteError(null);
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[deleteMcpServer, fetchMcpServers],
);
const deleteCodexMcpServer = useCallback(async (serverId: string) => {
const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to delete server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to delete Codex MCP server');
}
}, []);
const saveCodexMcpServer = useCallback(
async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {
const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
method: 'POST',
body: JSON.stringify({
name: serverData.name,
command: serverData.config.command,
args: serverData.config.args || [],
env: serverData.config.env || {},
}),
});
if (!response.ok) {
const error = await toResponseJson<JsonResult>(response);
throw new Error(error.error || 'Failed to save server');
}
const result = await toResponseJson<JsonResult>(response);
if (!result.success) {
throw new Error(result.error || 'Failed to save Codex MCP server');
}
if (!editingServer?.name || editingServer.name === serverData.name) {
return;
}
try {
await deleteCodexMcpServer(editingServer.name);
} catch (error) {
console.warn('Saved Codex MCP server update but failed to remove the previous server entry.', {
previousServerName: editingServer.name,
error: getErrorMessage(error),
});
}
},
[deleteCodexMcpServer],
);
const submitCodexMcpForm = useCallback(
async (formData: CodexMcpFormState, editingServer: McpServer | null) => {
await saveCodexMcpServer(formData, editingServer);
await fetchCodexMcpServers();
setSaveStatus('success');
setShowCodexMcpForm(false);
setEditingCodexMcpServer(null);
},
[fetchCodexMcpServers, saveCodexMcpServer],
);
const handleCodexMcpDelete = useCallback(
async (serverName: string) => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteCodexMcpServer(serverName);
await fetchCodexMcpServers();
setDeleteError(null);
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[deleteCodexMcpServer, fetchCodexMcpServers],
);
const loadSettings = useCallback(async () => {
try {
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
@@ -536,11 +197,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setNotificationPreferences(createDefaultNotificationPreferences());
}
await Promise.all([
fetchMcpServers(),
fetchCursorMcpServers(),
fetchCodexMcpServers(),
]);
} catch (error) {
console.error('Error loading settings:', error);
setClaudePermissions(createEmptyClaudePermissions());
@@ -549,7 +205,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setCodexPermissionMode('default');
setProjectSortOrder('name');
}
}, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
}, []);
const openLoginForProvider = useCallback((provider: AgentProvider) => {
setLoginProvider(provider);
@@ -628,26 +284,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
[],
);
const openMcpForm = useCallback((server?: McpServer) => {
setEditingMcpServer(server || null);
setShowMcpForm(true);
}, []);
const closeMcpForm = useCallback(() => {
setShowMcpForm(false);
setEditingMcpServer(null);
}, []);
const openCodexMcpForm = useCallback((server?: McpServer) => {
setEditingCodexMcpServer(server || null);
setShowCodexMcpForm(true);
}, []);
const closeCodexMcpForm = useCallback(() => {
setShowCodexMcpForm(false);
setEditingCodexMcpServer(null);
}, []);
useEffect(() => {
if (!isOpen) {
return;
@@ -727,7 +363,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
isDarkMode,
toggleDarkMode,
saveStatus,
deleteError,
projectSortOrder,
setProjectSortOrder,
codeEditorSettings,
@@ -740,21 +375,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
setNotificationPreferences,
codexPermissionMode,
setCodexPermissionMode,
mcpServers,
cursorMcpServers,
codexMcpServers,
showMcpForm,
editingMcpServer,
openMcpForm,
closeMcpForm,
submitMcpForm,
handleMcpDelete,
showCodexMcpForm,
editingCodexMcpServer,
openCodexMcpForm,
closeCodexMcpForm,
submitCodexMcpForm,
handleCodexMcpDelete,
providerAuthStatus,
geminiPermissionMode,
setGeminiPermissionMode,