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

@@ -1,6 +1,7 @@
import type { AgentCategoryContentSectionProps } from '../types';
import { McpServers } from '../../../../../mcp';
import AccountContent from './content/AccountContent';
import McpServersContent from './content/McpServersContent';
import PermissionsContent from './content/PermissionsContent';
export default function AgentCategoryContentSection({
@@ -13,18 +14,8 @@ export default function AgentCategoryContentSection({
onCursorPermissionsChange,
codexPermissionMode,
onCodexPermissionModeChange,
mcpServers,
cursorMcpServers,
codexMcpServers,
deleteError,
onOpenMcpForm,
onDeleteMcpServer,
onOpenCodexMcpForm,
onDeleteCodexMcpServer,
projects,
}: AgentCategoryContentSectionProps) {
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
const noopCursorMcpAction = () => {};
return (
<div className="flex-1 overflow-y-auto p-3 md:p-4">
{selectedCategory === 'account' && (
@@ -79,35 +70,10 @@ export default function AgentCategoryContentSection({
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'claude' && (
<McpServersContent
agent="claude"
servers={mcpServers}
onAdd={() => onOpenMcpForm()}
onEdit={(server) => onOpenMcpForm(server)}
onDelete={onDeleteMcpServer}
deleteError={deleteError}
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
<McpServersContent
agent="cursor"
servers={cursorMcpServers}
onAdd={noopCursorMcpAction}
onEdit={noopCursorMcpAction}
onDelete={noopCursorMcpAction}
/>
)}
{selectedCategory === 'mcp' && selectedAgent === 'codex' && (
<McpServersContent
agent="codex"
servers={codexMcpServers}
onAdd={() => onOpenCodexMcpForm()}
onEdit={(server) => onOpenCodexMcpForm(server)}
onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}
deleteError={deleteError}
{selectedCategory === 'mcp' && (
<McpServers
selectedProvider={selectedAgent}
currentProjects={projects}
/>
)}
</div>

View File

@@ -1,352 +0,0 @@
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge, Button } from '../../../../../../../shared/view/ui';
import { IS_PLATFORM } from '../../../../../../../constants/config';
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
import type { McpServer } from '../../../../../types/types';
const getTransportIcon = (type: string | undefined) => {
if (type === 'stdio') {
return <Terminal className="h-4 w-4" />;
}
if (type === 'sse') {
return <Zap className="h-4 w-4" />;
}
if (type === 'http') {
return <Globe className="h-4 w-4" />;
}
return <Server className="h-4 w-4" />;
};
const maskSecret = (value: unknown): string => {
const normalizedValue = String(value ?? '');
if (normalizedValue.length <= 4) {
return '****';
}
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
};
type ClaudeMcpServersProps = {
agent: 'claude';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string, scope?: string) => void;
deleteError?: string | null;
};
function ClaudeMcpServers({
servers,
onAdd,
onEdit,
onDelete,
deleteError,
}: Omit<ClaudeMcpServersProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.claude')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</div>
)}
<div className="space-y-2">
{servers.map((server) => {
const serverId = server.id || server.name;
return (
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
{getTransportIcon(server.type)}
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{server.type || 'stdio'}
</Badge>
<Badge variant="outline" className="text-xs">
{server.scope === 'local'
? t('mcpServers.scope.local')
: server.scope === 'user'
? t('mcpServers.scope.user')
: server.scope}
</Badge>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{server.type === 'stdio' && server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
<div>
{t('mcpServers.config.url')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.url}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(serverId, server.scope)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
{!IS_PLATFORM && (
<PremiumFeatureCard
icon={<Users className="h-5 w-5" />}
title="Team MCP Configs"
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
/>
)}
</div>
);
}
type CursorMcpServersProps = {
agent: 'cursor';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string) => void;
};
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpServersProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-purple-500" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.cursor')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
<div className="space-y-2">
{servers.map((server) => {
const serverId = server.id || server.name;
return (
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<Terminal className="h-4 w-4" />
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">stdio</Badge>
</div>
<div className="text-sm text-muted-foreground">
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(serverId)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
})}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
</div>
);
}
type CodexMcpServersProps = {
agent: 'codex';
servers: McpServer[];
onAdd: () => void;
onEdit: (server: McpServer) => void;
onDelete: (serverId: string) => void;
deleteError?: string | null;
};
function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit<CodexMcpServersProps, 'agent'>) {
const { t } = useTranslation('settings');
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-muted-foreground" />
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
</div>
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
<div className="flex items-center justify-between">
<Button onClick={onAdd} className="bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600" size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
</div>
{deleteError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
{deleteError}
</div>
)}
<div className="space-y-2">
{servers.map((server) => (
<div key={server.name} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<Terminal className="h-4 w-4" />
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">stdio</Badge>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{server.config?.command && (
<div>
{t('mcpServers.config.command')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
</div>
)}
{server.config?.args && server.config.args.length > 0 && (
<div>
{t('mcpServers.config.args')}:{' '}
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
</div>
)}
{server.config?.env && Object.keys(server.config.env).length > 0 && (
<div>
{t('mcpServers.config.environment')}:{' '}
<code className="rounded bg-muted px-1 text-xs">
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</code>
</div>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => onEdit(server)}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
title={t('mcpServers.actions.edit')}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
onClick={() => onDelete(server.name)}
variant="ghost"
size="sm"
className="text-red-600 hover:text-red-700"
title={t('mcpServers.actions.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
{servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
<div className="rounded-lg border border-border bg-muted/50 p-4">
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
</div>
</div>
);
}
type McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;
export default function McpServersContent(props: McpServersContentProps) {
if (props.agent === 'claude') {
return <ClaudeMcpServers {...props} />;
}
if (props.agent === 'cursor') {
return <CursorMcpServers {...props} />;
}
return <CodexMcpServers {...props} />;
}