diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts new file mode 100644 index 00000000..fb7a5793 --- /dev/null +++ b/src/components/mcp/constants.ts @@ -0,0 +1,54 @@ +import type { McpFormState, McpProvider, McpScope, McpTransport } from './types'; + +export const MCP_PROVIDER_NAMES: Record = { + claude: 'Claude', + cursor: 'Cursor', + codex: 'Codex', + gemini: 'Gemini', +}; + +export const MCP_SUPPORTED_SCOPES: Record = { + claude: ['user', 'project', 'local'], + cursor: ['user', 'project'], + codex: ['user', 'project'], + gemini: ['user', 'project'], +}; + +export const MCP_SUPPORTED_TRANSPORTS: Record = { + claude: ['stdio', 'http', 'sse'], + cursor: ['stdio', 'http'], + codex: ['stdio', 'http'], + gemini: ['stdio', 'http', 'sse'], +}; + +export const MCP_PROVIDER_BUTTON_CLASSES: Record = { + 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 = { + 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: '', +}; diff --git a/src/components/mcp/hooks/useMcpServerForm.ts b/src/components/mcp/hooks/useMcpServerForm.ts new file mode 100644 index 00000000..ea08fd28 --- /dev/null +++ b/src/components/mcp/hooks/useMcpServerForm.ts @@ -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; +}; + +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(() => 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 = (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) => { + 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, + }; +} diff --git a/src/components/mcp/hooks/useMcpServers.ts b/src/components/mcp/hooks/useMcpServers.ts new file mode 100644 index 00000000..631f1170 --- /dev/null +++ b/src/components/mcp/hooks/useMcpServers.ts @@ -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>; +}; + +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(); + +// 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 (response: Response): Promise => response.json() as Promise; + +const getApiErrorMessage = (payload: unknown, fallback: string): string => { + if (!payload || typeof payload !== 'object') { + return fallback; + } + + const record = payload as Record; + const error = record.error; + if (error && typeof error === 'object') { + const message = (error as Record).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, + 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(); + return projects.reduce((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 => { + 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>(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 => { + 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>(response); + + if (!response.ok || !data.success) { + throw new Error(getApiErrorMessage(data, 'Failed to delete MCP server')); + } +}; + +const saveProviderServer = async ( + provider: McpProvider, + payload: UpsertProviderMcpServerPayload, +): Promise => { + const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers`, { + method: 'POST', + body: JSON.stringify(payload), + }); + const data = await toResponseJson>(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 = { + 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(); + 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([]); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null); + const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false); + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingServer, setEditingServer] = useState(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, + }; +} diff --git a/src/components/mcp/index.ts b/src/components/mcp/index.ts new file mode 100644 index 00000000..33439517 --- /dev/null +++ b/src/components/mcp/index.ts @@ -0,0 +1 @@ +export { default as McpServers } from './view/McpServers'; \ No newline at end of file diff --git a/src/components/mcp/types.ts b/src/components/mcp/types.ts new file mode 100644 index 00000000..997ecef4 --- /dev/null +++ b/src/components/mcp/types.ts @@ -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; + +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 = { + success: true; + data: T; +}; + +export type ApiErrorResponse = { + success: false; + error?: { + code?: string; + message?: string; + details?: unknown; + }; +}; + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; diff --git a/src/components/mcp/utils/mcpFormatting.ts b/src/components/mcp/utils/mcpFormatting.ts new file mode 100644 index 00000000..badda29a --- /dev/null +++ b/src/components/mcp/utils/mcpFormatting.ts @@ -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 => ( + 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, + }; +}; diff --git a/src/components/mcp/view/McpServers.tsx b/src/components/mcp/view/McpServers.tsx new file mode 100644 index 00000000..393d7ea5 --- /dev/null +++ b/src/components/mcp/view/McpServers.tsx @@ -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 ; + } + + if (transport === 'sse') { + return ; + } + + if (transport === 'http') { + return ; + } + + return ; +}; + +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 ( +
+ {label}:{' '} + {children} +
+ ); +} + +function TeamMcpFeatureCard() { + return ( +
+
+
+ +
+
+
+

Team MCP Configs

+ +
+

+ Share MCP server configurations across your team. Everyone stays in sync automatically. +

+ + Available with CloudCLI Pro + + +
+
+
+ ); +} + +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 ( +
+
+ +

{t('mcpServers.title')}

+
+

{description}

+ +
+ + {saveStatus === 'success' && ( + {t('saveStatus.success')} + )} + {isLoadingProjectScopes && ( + Refreshing project scopes... + )} +
+ + {(loadError || deleteError) && ( +
+ {deleteError || loadError} +
+ )} + +
+ {isLoading && servers.length === 0 && ( +
Loading MCP servers...
+ )} + + {servers.map((server) => ( +
+
+
+
+ {getTransportIcon(server.transport)} + {server.name} + + {server.transport || 'stdio'} + + + {getScopeLabel(server.scope)} + + {server.projectDisplayName && ( + + {server.projectDisplayName} + + )} +
+ +
+ {server.command || ''} + {server.url || ''} + {(server.args || []).join(' ')} + {server.cwd || ''} + {server.env && Object.keys(server.env).length > 0 && ( + + {Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')} + + )} + {server.envVars && server.envVars.length > 0 && ( + {server.envVars.join(', ')} + )} +
+
+ +
+ + +
+
+
+ ))} + + {!isLoading && !isLoadingProjectScopes && servers.length === 0 && ( +
{t('mcpServers.empty')}
+ )} +
+ + {selectedProvider === 'codex' && ( +
+

{t('mcpServers.help.title')}

+

{t('mcpServers.help.description')}

+
+ )} + + {selectedProvider === 'claude' && !IS_PLATFORM && } + + +
+ ); +} diff --git a/src/components/mcp/view/modals/McpServerFormModal.tsx b/src/components/mcp/view/modals/McpServerFormModal.tsx new file mode 100644 index 00000000..2b307c09 --- /dev/null +++ b/src/components/mcp/view/modals/McpServerFormModal.tsx @@ -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; +}; + +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 ( +
+
+
+

+ {isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')} +

+ +
+ +
+ {!isEditing && ( +
+ + +
+ )} + + {isEditing && ( +
+ +
+ {formData.scope === 'user' ? : } + {getScopeLabel(formData.scope)} + {formData.workspacePath && ( + - {formData.workspacePath} + )} +
+

{t('mcpForm.scope.cannotChange')}

+
+ )} + + {!isEditing && ( +
+
+ +
+ {MCP_SUPPORTED_SCOPES[provider].map((scope) => ( + + ))} +
+

{getScopeDescription(formData.scope)}

+
+ + {showProjectSelector && ( +
+ + + {formData.workspacePath && ( +

+ {t('mcpForm.projectPath', { path: formData.workspacePath })} +

+ )} +
+ )} +
+ )} + +
+
+ + updateForm('name', event.target.value)} + placeholder={t('mcpForm.placeholders.serverName')} + required + /> +
+ + {formData.importMode === 'form' && ( +
+ + +
+ )} +
+ + {formData.importMode === 'json' && ( +
+ +