mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-21 04:51:31 +00:00
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`
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
import { FolderOpen, Globe, X } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { Button, Input } from '../../../../shared/view/ui';
|
|
import {
|
|
MCP_PROVIDER_NAMES,
|
|
MCP_SUPPORTED_SCOPES,
|
|
MCP_SUPPORTED_TRANSPORTS,
|
|
MCP_SUPPORTS_WORKING_DIRECTORY,
|
|
} from '../../constants';
|
|
import { useMcpServerForm } from '../../hooks/useMcpServerForm';
|
|
import { formatKeyValueLines, parseKeyValueLines, parseListLines } from '../../utils/mcpFormatting';
|
|
import type { McpFormState, McpProject, McpProvider, McpScope, ProviderMcpServer } from '../../types';
|
|
|
|
type McpServerFormModalProps = {
|
|
provider: McpProvider;
|
|
isOpen: boolean;
|
|
editingServer: ProviderMcpServer | null;
|
|
currentProjects: McpProject[];
|
|
onClose: () => void;
|
|
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
|
|
};
|
|
|
|
const getScopeLabel = (scope: McpScope): string => {
|
|
if (scope === 'user') {
|
|
return 'User (Global)';
|
|
}
|
|
|
|
if (scope === 'local') {
|
|
return 'Claude Local';
|
|
}
|
|
|
|
return 'Project';
|
|
};
|
|
|
|
const getScopeDescription = (scope: McpScope): string => {
|
|
if (scope === 'user') {
|
|
return 'Available across all projects on your machine';
|
|
}
|
|
|
|
if (scope === 'local') {
|
|
return 'Stored in Claude user settings for the selected project';
|
|
}
|
|
|
|
return 'Stored in the selected project workspace';
|
|
};
|
|
|
|
export default function McpServerFormModal({
|
|
provider,
|
|
isOpen,
|
|
editingServer,
|
|
currentProjects,
|
|
onClose,
|
|
onSubmit,
|
|
}: McpServerFormModalProps) {
|
|
const { t } = useTranslation('settings');
|
|
const {
|
|
formData,
|
|
projectOptions,
|
|
isEditing,
|
|
isSubmitting,
|
|
jsonValidationError,
|
|
canSubmit,
|
|
updateForm,
|
|
updateScope,
|
|
updateTransport,
|
|
updateJsonInput,
|
|
handleSubmit,
|
|
} = useMcpServerForm({
|
|
provider,
|
|
isOpen,
|
|
editingServer,
|
|
currentProjects,
|
|
onSubmit,
|
|
});
|
|
|
|
if (!isOpen) {
|
|
return null;
|
|
}
|
|
|
|
const providerName = MCP_PROVIDER_NAMES[provider];
|
|
const showProjectSelector = formData.scope !== 'user';
|
|
const supportsHttpHeaders = formData.transport === 'http' || formData.transport === 'sse';
|
|
const supportsWorkingDirectory = MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
|
|
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
|
|
<div className="flex items-center justify-between border-b border-border p-4">
|
|
<h3 className="text-lg font-medium text-foreground">
|
|
{isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
|
|
</h3>
|
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4 p-4">
|
|
{!isEditing && (
|
|
<div className="mb-4 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => updateForm('importMode', 'form')}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
formData.importMode === 'form'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{t('mcpForm.importMode.form')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => updateForm('importMode', 'json')}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
formData.importMode === 'json'
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{t('mcpForm.importMode.json')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isEditing && (
|
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50">
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.scope.label')}
|
|
</label>
|
|
<div className="flex items-center gap-2">
|
|
{formData.scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
|
|
<span className="text-sm">{getScopeLabel(formData.scope)}</span>
|
|
{formData.workspacePath && (
|
|
<span className="truncate text-xs text-muted-foreground">- {formData.workspacePath}</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{!isEditing && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.scope.label')} *
|
|
</label>
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{MCP_SUPPORTED_SCOPES[provider].map((scope) => (
|
|
<button
|
|
key={scope}
|
|
type="button"
|
|
onClick={() => updateScope(scope)}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
formData.scope === scope
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-center gap-2">
|
|
{scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
|
|
<span>{getScopeLabel(scope)}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted-foreground">{getScopeDescription(formData.scope)}</p>
|
|
</div>
|
|
|
|
{showProjectSelector && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.selectProject')} *
|
|
</label>
|
|
<select
|
|
value={formData.workspacePath}
|
|
onChange={(event) => updateForm('workspacePath', event.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
required
|
|
>
|
|
<option value="">{t('mcpForm.fields.selectProject')}</option>
|
|
{projectOptions.map((project) => (
|
|
<option key={project.value} value={project.value}>
|
|
{project.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{formData.workspacePath && (
|
|
<p className="mt-1 truncate text-xs text-muted-foreground">
|
|
{t('mcpForm.projectPath', { path: formData.workspacePath })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.serverName')} *
|
|
</label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(event) => updateForm('name', event.target.value)}
|
|
placeholder={t('mcpForm.placeholders.serverName')}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{formData.importMode === 'form' && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.transportType')} *
|
|
</label>
|
|
<select
|
|
value={formData.transport}
|
|
onChange={(event) => updateTransport(event.target.value as McpFormState['transport'])}
|
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
>
|
|
{MCP_SUPPORTED_TRANSPORTS[provider].map((transport) => (
|
|
<option key={transport} value={transport}>
|
|
{transport === 'sse' ? 'SSE' : transport.toUpperCase()}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{formData.importMode === 'json' && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.jsonConfig')} *
|
|
</label>
|
|
<textarea
|
|
value={formData.jsonInput}
|
|
onChange={(event) => updateJsonInput(event.target.value)}
|
|
className={`w-full border px-3 py-2 ${
|
|
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
|
} rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}
|
|
rows={8}
|
|
placeholder={'{\n "type": "stdio",\n "command": "npx",\n "args": ["@upstash/context7-mcp"]\n}'}
|
|
required
|
|
/>
|
|
{jsonValidationError && (
|
|
<p className="mt-1 text-xs text-red-500">{jsonValidationError}</p>
|
|
)}
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
{t('mcpForm.validation.jsonHelp')}
|
|
<br />
|
|
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
|
|
<br />
|
|
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{formData.importMode === 'form' && formData.transport === 'stdio' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.command')} *
|
|
</label>
|
|
<Input
|
|
value={formData.command}
|
|
onChange={(event) => updateForm('command', event.target.value)}
|
|
placeholder="npx @my-org/mcp-server"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.arguments')}
|
|
</label>
|
|
<textarea
|
|
value={formData.args.join('\n')}
|
|
onChange={(event) => updateForm('args', parseListLines(event.target.value))}
|
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
rows={3}
|
|
placeholder="--port 3000"
|
|
/>
|
|
</div>
|
|
|
|
{supportsWorkingDirectory && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
Working Directory
|
|
</label>
|
|
<Input
|
|
value={formData.cwd}
|
|
onChange={(event) => updateForm('cwd', event.target.value)}
|
|
placeholder="."
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{formData.importMode === 'form' && formData.transport !== 'stdio' && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.url')} *
|
|
</label>
|
|
<Input
|
|
value={formData.url}
|
|
onChange={(event) => updateForm('url', event.target.value)}
|
|
placeholder="https://api.example.com/mcp"
|
|
type="url"
|
|
required
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{formData.importMode === 'form' && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.envVars')}
|
|
</label>
|
|
<textarea
|
|
value={formatKeyValueLines(formData.env)}
|
|
onChange={(event) => updateForm('env', parseKeyValueLines(event.target.value))}
|
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
rows={3}
|
|
placeholder="API_KEY=your-key DEBUG=true"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{formData.importMode === 'form' && supportsHttpHeaders && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
{t('mcpForm.fields.headers')}
|
|
</label>
|
|
<textarea
|
|
value={formatKeyValueLines(formData.headers)}
|
|
onChange={(event) => updateForm('headers', parseKeyValueLines(event.target.value))}
|
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
rows={3}
|
|
placeholder="Authorization=Bearer token X-API-Key=your-key"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{provider === 'codex' && formData.importMode === 'form' && formData.transport === 'stdio' && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
Environment Variable Names
|
|
</label>
|
|
<textarea
|
|
value={formData.envVars.join('\n')}
|
|
onChange={(event) => updateForm('envVars', parseListLines(event.target.value))}
|
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
rows={3}
|
|
placeholder="GITHUB_TOKEN API_KEY"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{provider === 'codex' && formData.importMode === 'form' && formData.transport === 'http' && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
Bearer Token Environment Variable
|
|
</label>
|
|
<Input
|
|
value={formData.bearerTokenEnvVar}
|
|
onChange={(event) => updateForm('bearerTokenEnvVar', event.target.value)}
|
|
placeholder="MCP_TOKEN"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<Button type="button" variant="outline" onClick={onClose}>
|
|
{t('mcpForm.actions.cancel')}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting || !canSubmit}
|
|
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
|
>
|
|
{isSubmitting
|
|
? t('mcpForm.actions.saving')
|
|
: isEditing
|
|
? t('mcpForm.actions.updateServer')
|
|
: `${t('mcpForm.actions.addServer')} to ${providerName}`}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|