mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-04 11:45:38 +08:00
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 <Provider> 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
This commit is contained in:
@@ -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<void>;
|
||||
};
|
||||
|
||||
@@ -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<McpFormState>(() => cloneDefaultForm(provider));
|
||||
const [formData, setFormData] = useState<McpFormState>(() => (
|
||||
cloneDefaultForm(provider, supportedScopes, supportedTransports)
|
||||
));
|
||||
const [multilineText, setMultilineText] = useState<MultilineFieldText>(() => (
|
||||
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) {
|
||||
|
||||
@@ -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<Partial<ProviderMcpServer>>;
|
||||
};
|
||||
|
||||
type GlobalMcpServerResponse = {
|
||||
results: GlobalMcpServerResult[];
|
||||
};
|
||||
|
||||
type ProjectTarget = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
@@ -184,6 +189,22 @@ const saveProviderServer = async (
|
||||
}
|
||||
};
|
||||
|
||||
const saveGlobalServer = async (
|
||||
payload: UpsertProviderMcpServerPayload,
|
||||
): Promise<GlobalMcpServerResult[]> => {
|
||||
const response = await authenticatedFetch('/api/providers/mcp/servers/global', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await toResponseJson<ApiResponse<GlobalMcpServerResponse>>(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<McpScope, number> = {
|
||||
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<ProviderMcpServer | null>(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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user