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

@@ -0,0 +1,54 @@
import type { McpFormState, McpProvider, McpScope, McpTransport } from './types';
export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
claude: 'Claude',
cursor: 'Cursor',
codex: 'Codex',
gemini: 'Gemini',
};
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
claude: ['user', 'project', 'local'],
cursor: ['user', 'project'],
codex: ['user', 'project'],
gemini: ['user', 'project'],
};
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
claude: ['stdio', 'http', 'sse'],
cursor: ['stdio', 'http'],
codex: ['stdio', 'http'],
gemini: ['stdio', 'http', 'sse'],
};
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
claude: 'bg-purple-600 text-white hover:bg-purple-700',
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
};
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
claude: false,
cursor: false,
codex: true,
gemini: true,
};
export const DEFAULT_MCP_FORM: McpFormState = {
name: '',
scope: 'user',
workspacePath: '',
transport: 'stdio',
command: '',
args: [],
env: {},
cwd: '',
url: '',
headers: {},
envVars: [],
bearerTokenEnvVar: '',
envHttpHeaders: {},
importMode: 'form',
jsonInput: '',
};

View File

@@ -0,0 +1,185 @@
import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DEFAULT_MCP_FORM, MCP_SUPPORTED_SCOPES, MCP_SUPPORTED_TRANSPORTS } from '../constants';
import type { McpFormState, McpProject, McpProvider, McpScope, McpTransport, ProviderMcpServer } from '../types';
import { getErrorMessage, getProjectPath, isMcpTransport } from '../utils/mcpFormatting';
type UseMcpServerFormArgs = {
provider: McpProvider;
isOpen: boolean;
editingServer: ProviderMcpServer | null;
currentProjects: McpProject[];
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
};
const cloneDefaultForm = (provider: McpProvider): McpFormState => ({
...DEFAULT_MCP_FORM,
scope: MCP_SUPPORTED_SCOPES[provider][0],
transport: MCP_SUPPORTED_TRANSPORTS[provider][0],
args: [],
env: {},
headers: {},
envVars: [],
envHttpHeaders: {},
});
const createFormStateFromServer = (
provider: McpProvider,
server: ProviderMcpServer,
): McpFormState => ({
...cloneDefaultForm(provider),
name: server.name,
scope: server.scope,
workspacePath: server.workspacePath || '',
transport: server.transport,
command: server.command || '',
args: server.args || [],
env: server.env || {},
cwd: server.cwd || '',
url: server.url || '',
headers: server.headers || {},
envVars: server.envVars || [],
bearerTokenEnvVar: server.bearerTokenEnvVar || '',
envHttpHeaders: server.envHttpHeaders || {},
});
const normalizeScope = (provider: McpProvider, value: McpScope): McpScope => (
MCP_SUPPORTED_SCOPES[provider].includes(value) ? value : MCP_SUPPORTED_SCOPES[provider][0]
);
const normalizeTransport = (provider: McpProvider, value: McpTransport): McpTransport => (
MCP_SUPPORTED_TRANSPORTS[provider].includes(value) ? value : MCP_SUPPORTED_TRANSPORTS[provider][0]
);
export function useMcpServerForm({
provider,
isOpen,
editingServer,
currentProjects,
onSubmit,
}: UseMcpServerFormArgs) {
const { t } = useTranslation('settings');
const [formData, setFormData] = useState<McpFormState>(() => cloneDefaultForm(provider));
const [jsonValidationError, setJsonValidationError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const isEditing = Boolean(editingServer);
useEffect(() => {
if (!isOpen) {
return;
}
setJsonValidationError('');
if (editingServer) {
setFormData(createFormStateFromServer(provider, editingServer));
return;
}
setFormData(cloneDefaultForm(provider));
}, [editingServer, isOpen, provider]);
const projectOptions = useMemo(() => (
currentProjects
.map((project) => ({
value: getProjectPath(project),
label: project.displayName || project.name,
}))
.filter((project) => project.value)
), [currentProjects]);
const updateForm = <K extends keyof McpFormState>(key: K, value: McpFormState[K]) => {
setFormData((prev) => ({ ...prev, [key]: value }));
};
const updateScope = (scope: McpScope) => {
setFormData((prev) => ({
...prev,
scope: normalizeScope(provider, scope),
workspacePath: scope === 'user' ? '' : prev.workspacePath,
}));
};
const updateTransport = (transport: McpTransport) => {
setFormData((prev) => ({ ...prev, transport: normalizeTransport(provider, transport) }));
};
const validateJsonInput = (value: string) => {
if (!value.trim()) {
setJsonValidationError('');
return;
}
try {
const parsed = JSON.parse(value) as { type?: unknown; transport?: unknown; command?: unknown; url?: unknown };
const transportInput = parsed.transport || parsed.type;
if (!isMcpTransport(transportInput)) {
setJsonValidationError(t('mcpForm.validation.missingType'));
} else if (!MCP_SUPPORTED_TRANSPORTS[provider].includes(transportInput)) {
setJsonValidationError(`${provider} does not support ${transportInput} MCP servers`);
} else if (transportInput === 'stdio' && !parsed.command) {
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
} else if ((transportInput === 'http' || transportInput === 'sse') && !parsed.url) {
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: transportInput }));
} else {
setJsonValidationError('');
}
} catch {
setJsonValidationError(t('mcpForm.validation.invalidJson'));
}
};
const updateJsonInput = (value: string) => {
setFormData((prev) => ({ ...prev, jsonInput: value }));
validateJsonInput(value);
};
const canSubmit = useMemo(() => {
if (!formData.name.trim()) {
return false;
}
if (formData.scope !== 'user' && !formData.workspacePath.trim()) {
return false;
}
if (formData.importMode === 'json') {
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
}
if (formData.transport === 'stdio') {
return Boolean(formData.command.trim());
}
return Boolean(formData.url.trim());
}, [formData, jsonValidationError]);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formData, editingServer);
} catch (error) {
alert(`Error: ${getErrorMessage(error)}`);
} finally {
setIsSubmitting(false);
}
};
return {
formData,
setFormData,
projectOptions,
isEditing,
isSubmitting,
jsonValidationError,
canSubmit,
updateForm,
updateScope,
updateTransport,
updateJsonInput,
handleSubmit,
};
}

View File

@@ -0,0 +1,458 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { MCP_SUPPORTED_SCOPES } from '../constants';
import type {
ApiResponse,
McpFormState,
McpProject,
McpProvider,
McpScope,
McpTransport,
ProviderMcpServer,
UpsertProviderMcpServerPayload,
} from '../types';
import {
createMcpPayloadFromForm,
getErrorMessage,
getProjectPath,
isMcpScope,
isMcpTransport,
} from '../utils/mcpFormatting';
type ProviderMcpServerResponse = {
provider: McpProvider;
scope: McpScope;
servers: Array<Partial<ProviderMcpServer>>;
};
type ProjectTarget = {
name: string;
displayName: string;
path: string;
};
type McpServersCacheEntry = {
servers: ProviderMcpServer[];
updatedAt: number;
};
type ScopedProjectRequest = {
scope: McpScope;
project: ProjectTarget;
};
const MCP_CACHE_TTL_MS = 30_000;
const mcpServersCache = new Map<string, McpServersCacheEntry>();
// Settings users often switch between provider tabs repeatedly. A short module
// cache prevents those tab switches from refetching every project config file.
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
const getApiErrorMessage = (payload: unknown, fallback: string): string => {
if (!payload || typeof payload !== 'object') {
return fallback;
}
const record = payload as Record<string, unknown>;
const error = record.error;
if (error && typeof error === 'object') {
const message = (error as Record<string, unknown>).message;
if (typeof message === 'string' && message.trim()) {
return message;
}
}
if (typeof error === 'string' && error.trim()) {
return error;
}
const details = record.details;
if (typeof details === 'string' && details.trim()) {
return details;
}
return fallback;
};
const normalizeTransport = (value: unknown, fallback: McpTransport = 'stdio'): McpTransport => (
isMcpTransport(value) ? value : fallback
);
const normalizeScope = (value: unknown, fallback: McpScope): McpScope => (
isMcpScope(value) ? value : fallback
);
const normalizeServer = (
provider: McpProvider,
scope: McpScope,
server: Partial<ProviderMcpServer>,
project?: ProjectTarget,
): ProviderMcpServer => {
const transport = normalizeTransport(server.transport, server.url ? 'http' : 'stdio');
return {
provider,
name: String(server.name ?? ''),
scope: normalizeScope(server.scope, scope),
transport,
command: server.command,
args: server.args ?? [],
env: server.env ?? {},
cwd: server.cwd,
url: server.url,
headers: server.headers ?? {},
envVars: server.envVars ?? [],
bearerTokenEnvVar: server.bearerTokenEnvVar,
envHttpHeaders: server.envHttpHeaders ?? {},
workspacePath: project?.path || server.workspacePath,
projectName: project?.name || server.projectName,
projectDisplayName: project?.displayName || server.projectDisplayName,
};
};
const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => {
const seen = new Set<string>();
return projects.reduce<ProjectTarget[]>((acc, project) => {
const projectPath = getProjectPath(project);
if (!projectPath || seen.has(projectPath)) {
return acc;
}
seen.add(projectPath);
acc.push({
name: project.name,
displayName: project.displayName || project.name,
path: projectPath,
});
return acc;
}, []);
};
const fetchProviderScopeServers = async (
provider: McpProvider,
scope: McpScope,
project?: ProjectTarget,
): Promise<ProviderMcpServer[]> => {
const params = new URLSearchParams({ scope });
if (project?.path) {
params.set('workspacePath', project.path);
}
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers?${params.toString()}`);
const data = await toResponseJson<ApiResponse<ProviderMcpServerResponse>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, `Failed to load ${provider} MCP servers`));
}
return (data.data.servers || []).map((server) => normalizeServer(provider, scope, server, project));
};
const deleteProviderServer = async (
provider: McpProvider,
server: ProviderMcpServer,
): Promise<void> => {
const params = new URLSearchParams({ scope: server.scope });
if (server.workspacePath) {
params.set('workspacePath', server.workspacePath);
}
const response = await authenticatedFetch(
`/api/providers/${provider}/mcp/servers/${encodeURIComponent(server.name)}?${params.toString()}`,
{ method: 'DELETE' },
);
const data = await toResponseJson<ApiResponse<{ removed: boolean }>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to delete MCP server'));
}
};
const saveProviderServer = async (
provider: McpProvider,
payload: UpsertProviderMcpServerPayload,
): Promise<void> => {
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers`, {
method: 'POST',
body: JSON.stringify(payload),
});
const data = await toResponseJson<ApiResponse<{ server: ProviderMcpServer }>>(response);
if (!response.ok || !data.success) {
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server'));
}
};
const didServerIdentityChange = (
editingServer: ProviderMcpServer,
payload: UpsertProviderMcpServerPayload,
): boolean => (
editingServer.name !== payload.name
|| editingServer.scope !== payload.scope
|| (editingServer.workspacePath || '') !== (payload.workspacePath || '')
);
const getServerIdentity = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
);
const getCacheKey = (provider: McpProvider, projects: ProjectTarget[]): string => {
const projectKey = projects.map((project) => project.path).sort().join('|');
return `${provider}:${projectKey}`;
};
const sortServers = (servers: ProviderMcpServer[]): ProviderMcpServer[] => {
const scopeOrder: Record<McpScope, number> = {
user: 0,
project: 1,
local: 2,
};
return [...servers].sort((left, right) => {
const scopeDelta = scopeOrder[left.scope] - scopeOrder[right.scope];
if (scopeDelta !== 0) {
return scopeDelta;
}
const projectDelta = (left.projectDisplayName || '').localeCompare(right.projectDisplayName || '');
if (projectDelta !== 0) {
return projectDelta;
}
return left.name.localeCompare(right.name);
});
};
const mergeServers = (
existingServers: ProviderMcpServer[],
incomingServers: ProviderMcpServer[],
): ProviderMcpServer[] => {
const serversById = new Map<string, ProviderMcpServer>();
existingServers.forEach((server) => {
serversById.set(getServerIdentity(server), server);
});
incomingServers.forEach((server) => {
serversById.set(getServerIdentity(server), server);
});
return sortServers([...serversById.values()]);
};
const replaceScopedServers = (
existingServers: ProviderMcpServer[],
incomingServers: ProviderMcpServer[],
scope: McpScope,
workspacePath?: string,
): ProviderMcpServer[] => {
const remainingServers = existingServers.filter((server) => (
server.scope !== scope || (server.workspacePath || '') !== (workspacePath || '')
));
return mergeServers(remainingServers, incomingServers);
};
type UseMcpServersArgs = {
selectedProvider: McpProvider;
currentProjects: McpProject[];
};
export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServersArgs) {
const [servers, setServers] = useState<ProviderMcpServer[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingServer, setEditingServer] = useState<ProviderMcpServer | null>(null);
const activeLoadIdRef = useRef(0);
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
const cacheKey = useMemo(() => getCacheKey(selectedProvider, projectTargets), [projectTargets, selectedProvider]);
const refreshServers = useCallback(async (options: { force?: boolean } = {}) => {
const loadId = activeLoadIdRef.current + 1;
activeLoadIdRef.current = loadId;
const cachedEntry = mcpServersCache.get(cacheKey);
const canUseCache = !options.force && cachedEntry && Date.now() - cachedEntry.updatedAt < MCP_CACHE_TTL_MS;
if (canUseCache) {
setServers(cachedEntry.servers);
setIsLoading(false);
setIsLoadingProjectScopes(false);
setLoadError(null);
return;
}
if (cachedEntry && !options.force) {
setServers(cachedEntry.servers);
} else {
setServers([]);
}
setIsLoading(!cachedEntry);
setIsLoadingProjectScopes(false);
setLoadError(null);
const supportedScopes = MCP_SUPPORTED_SCOPES[selectedProvider];
let nextServers: ProviderMcpServer[] = cachedEntry && !options.force ? cachedEntry.servers : [];
let firstError: string | null = null;
// Load the global/user scope first so the visible list can paint quickly.
// Project and local scopes can involve many project config files, so they
// are appended below as background requests instead of blocking this render.
if (supportedScopes.includes('user')) {
try {
const userServers = await fetchProviderScopeServers(selectedProvider, 'user');
if (activeLoadIdRef.current !== loadId) {
return;
}
nextServers = replaceScopedServers(nextServers, userServers, 'user');
setServers(sortServers(nextServers));
} catch (error) {
firstError = getErrorMessage(error);
}
}
if (activeLoadIdRef.current !== loadId) {
return;
}
setIsLoading(false);
const projectScopeRequests: ScopedProjectRequest[] = [];
projectTargets.forEach((project) => {
if (supportedScopes.includes('project')) {
projectScopeRequests.push({ scope: 'project', project });
}
if (supportedScopes.includes('local')) {
projectScopeRequests.push({ scope: 'local', project });
}
});
if (projectScopeRequests.length === 0) {
const finalServers = sortServers(nextServers);
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
setLoadError(firstError);
return;
}
setIsLoadingProjectScopes(true);
// Update the UI as each project scope resolves. This avoids waiting for the
// slowest project before showing servers from faster config files.
await Promise.all(projectScopeRequests.map(async ({ scope, project }) => {
try {
const scopedServers = await fetchProviderScopeServers(selectedProvider, scope, project);
if (activeLoadIdRef.current !== loadId) {
return;
}
nextServers = replaceScopedServers(nextServers, scopedServers, scope, project.path);
setServers(nextServers);
} catch (error) {
firstError = firstError || getErrorMessage(error);
}
}));
if (activeLoadIdRef.current !== loadId) {
return;
}
const finalServers = sortServers(nextServers);
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
setServers(finalServers);
setLoadError(firstError);
setIsLoadingProjectScopes(false);
}, [cacheKey, projectTargets, selectedProvider]);
const openForm = useCallback((server?: ProviderMcpServer) => {
setEditingServer(server || null);
setIsFormOpen(true);
}, []);
const closeForm = useCallback(() => {
setIsFormOpen(false);
setEditingServer(null);
}, []);
const submitForm = useCallback(
async (formData: McpFormState, serverBeingEdited: ProviderMcpServer | null) => {
const payload = createMcpPayloadFromForm(selectedProvider, formData);
if (payload.scope !== 'user' && !payload.workspacePath) {
throw new Error('Select a project for project-scoped MCP servers');
}
await saveProviderServer(selectedProvider, payload);
if (serverBeingEdited && didServerIdentityChange(serverBeingEdited, payload)) {
await deleteProviderServer(selectedProvider, serverBeingEdited);
}
mcpServersCache.delete(cacheKey);
await refreshServers({ force: true });
setSaveStatus('success');
closeForm();
},
[cacheKey, closeForm, refreshServers, selectedProvider],
);
const deleteServer = useCallback(
async (server: ProviderMcpServer) => {
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
return;
}
setDeleteError(null);
try {
await deleteProviderServer(selectedProvider, server);
mcpServersCache.delete(cacheKey);
await refreshServers({ force: true });
setSaveStatus('success');
} catch (error) {
setDeleteError(getErrorMessage(error));
setSaveStatus('error');
}
},
[cacheKey, refreshServers, selectedProvider],
);
useEffect(() => {
void refreshServers();
}, [refreshServers]);
useEffect(() => {
setIsFormOpen(false);
setEditingServer(null);
setDeleteError(null);
setSaveStatus(null);
}, [selectedProvider]);
useEffect(() => {
if (saveStatus === null) {
return;
}
const timer = window.setTimeout(() => setSaveStatus(null), 2000);
return () => window.clearTimeout(timer);
}, [saveStatus]);
return {
servers,
isLoading,
isLoadingProjectScopes,
loadError,
deleteError,
saveStatus,
isFormOpen,
editingServer,
openForm,
closeForm,
submitForm,
deleteServer,
refreshServers,
};
}

View File

@@ -0,0 +1 @@
export { default as McpServers } from './view/McpServers';

View File

@@ -0,0 +1,83 @@
import type { LLMProvider } from '../../types/app';
export type McpProvider = LLMProvider;
export type McpScope = 'user' | 'local' | 'project';
export type McpTransport = 'stdio' | 'http' | 'sse';
export type McpImportMode = 'form' | 'json';
export type KeyValueMap = Record<string, string>;
export type McpProject = {
name: string;
displayName?: string;
fullPath?: string;
path?: string;
};
export type ProviderMcpServer = {
provider: McpProvider;
name: string;
scope: McpScope;
transport: McpTransport;
command?: string;
args?: string[];
env?: KeyValueMap;
cwd?: string;
url?: string;
headers?: KeyValueMap;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: KeyValueMap;
workspacePath?: string;
projectName?: string;
projectDisplayName?: string;
};
export type McpFormState = {
name: string;
scope: McpScope;
workspacePath: string;
transport: McpTransport;
command: string;
args: string[];
env: KeyValueMap;
cwd: string;
url: string;
headers: KeyValueMap;
envVars: string[];
bearerTokenEnvVar: string;
envHttpHeaders: KeyValueMap;
importMode: McpImportMode;
jsonInput: string;
};
export type UpsertProviderMcpServerPayload = {
name: string;
scope: McpScope;
transport: McpTransport;
workspacePath?: string;
command?: string;
args?: string[];
env?: KeyValueMap;
cwd?: string;
url?: string;
headers?: KeyValueMap;
envVars?: string[];
bearerTokenEnvVar?: string;
envHttpHeaders?: KeyValueMap;
};
export type ApiSuccessResponse<T> = {
success: true;
data: T;
};
export type ApiErrorResponse = {
success: false;
error?: {
code?: string;
message?: string;
details?: unknown;
};
};
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

View File

@@ -0,0 +1,149 @@
import { MCP_SUPPORTED_TRANSPORTS, MCP_SUPPORTS_WORKING_DIRECTORY } from '../constants';
import type {
KeyValueMap,
McpFormState,
McpProvider,
McpScope,
McpTransport,
UpsertProviderMcpServerPayload,
} from '../types';
const isRecord = (value: unknown): value is Record<string, unknown> => (
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
);
const readString = (value: unknown): string | undefined => (
typeof value === 'string' && value.trim() ? value.trim() : undefined
);
const readStringArray = (value: unknown): string[] | undefined => (
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : undefined
);
const readStringRecord = (value: unknown): KeyValueMap | undefined => {
if (!isRecord(value)) {
return undefined;
}
const normalized: KeyValueMap = {};
Object.entries(value).forEach(([key, entry]) => {
if (typeof entry === 'string') {
normalized[key] = entry;
}
});
return Object.keys(normalized).length > 0 ? normalized : undefined;
};
export const formatKeyValueLines = (value: KeyValueMap): string => (
Object.entries(value).map(([key, entry]) => `${key}=${entry}`).join('\n')
);
export const parseKeyValueLines = (value: string): KeyValueMap => {
const normalized: KeyValueMap = {};
value.split('\n').forEach((line) => {
const [key, ...valueParts] = line.split('=');
if (key?.trim()) {
normalized[key.trim()] = valueParts.join('=').trim();
}
});
return normalized;
};
export const parseListLines = (value: string): string[] => (
value.split('\n').map((entry) => entry.trim()).filter(Boolean)
);
export const maskSecret = (value: unknown): string => {
const normalizedValue = String(value ?? '');
if (normalizedValue.length <= 4) {
return '****';
}
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
};
export const isMcpScope = (value: unknown): value is McpScope => (
value === 'user' || value === 'local' || value === 'project'
);
export const isMcpTransport = (value: unknown): value is McpTransport => (
value === 'stdio' || value === 'http' || value === 'sse'
);
export const getProjectPath = (project: { fullPath?: string; path?: string }): string => (
project.fullPath || project.path || ''
);
export const getErrorMessage = (error: unknown): string => (
error instanceof Error ? error.message : 'Unknown error'
);
export const parseJsonMcpPayload = (
provider: McpProvider,
formData: McpFormState,
): UpsertProviderMcpServerPayload => {
const parsed = JSON.parse(formData.jsonInput) as unknown;
if (!isRecord(parsed)) {
throw new Error('JSON configuration must be an object');
}
const transportInput = readString(parsed.transport) ?? readString(parsed.type);
const transport = isMcpTransport(transportInput) ? transportInput : undefined;
if (!transport) {
throw new Error('Missing required field: type');
}
if (!MCP_SUPPORTED_TRANSPORTS[provider].includes(transport)) {
throw new Error(`${provider} does not support ${transport} MCP servers`);
}
if (transport === 'stdio' && !readString(parsed.command)) {
throw new Error('stdio type requires a command field');
}
if ((transport === 'http' || transport === 'sse') && !readString(parsed.url)) {
throw new Error(`${transport} type requires a url field`);
}
return {
name: formData.name.trim(),
scope: formData.scope,
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
transport,
command: readString(parsed.command),
args: readStringArray(parsed.args) ?? [],
env: readStringRecord(parsed.env) ?? {},
cwd: MCP_SUPPORTS_WORKING_DIRECTORY[provider] ? readString(parsed.cwd) : undefined,
url: readString(parsed.url),
headers: readStringRecord(parsed.headers ?? parsed.http_headers) ?? {},
envVars: readStringArray(parsed.envVars ?? parsed.env_vars) ?? [],
bearerTokenEnvVar: readString(parsed.bearerTokenEnvVar ?? parsed.bearer_token_env_var),
envHttpHeaders: readStringRecord(parsed.envHttpHeaders ?? parsed.env_http_headers) ?? {},
};
};
export const createMcpPayloadFromForm = (
provider: McpProvider,
formData: McpFormState,
): UpsertProviderMcpServerPayload => {
if (formData.importMode === 'json') {
return parseJsonMcpPayload(provider, formData);
}
return {
name: formData.name.trim(),
scope: formData.scope,
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
transport: formData.transport,
command: formData.transport === 'stdio' ? formData.command.trim() : undefined,
args: formData.transport === 'stdio' ? formData.args : undefined,
env: formData.env,
cwd: MCP_SUPPORTS_WORKING_DIRECTORY[provider] ? formData.cwd.trim() || undefined : undefined,
url: formData.transport !== 'stdio' ? formData.url.trim() : undefined,
headers: formData.transport !== 'stdio' ? formData.headers : undefined,
envVars: provider === 'codex' ? formData.envVars : undefined,
bearerTokenEnvVar: provider === 'codex' ? formData.bearerTokenEnvVar.trim() || undefined : undefined,
envHttpHeaders: provider === 'codex' ? formData.envHttpHeaders : undefined,
};
};

View File

@@ -0,0 +1,231 @@
import { Edit3, ExternalLink, Globe, Lock, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
import { IS_PLATFORM } from '../../../constants/config';
import { Badge, Button } from '../../../shared/view/ui';
import { MCP_PROVIDER_BUTTON_CLASSES, MCP_PROVIDER_NAMES } from '../constants';
import { useMcpServers } from '../hooks/useMcpServers';
import { maskSecret } from '../utils/mcpFormatting';
import McpServerFormModal from './modals/McpServerFormModal';
type McpServersProps = {
selectedProvider: McpProvider;
currentProjects: McpProject[];
};
const getTransportIcon = (transport: string | undefined) => {
if (transport === 'stdio') {
return <Terminal className="h-4 w-4" />;
}
if (transport === 'sse') {
return <Zap className="h-4 w-4" />;
}
if (transport === 'http') {
return <Globe className="h-4 w-4" />;
}
return <Server className="h-4 w-4" />;
};
const getScopeLabel = (scope: McpScope): string => {
if (scope === 'user') {
return 'user';
}
if (scope === 'local') {
return 'local';
}
return 'project';
};
const getServerKey = (server: ProviderMcpServer): string => (
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
);
function ConfigLine({ label, children }: { label: string; children: string }) {
if (!children) {
return null;
}
return (
<div>
{label}:{' '}
<code className="rounded bg-muted px-1 text-xs">{children}</code>
</div>
);
}
function TeamMcpFeatureCard() {
return (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
<Users className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">Team MCP Configs</h4>
<Lock className="h-3 w-3 text-muted-foreground/60" />
</div>
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
Share MCP server configurations across your team. Everyone stays in sync automatically.
</p>
<a
href="https://cloudcli.ai"
target="_blank"
rel="noopener noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
>
Available with CloudCLI Pro
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
);
}
export default function McpServers({ selectedProvider, currentProjects }: McpServersProps) {
const { t } = useTranslation('settings');
const {
servers,
isLoading,
isLoadingProjectScopes,
loadError,
deleteError,
saveStatus,
isFormOpen,
editingServer,
openForm,
closeForm,
submitForm,
deleteServer,
} = useMcpServers({ selectedProvider, currentProjects });
const providerName = MCP_PROVIDER_NAMES[selectedProvider];
const description = t(`mcpServers.description.${selectedProvider}`, {
defaultValue: `Model Context Protocol servers provide additional tools and data sources to ${providerName}`,
});
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">{description}</p>
<div className="flex items-center justify-between gap-3">
<Button onClick={() => openForm()} className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]} size="sm">
<Plus className="mr-2 h-4 w-4" />
{t('mcpServers.addButton')}
</Button>
{saveStatus === 'success' && (
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
)}
{isLoadingProjectScopes && (
<span className="animate-in fade-in text-xs text-muted-foreground">Refreshing project scopes...</span>
)}
</div>
{(loadError || 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 || loadError}
</div>
)}
<div className="space-y-2">
{isLoading && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
)}
{servers.map((server) => (
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
{getTransportIcon(server.transport)}
<span className="font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{server.transport || 'stdio'}
</Badge>
<Badge variant="outline" className="text-xs">
{getScopeLabel(server.scope)}
</Badge>
{server.projectDisplayName && (
<Badge variant="outline" className="max-w-full truncate text-xs">
{server.projectDisplayName}
</Badge>
)}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
{server.env && Object.keys(server.env).length > 0 && (
<ConfigLine label={t('mcpServers.config.environment')}>
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
</ConfigLine>
)}
{server.envVars && server.envVars.length > 0 && (
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
)}
</div>
</div>
<div className="ml-4 flex items-center gap-2">
<Button
onClick={() => openForm(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={() => deleteServer(server)}
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>
))}
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
)}
</div>
{selectedProvider === 'codex' && (
<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>
)}
{selectedProvider === 'claude' && !IS_PLATFORM && <TeamMcpFeatureCard />}
<McpServerFormModal
provider={selectedProvider}
isOpen={isFormOpen}
editingServer={editingServer}
currentProjects={currentProjects}
onClose={closeForm}
onSubmit={submitForm}
/>
</div>
);
}

View File

@@ -0,0 +1,394 @@
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&#10;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&#10;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&#10;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&#10;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>
);
}