From d979c315cd9a80c82796f7125eb9b5714d84114a Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Thu, 16 Apr 2026 22:43:18 +0300 Subject: [PATCH] feat(mcp): add global MCP server creation flow Add a separate global MCP add path in the settings MCP module so users can create one shared MCP server configuration across Claude, Cursor, Codex, and Gemini from the same screen. The provider-specific add flow is still kept next to it because these two actions have different intent. A global MCP server must be constrained to the subset of configuration that every provider can accept, while a provider-specific server can still use that provider's own supported scopes, transports, and fields. Naming the buttons as "Add Global MCP Server" and "Add MCP Server" makes that distinction explicit without forcing users to infer it from the selected tab. This also moves the explanatory copy to button hover text to keep the MCP toolbar compact while still documenting the difference between global and provider-only adds at the point of action. Implementation details: - Add global MCP form mode with shared user/project scopes and stdio/http transports. - Submit global creates through `/api/providers/mcp/servers/global`. - Reuse the existing MCP form modal with configurable scopes, transports, labels, and descriptions instead of duplicating form logic. - Disable provider-only fields for the global flow because those fields cannot be safely written to every provider. - Clear the MCP server cache globally after a global add because every provider tab may have changed. - Surface partial global add failures with provider-specific error messages. Validation: - npx eslint src/components/mcp/view/McpServers.tsx - npm run typecheck - npm run build:client --- src/components/mcp/constants.ts | 4 + src/components/mcp/hooks/useMcpServerForm.ts | 50 ++++++++---- src/components/mcp/hooks/useMcpServers.ts | 79 ++++++++++++++++++- src/components/mcp/types.ts | 7 ++ src/components/mcp/utils/mcpFormatting.ts | 59 +++++++++++--- src/components/mcp/view/McpServers.tsx | 74 ++++++++++++++--- .../mcp/view/modals/McpServerFormModal.tsx | 77 +++++++++++++----- 7 files changed, 289 insertions(+), 61 deletions(-) diff --git a/src/components/mcp/constants.ts b/src/components/mcp/constants.ts index fb7a5793..4b1a949c 100644 --- a/src/components/mcp/constants.ts +++ b/src/components/mcp/constants.ts @@ -21,6 +21,10 @@ export const MCP_SUPPORTED_TRANSPORTS: Record = { gemini: ['stdio', 'http', 'sse'], }; +export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project']; + +export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http']; + 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', diff --git a/src/components/mcp/hooks/useMcpServerForm.ts b/src/components/mcp/hooks/useMcpServerForm.ts index e9224de5..52809cbe 100644 --- a/src/components/mcp/hooks/useMcpServerForm.ts +++ b/src/components/mcp/hooks/useMcpServerForm.ts @@ -17,6 +17,9 @@ type UseMcpServerFormArgs = { isOpen: boolean; editingServer: ProviderMcpServer | null; currentProjects: McpProject[]; + supportedScopes?: McpScope[]; + supportedTransports?: McpTransport[]; + unsupportedTransportMessage?: (transport: McpTransport) => string; onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise; }; @@ -28,10 +31,14 @@ type MultilineFieldText = { envHttpHeaders: string; }; -const cloneDefaultForm = (provider: McpProvider): McpFormState => ({ +const cloneDefaultForm = ( + provider: McpProvider, + supportedScopes = MCP_SUPPORTED_SCOPES[provider], + supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider], +): McpFormState => ({ ...DEFAULT_MCP_FORM, - scope: MCP_SUPPORTED_SCOPES[provider][0], - transport: MCP_SUPPORTED_TRANSPORTS[provider][0], + scope: supportedScopes[0], + transport: supportedTransports[0], args: [], env: {}, headers: {}, @@ -42,8 +49,10 @@ const cloneDefaultForm = (provider: McpProvider): McpFormState => ({ const createFormStateFromServer = ( provider: McpProvider, server: ProviderMcpServer, + supportedScopes?: McpScope[], + supportedTransports?: McpTransport[], ): McpFormState => ({ - ...cloneDefaultForm(provider), + ...cloneDefaultForm(provider, supportedScopes, supportedTransports), name: server.name, scope: server.scope, workspacePath: server.workspacePath || '', @@ -67,12 +76,12 @@ const createMultilineTextFromForm = (formData: McpFormState): MultilineFieldText envHttpHeaders: formatKeyValueLines(formData.envHttpHeaders), }); -const normalizeScope = (provider: McpProvider, value: McpScope): McpScope => ( - MCP_SUPPORTED_SCOPES[provider].includes(value) ? value : MCP_SUPPORTED_SCOPES[provider][0] +const normalizeScope = (supportedScopes: McpScope[], value: McpScope): McpScope => ( + supportedScopes.includes(value) ? value : supportedScopes[0] ); -const normalizeTransport = (provider: McpProvider, value: McpTransport): McpTransport => ( - MCP_SUPPORTED_TRANSPORTS[provider].includes(value) ? value : MCP_SUPPORTED_TRANSPORTS[provider][0] +const normalizeTransport = (supportedTransports: McpTransport[], value: McpTransport): McpTransport => ( + supportedTransports.includes(value) ? value : supportedTransports[0] ); export function useMcpServerForm({ @@ -80,12 +89,17 @@ export function useMcpServerForm({ isOpen, editingServer, currentProjects, + supportedScopes = MCP_SUPPORTED_SCOPES[provider], + supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider], + unsupportedTransportMessage, onSubmit, }: UseMcpServerFormArgs) { const { t } = useTranslation('settings'); - const [formData, setFormData] = useState(() => cloneDefaultForm(provider)); + const [formData, setFormData] = useState(() => ( + cloneDefaultForm(provider, supportedScopes, supportedTransports) + )); const [multilineText, setMultilineText] = useState(() => ( - createMultilineTextFromForm(cloneDefaultForm(provider)) + createMultilineTextFromForm(cloneDefaultForm(provider, supportedScopes, supportedTransports)) )); const [jsonValidationError, setJsonValidationError] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -99,16 +113,16 @@ export function useMcpServerForm({ setJsonValidationError(''); if (editingServer) { - const nextFormData = createFormStateFromServer(provider, editingServer); + const nextFormData = createFormStateFromServer(provider, editingServer, supportedScopes, supportedTransports); setFormData(nextFormData); setMultilineText(createMultilineTextFromForm(nextFormData)); return; } - const nextFormData = cloneDefaultForm(provider); + const nextFormData = cloneDefaultForm(provider, supportedScopes, supportedTransports); setFormData(nextFormData); setMultilineText(createMultilineTextFromForm(nextFormData)); - }, [editingServer, isOpen, provider]); + }, [editingServer, isOpen, provider, supportedScopes, supportedTransports]); const projectOptions = useMemo(() => ( currentProjects @@ -126,13 +140,13 @@ export function useMcpServerForm({ const updateScope = (scope: McpScope) => { setFormData((prev) => ({ ...prev, - scope: normalizeScope(provider, scope), + scope: normalizeScope(supportedScopes, scope), workspacePath: scope === 'user' ? '' : prev.workspacePath, })); }; const updateTransport = (transport: McpTransport) => { - setFormData((prev) => ({ ...prev, transport: normalizeTransport(provider, transport) })); + setFormData((prev) => ({ ...prev, transport: normalizeTransport(supportedTransports, transport) })); }; const validateJsonInput = (value: string) => { @@ -146,8 +160,10 @@ export function useMcpServerForm({ 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 (!supportedTransports.includes(transportInput)) { + setJsonValidationError( + unsupportedTransportMessage?.(transportInput) ?? `${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) { diff --git a/src/components/mcp/hooks/useMcpServers.ts b/src/components/mcp/hooks/useMcpServers.ts index 631f1170..57ed81cc 100644 --- a/src/components/mcp/hooks/useMcpServers.ts +++ b/src/components/mcp/hooks/useMcpServers.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api'; -import { MCP_SUPPORTED_SCOPES } from '../constants'; +import { MCP_GLOBAL_SUPPORTED_TRANSPORTS, MCP_PROVIDER_NAMES, MCP_SUPPORTED_SCOPES } from '../constants'; import type { ApiResponse, + GlobalMcpServerResult, McpFormState, McpProject, McpProvider, @@ -26,6 +27,10 @@ type ProviderMcpServerResponse = { servers: Array>; }; +type GlobalMcpServerResponse = { + results: GlobalMcpServerResult[]; +}; + type ProjectTarget = { name: string; displayName: string; @@ -184,6 +189,22 @@ const saveProviderServer = async ( } }; +const saveGlobalServer = async ( + payload: UpsertProviderMcpServerPayload, +): Promise => { + const response = await authenticatedFetch('/api/providers/mcp/servers/global', { + 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 to all providers')); + } + + return data.data.results || []; +}; + const didServerIdentityChange = ( editingServer: ProviderMcpServer, payload: UpsertProviderMcpServerPayload, @@ -202,6 +223,12 @@ const getCacheKey = (provider: McpProvider, projects: ProjectTarget[]): string = return `${provider}:${projectKey}`; }; +const formatGlobalAddFailures = (failures: GlobalMcpServerResult[]): string => ( + failures + .map((failure) => `${MCP_PROVIDER_NAMES[failure.provider]}: ${failure.error || 'Unknown error'}`) + .join('; ') +); + const sortServers = (servers: ProviderMcpServer[]): ProviderMcpServer[] => { const scopeOrder: Record = { user: 0, @@ -265,6 +292,7 @@ export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServe const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null); const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false); const [isFormOpen, setIsFormOpen] = useState(false); + const [isGlobalFormOpen, setIsGlobalFormOpen] = useState(false); const [editingServer, setEditingServer] = useState(null); const activeLoadIdRef = useRef(0); @@ -379,6 +407,14 @@ export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServe setEditingServer(null); }, []); + const openGlobalForm = useCallback(() => { + setIsGlobalFormOpen(true); + }, []); + + const closeGlobalForm = useCallback(() => { + setIsGlobalFormOpen(false); + }, []); + const submitForm = useCallback( async (formData: McpFormState, serverBeingEdited: ProviderMcpServer | null) => { const payload = createMcpPayloadFromForm(selectedProvider, formData); @@ -400,6 +436,42 @@ export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServe [cacheKey, closeForm, refreshServers, selectedProvider], ); + const submitGlobalForm = useCallback( + async (formData: McpFormState) => { + const payload = createMcpPayloadFromForm(selectedProvider, formData, { + supportedTransports: MCP_GLOBAL_SUPPORTED_TRANSPORTS, + supportsWorkingDirectory: false, + includeProviderSpecificFields: false, + unsupportedTransportMessage: (transport) => + `Add MCP Server supports only stdio and http across all providers, not ${transport}.`, + }); + + if (payload.scope === 'local') { + throw new Error('Add MCP Server supports only user or project scope across all providers.'); + } + + if (payload.scope !== 'user' && !payload.workspacePath) { + throw new Error('Select a project for project-scoped MCP servers'); + } + + // The global endpoint updates every provider, so clear every provider + // cache entry instead of only the currently visible provider tab. + const results = await saveGlobalServer(payload); + mcpServersCache.clear(); + await refreshServers({ force: true }); + + const failures = results.filter((result) => !result.created); + if (failures.length > 0) { + setSaveStatus('error'); + throw new Error(`Failed to add MCP server to all providers. ${formatGlobalAddFailures(failures)}`); + } + + setSaveStatus('success'); + closeGlobalForm(); + }, + [closeGlobalForm, refreshServers, selectedProvider], + ); + const deleteServer = useCallback( async (server: ProviderMcpServer) => { if (!window.confirm('Are you sure you want to delete this MCP server?')) { @@ -426,6 +498,7 @@ export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServe useEffect(() => { setIsFormOpen(false); + setIsGlobalFormOpen(false); setEditingServer(null); setDeleteError(null); setSaveStatus(null); @@ -448,10 +521,14 @@ export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServe deleteError, saveStatus, isFormOpen, + isGlobalFormOpen, editingServer, openForm, + openGlobalForm, closeForm, + closeGlobalForm, submitForm, + submitGlobalForm, deleteServer, refreshServers, }; diff --git a/src/components/mcp/types.ts b/src/components/mcp/types.ts index 997ecef4..810258e9 100644 --- a/src/components/mcp/types.ts +++ b/src/components/mcp/types.ts @@ -4,6 +4,7 @@ export type McpProvider = LLMProvider; export type McpScope = 'user' | 'local' | 'project'; export type McpTransport = 'stdio' | 'http' | 'sse'; export type McpImportMode = 'form' | 'json'; +export type McpFormMode = 'provider' | 'global'; export type KeyValueMap = Record; export type McpProject = { @@ -66,6 +67,12 @@ export type UpsertProviderMcpServerPayload = { envHttpHeaders?: KeyValueMap; }; +export type GlobalMcpServerResult = { + provider: McpProvider; + created: boolean; + error?: string; +}; + export type ApiSuccessResponse = { success: true; data: T; diff --git a/src/components/mcp/utils/mcpFormatting.ts b/src/components/mcp/utils/mcpFormatting.ts index badda29a..4184c234 100644 --- a/src/components/mcp/utils/mcpFormatting.ts +++ b/src/components/mcp/utils/mcpFormatting.ts @@ -8,6 +8,13 @@ import type { UpsertProviderMcpServerPayload, } from '../types'; +type CreateMcpPayloadOptions = { + supportedTransports?: McpTransport[]; + supportsWorkingDirectory?: boolean; + includeProviderSpecificFields?: boolean; + unsupportedTransportMessage?: (transport: McpTransport) => string; +}; + const isRecord = (value: unknown): value is Record => ( Boolean(value) && typeof value === 'object' && !Array.isArray(value) ); @@ -79,9 +86,25 @@ export const getErrorMessage = (error: unknown): string => ( error instanceof Error ? error.message : 'Unknown error' ); +const assertSupportedTransport = ( + provider: McpProvider, + transport: McpTransport, + options?: CreateMcpPayloadOptions, +) => { + const supportedTransports = options?.supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider]; + if (supportedTransports.includes(transport)) { + return; + } + + throw new Error( + options?.unsupportedTransportMessage?.(transport) ?? `${provider} does not support ${transport} MCP servers`, + ); +}; + export const parseJsonMcpPayload = ( provider: McpProvider, formData: McpFormState, + options?: CreateMcpPayloadOptions, ): UpsertProviderMcpServerPayload => { const parsed = JSON.parse(formData.jsonInput) as unknown; if (!isRecord(parsed)) { @@ -94,9 +117,7 @@ export const parseJsonMcpPayload = ( throw new Error('Missing required field: type'); } - if (!MCP_SUPPORTED_TRANSPORTS[provider].includes(transport)) { - throw new Error(`${provider} does not support ${transport} MCP servers`); - } + assertSupportedTransport(provider, transport, options); if (transport === 'stdio' && !readString(parsed.command)) { throw new Error('stdio type requires a command field'); @@ -114,23 +135,37 @@ export const parseJsonMcpPayload = ( command: readString(parsed.command), args: readStringArray(parsed.args) ?? [], env: readStringRecord(parsed.env) ?? {}, - cwd: MCP_SUPPORTS_WORKING_DIRECTORY[provider] ? readString(parsed.cwd) : undefined, + cwd: (options?.supportsWorkingDirectory ?? 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) ?? {}, + envVars: (options?.includeProviderSpecificFields ?? provider === 'codex') + ? readStringArray(parsed.envVars ?? parsed.env_vars) ?? [] + : undefined, + bearerTokenEnvVar: (options?.includeProviderSpecificFields ?? provider === 'codex') + ? readString(parsed.bearerTokenEnvVar ?? parsed.bearer_token_env_var) + : undefined, + envHttpHeaders: (options?.includeProviderSpecificFields ?? provider === 'codex') + ? readStringRecord(parsed.envHttpHeaders ?? parsed.env_http_headers) ?? {} + : undefined, }; }; export const createMcpPayloadFromForm = ( provider: McpProvider, formData: McpFormState, + options?: CreateMcpPayloadOptions, ): UpsertProviderMcpServerPayload => { if (formData.importMode === 'json') { - return parseJsonMcpPayload(provider, formData); + return parseJsonMcpPayload(provider, formData, options); } + assertSupportedTransport(provider, formData.transport, options); + + const supportsWorkingDirectory = options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider]; + const includeProviderSpecificFields = options?.includeProviderSpecificFields ?? provider === 'codex'; + return { name: formData.name.trim(), scope: formData.scope, @@ -139,11 +174,11 @@ export const createMcpPayloadFromForm = ( 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, + cwd: supportsWorkingDirectory ? 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, + envVars: includeProviderSpecificFields ? formData.envVars : undefined, + bearerTokenEnvVar: includeProviderSpecificFields ? formData.bearerTokenEnvVar.trim() || undefined : undefined, + envHttpHeaders: includeProviderSpecificFields ? formData.envHttpHeaders : undefined, }; }; diff --git a/src/components/mcp/view/McpServers.tsx b/src/components/mcp/view/McpServers.tsx index 393d7ea5..8ec9d03e 100644 --- a/src/components/mcp/view/McpServers.tsx +++ b/src/components/mcp/view/McpServers.tsx @@ -4,7 +4,12 @@ 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 { + MCP_GLOBAL_SUPPORTED_SCOPES, + MCP_GLOBAL_SUPPORTED_TRANSPORTS, + MCP_PROVIDER_BUTTON_CLASSES, + MCP_PROVIDER_NAMES, +} from '../constants'; import { useMcpServers } from '../hooks/useMcpServers'; import { maskSecret } from '../utils/mcpFormatting'; @@ -100,10 +105,14 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer deleteError, saveStatus, isFormOpen, + isGlobalFormOpen, editingServer, openForm, + openGlobalForm, closeForm, + closeGlobalForm, submitForm, + submitGlobalForm, deleteServer, } = useMcpServers({ selectedProvider, currentProjects }); @@ -111,6 +120,12 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer const description = t(`mcpServers.description.${selectedProvider}`, { defaultValue: `Model Context Protocol servers provide additional tools and data sources to ${providerName}`, }); + const globalButtonLabel = 'Add Global MCP Server'; + const providerButtonLabel = `Add ${providerName} MCP Server`; + const globalAddDescription = 'Add Global MCP Server writes one common stdio or HTTP server to Claude, Cursor, Codex, and Gemini.'; + const providerAddDescription = `${providerButtonLabel} only changes ${providerName}.`; + const globalModalDescription = 'Adds this MCP server to every provider: Claude, Cursor, Codex, and Gemini. ' + + 'Only stdio and HTTP transports are supported because the same config must work across all providers.'; return (
@@ -120,17 +135,35 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer

{description}

-
- - {saveStatus === 'success' && ( - {t('saveStatus.success')} - )} - {isLoadingProjectScopes && ( - Refreshing project scopes... - )} +
+
+ + +
+
+ {saveStatus === 'success' && ( + {t('saveStatus.success')} + )} + {isLoadingProjectScopes && ( + Refreshing project scopes... + )} +
{(loadError || deleteError) && ( @@ -223,9 +256,26 @@ export default function McpServers({ selectedProvider, currentProjects }: McpSer isOpen={isFormOpen} editingServer={editingServer} currentProjects={currentProjects} + title={editingServer ? undefined : providerButtonLabel} + submitLabel={providerButtonLabel} onClose={closeForm} onSubmit={submitForm} /> + + submitGlobalForm(formData)} + />
); } diff --git a/src/components/mcp/view/modals/McpServerFormModal.tsx b/src/components/mcp/view/modals/McpServerFormModal.tsx index 09f0c217..afffa512 100644 --- a/src/components/mcp/view/modals/McpServerFormModal.tsx +++ b/src/components/mcp/view/modals/McpServerFormModal.tsx @@ -9,50 +9,77 @@ import { MCP_SUPPORTS_WORKING_DIRECTORY, } from '../../constants'; import { useMcpServerForm } from '../../hooks/useMcpServerForm'; -import type { McpFormState, McpProject, McpProvider, McpScope, ProviderMcpServer } from '../../types'; +import type { + McpFormMode, + McpFormState, + McpProject, + McpProvider, + McpScope, + McpTransport, + ProviderMcpServer, +} from '../../types'; type McpServerFormModalProps = { provider: McpProvider; + mode?: McpFormMode; isOpen: boolean; editingServer: ProviderMcpServer | null; currentProjects: McpProject[]; + title?: string; + description?: string; + submitLabel?: string; + supportedScopes?: McpScope[]; + supportedTransports?: McpTransport[]; onClose: () => void; onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise; }; -const getScopeLabel = (scope: McpScope): string => { +const getScopeLabel = (scope: McpScope, mode: McpFormMode): string => { if (scope === 'user') { - return 'User (Global)'; + return mode === 'global' ? 'User (All Providers)' : 'User (Global)'; } if (scope === 'local') { return 'Claude Local'; } - return 'Project'; + return mode === 'global' ? 'Project (All Providers)' : 'Project'; }; -const getScopeDescription = (scope: McpScope): string => { +const getScopeDescription = (scope: McpScope, mode: McpFormMode): string => { if (scope === 'user') { - return 'Available across all projects on your machine'; + return mode === 'global' + ? 'Writes to each provider user config and is available across projects on this machine' + : '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'; + return mode === 'global' + ? 'Writes to the selected project workspace for every provider' + : 'Stored in the selected project workspace'; }; export default function McpServerFormModal({ provider, + mode = 'provider', isOpen, editingServer, currentProjects, + title, + description, + submitLabel, + supportedScopes, + supportedTransports, onClose, onSubmit, }: McpServerFormModalProps) { const { t } = useTranslation('settings'); + const isGlobalMode = mode === 'global'; + const availableScopes = supportedScopes ?? MCP_SUPPORTED_SCOPES[provider]; + const availableTransports = supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider]; const { formData, multilineText, @@ -72,6 +99,11 @@ export default function McpServerFormModal({ isOpen, editingServer, currentProjects, + supportedScopes: availableScopes, + supportedTransports: availableTransports, + unsupportedTransportMessage: isGlobalMode + ? (transport) => `Add MCP Server supports only stdio and http across all providers, not ${transport}.` + : undefined, onSubmit, }); @@ -80,23 +112,30 @@ export default function McpServerFormModal({ } const providerName = MCP_PROVIDER_NAMES[provider]; + const modalTitle = title ?? (isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')); + const addButtonLabel = submitLabel ?? `${t('mcpForm.actions.addServer')} to ${providerName}`; const showProjectSelector = formData.scope !== 'user'; const supportsHttpHeaders = formData.transport === 'http' || formData.transport === 'sse'; - const supportsWorkingDirectory = MCP_SUPPORTS_WORKING_DIRECTORY[provider]; + const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider]; + const showCodexOnlyFields = provider === 'codex' && !isGlobalMode; return (
-

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

+

{modalTitle}

+ {description && ( +
+ {description} +
+ )} + {!isEditing && (
))}
-

{getScopeDescription(formData.scope)}

+

{getScopeDescription(formData.scope, mode)}

{showProjectSelector && ( @@ -219,7 +258,7 @@ export default function McpServerFormModal({ 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) => ( + {availableTransports.map((transport) => ( @@ -344,7 +383,7 @@ export default function McpServerFormModal({
)} - {provider === 'codex' && formData.importMode === 'form' && formData.transport === 'stdio' && ( + {showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'stdio' && (
)} - {provider === 'codex' && formData.importMode === 'form' && formData.transport === 'http' && ( + {showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'http' && (