mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-21 13:01:31 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user