mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-19 12:01:28 +00:00
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:
54
src/components/mcp/constants.ts
Normal file
54
src/components/mcp/constants.ts
Normal 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: '',
|
||||||
|
};
|
||||||
185
src/components/mcp/hooks/useMcpServerForm.ts
Normal file
185
src/components/mcp/hooks/useMcpServerForm.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
458
src/components/mcp/hooks/useMcpServers.ts
Normal file
458
src/components/mcp/hooks/useMcpServers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
1
src/components/mcp/index.ts
Normal file
1
src/components/mcp/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as McpServers } from './view/McpServers';
|
||||||
83
src/components/mcp/types.ts
Normal file
83
src/components/mcp/types.ts
Normal 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;
|
||||||
149
src/components/mcp/utils/mcpFormatting.ts
Normal file
149
src/components/mcp/utils/mcpFormatting.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
231
src/components/mcp/view/McpServers.tsx
Normal file
231
src/components/mcp/view/McpServers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
394
src/components/mcp/view/modals/McpServerFormModal.tsx
Normal file
394
src/components/mcp/view/modals/McpServerFormModal.tsx
Normal 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 3000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{supportsWorkingDirectory && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
Working Directory
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.cwd}
|
||||||
|
onChange={(event) => updateForm('cwd', event.target.value)}
|
||||||
|
placeholder="."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && formData.transport !== 'stdio' && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
{t('mcpForm.fields.url')} *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(event) => updateForm('url', event.target.value)}
|
||||||
|
placeholder="https://api.example.com/mcp"
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
{t('mcpForm.fields.envVars')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formatKeyValueLines(formData.env)}
|
||||||
|
onChange={(event) => updateForm('env', parseKeyValueLines(event.target.value))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
rows={3}
|
||||||
|
placeholder="API_KEY=your-key DEBUG=true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.importMode === 'form' && supportsHttpHeaders && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
{t('mcpForm.fields.headers')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formatKeyValueLines(formData.headers)}
|
||||||
|
onChange={(event) => updateForm('headers', parseKeyValueLines(event.target.value))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Authorization=Bearer token X-API-Key=your-key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{provider === 'codex' && formData.importMode === 'form' && formData.transport === 'stdio' && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
Environment Variable Names
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.envVars.join('\n')}
|
||||||
|
onChange={(event) => updateForm('envVars', parseListLines(event.target.value))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||||
|
rows={3}
|
||||||
|
placeholder="GITHUB_TOKEN API_KEY"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{provider === 'codex' && formData.importMode === 'form' && formData.transport === 'http' && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
Bearer Token Environment Variable
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={formData.bearerTokenEnvVar}
|
||||||
|
onChange={(event) => updateForm('bearerTokenEnvVar', event.target.value)}
|
||||||
|
placeholder="MCP_TOKEN"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
{t('mcpForm.actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !canSubmit}
|
||||||
|
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? t('mcpForm.actions.saving')
|
||||||
|
: isEditing
|
||||||
|
? t('mcpForm.actions.updateServer')
|
||||||
|
: `${t('mcpForm.actions.addServer')} to ${providerName}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentCategory,
|
AgentCategory,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
ClaudeMcpFormState,
|
|
||||||
CodexMcpFormState,
|
|
||||||
CodeEditorSettingsState,
|
CodeEditorSettingsState,
|
||||||
CursorPermissionsState,
|
CursorPermissionsState,
|
||||||
ProjectSortOrder,
|
ProjectSortOrder,
|
||||||
@@ -18,7 +16,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
|||||||
'notifications',
|
'notifications',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||||
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||||
@@ -31,33 +29,6 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
|||||||
fontSize: '14',
|
fontSize: '14',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_CLAUDE_MCP_FORM: ClaudeMcpFormState = {
|
|
||||||
name: '',
|
|
||||||
type: 'stdio',
|
|
||||||
scope: 'user',
|
|
||||||
projectPath: '',
|
|
||||||
config: {
|
|
||||||
command: '',
|
|
||||||
args: [],
|
|
||||||
env: {},
|
|
||||||
url: '',
|
|
||||||
headers: {},
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
importMode: 'form',
|
|
||||||
jsonInput: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_CODEX_MCP_FORM: CodexMcpFormState = {
|
|
||||||
name: '',
|
|
||||||
type: 'stdio',
|
|
||||||
config: {
|
|
||||||
command: '',
|
|
||||||
args: [],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
|
export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
|
||||||
allowedCommands: [],
|
allowedCommands: [],
|
||||||
disallowedCommands: [],
|
disallowedCommands: [],
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ import {
|
|||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
import type {
|
import type {
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
ClaudeMcpFormState,
|
|
||||||
ClaudePermissionsState,
|
ClaudePermissionsState,
|
||||||
CodeEditorSettingsState,
|
CodeEditorSettingsState,
|
||||||
CodexMcpFormState,
|
|
||||||
CodexPermissionMode,
|
CodexPermissionMode,
|
||||||
CursorPermissionsState,
|
CursorPermissionsState,
|
||||||
GeminiPermissionMode,
|
GeminiPermissionMode,
|
||||||
McpServer,
|
|
||||||
NotificationPreferencesState,
|
NotificationPreferencesState,
|
||||||
ProjectSortOrder,
|
ProjectSortOrder,
|
||||||
SettingsMainTab,
|
SettingsMainTab,
|
||||||
@@ -31,31 +28,6 @@ type UseSettingsControllerArgs = {
|
|||||||
initialTab: string;
|
initialTab: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JsonResult = {
|
|
||||||
success?: boolean;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type McpReadResponse = {
|
|
||||||
success?: boolean;
|
|
||||||
servers?: McpServer[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type McpCliServer = {
|
|
||||||
name: string;
|
|
||||||
type?: string;
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
env?: Record<string, string>;
|
|
||||||
url?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type McpCliReadResponse = {
|
|
||||||
success?: boolean;
|
|
||||||
servers?: McpCliServer[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ClaudeSettingsStorage = {
|
type ClaudeSettingsStorage = {
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
disallowedTools?: string[];
|
disallowedTools?: string[];
|
||||||
@@ -91,10 +63,6 @@ const normalizeMainTab = (tab: string): SettingsMainTab => {
|
|||||||
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
|
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getErrorMessage = (error: unknown): string => (
|
|
||||||
error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
);
|
|
||||||
|
|
||||||
const parseJson = <T>(value: string | null, fallback: T): T => {
|
const parseJson = <T>(value: string | null, fallback: T): T => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -123,25 +91,6 @@ const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
|||||||
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
|
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (
|
|
||||||
servers.map((server) => ({
|
|
||||||
id: server.name,
|
|
||||||
name: server.name,
|
|
||||||
type: server.type || 'stdio',
|
|
||||||
scope: 'user',
|
|
||||||
config: {
|
|
||||||
command: server.command || '',
|
|
||||||
args: server.args || [],
|
|
||||||
env: server.env || {},
|
|
||||||
url: server.url || '',
|
|
||||||
headers: server.headers || {},
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
created: new Date().toISOString(),
|
|
||||||
updated: new Date().toISOString(),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
|
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
|
||||||
|
|
||||||
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
|
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
|
||||||
@@ -172,7 +121,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
|
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
|
||||||
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
||||||
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||||
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
|
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
|
||||||
readCodeEditorSettings()
|
readCodeEditorSettings()
|
||||||
@@ -190,15 +138,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||||
|
|
||||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
|
||||||
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
|
|
||||||
const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);
|
|
||||||
|
|
||||||
const [showMcpForm, setShowMcpForm] = useState(false);
|
|
||||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
|
|
||||||
const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
|
|
||||||
const [editingCodexMcpServer, setEditingCodexMcpServer] = useState<McpServer | null>(null);
|
|
||||||
|
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
|
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
|
||||||
const {
|
const {
|
||||||
@@ -207,284 +146,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
refreshProviderAuthStatuses,
|
refreshProviderAuthStatuses,
|
||||||
} = useProviderAuthStatus();
|
} = useProviderAuthStatus();
|
||||||
|
|
||||||
const fetchCursorMcpServers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch('/api/cursor/mcp');
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error('Failed to fetch Cursor MCP servers');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await toResponseJson<{ servers?: McpServer[] }>(response);
|
|
||||||
setCursorMcpServers(data.servers || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching Cursor MCP servers:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchCodexMcpServers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
|
|
||||||
|
|
||||||
if (configResponse.ok) {
|
|
||||||
const configData = await toResponseJson<McpReadResponse>(configResponse);
|
|
||||||
if (configData.success && configData.servers) {
|
|
||||||
setCodexMcpServers(configData.servers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
|
|
||||||
if (!cliResponse.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
|
|
||||||
if (!cliData.success || !cliData.servers) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching Codex MCP servers:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchMcpServers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const configResponse = await authenticatedFetch('/api/mcp/config/read');
|
|
||||||
if (configResponse.ok) {
|
|
||||||
const configData = await toResponseJson<McpReadResponse>(configResponse);
|
|
||||||
if (configData.success && configData.servers) {
|
|
||||||
setMcpServers(configData.servers);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
|
|
||||||
if (cliResponse.ok) {
|
|
||||||
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
|
|
||||||
if (cliData.success && cliData.servers) {
|
|
||||||
setMcpServers(mapCliServersToMcpServers(cliData.servers));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('Failed to fetch MCP servers');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching MCP servers:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {
|
|
||||||
const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await toResponseJson<JsonResult>(response);
|
|
||||||
throw new Error(error.error || 'Failed to delete server');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await toResponseJson<JsonResult>(response);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to delete server via Claude CLI');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveMcpServer = useCallback(
|
|
||||||
async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {
|
|
||||||
const newServerScope = serverData.scope || 'user';
|
|
||||||
|
|
||||||
const response = await authenticatedFetch('/api/mcp/cli/add', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: serverData.name,
|
|
||||||
type: serverData.type,
|
|
||||||
scope: newServerScope,
|
|
||||||
projectPath: serverData.projectPath,
|
|
||||||
command: serverData.config.command,
|
|
||||||
args: serverData.config.args || [],
|
|
||||||
url: serverData.config.url,
|
|
||||||
headers: serverData.config.headers || {},
|
|
||||||
env: serverData.config.env || {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await toResponseJson<JsonResult>(response);
|
|
||||||
throw new Error(error.error || 'Failed to save server');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await toResponseJson<JsonResult>(response);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to save server via Claude CLI');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editingServer?.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousServerScope = editingServer.scope || 'user';
|
|
||||||
const didServerIdentityChange =
|
|
||||||
editingServer.id !== serverData.name || previousServerScope !== newServerScope;
|
|
||||||
|
|
||||||
if (!didServerIdentityChange) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteMcpServer(editingServer.id, previousServerScope);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Saved MCP server update but failed to remove the previous server entry.', {
|
|
||||||
previousServerId: editingServer.id,
|
|
||||||
previousServerScope,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteMcpServer],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitMcpForm = useCallback(
|
|
||||||
async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {
|
|
||||||
if (formData.importMode === 'json') {
|
|
||||||
const response = await authenticatedFetch('/api/mcp/cli/add-json', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: formData.name,
|
|
||||||
jsonConfig: formData.jsonInput,
|
|
||||||
scope: formData.scope,
|
|
||||||
projectPath: formData.projectPath,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await toResponseJson<JsonResult>(response);
|
|
||||||
throw new Error(error.error || 'Failed to add server');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await toResponseJson<JsonResult>(response);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to add server via JSON');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await saveMcpServer(formData, editingServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
await fetchMcpServers();
|
|
||||||
setSaveStatus('success');
|
|
||||||
setShowMcpForm(false);
|
|
||||||
setEditingMcpServer(null);
|
|
||||||
},
|
|
||||||
[fetchMcpServers, saveMcpServer],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMcpDelete = useCallback(
|
|
||||||
async (serverId: string, scope = 'user') => {
|
|
||||||
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeleteError(null);
|
|
||||||
try {
|
|
||||||
await deleteMcpServer(serverId, scope);
|
|
||||||
await fetchMcpServers();
|
|
||||||
setDeleteError(null);
|
|
||||||
setSaveStatus('success');
|
|
||||||
} catch (error) {
|
|
||||||
setDeleteError(getErrorMessage(error));
|
|
||||||
setSaveStatus('error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteMcpServer, fetchMcpServers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteCodexMcpServer = useCallback(async (serverId: string) => {
|
|
||||||
const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await toResponseJson<JsonResult>(response);
|
|
||||||
throw new Error(error.error || 'Failed to delete server');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await toResponseJson<JsonResult>(response);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to delete Codex MCP server');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveCodexMcpServer = useCallback(
|
|
||||||
async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {
|
|
||||||
const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
name: serverData.name,
|
|
||||||
command: serverData.config.command,
|
|
||||||
args: serverData.config.args || [],
|
|
||||||
env: serverData.config.env || {},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await toResponseJson<JsonResult>(response);
|
|
||||||
throw new Error(error.error || 'Failed to save server');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await toResponseJson<JsonResult>(response);
|
|
||||||
if (!result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to save Codex MCP server');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editingServer?.name || editingServer.name === serverData.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteCodexMcpServer(editingServer.name);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Saved Codex MCP server update but failed to remove the previous server entry.', {
|
|
||||||
previousServerName: editingServer.name,
|
|
||||||
error: getErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteCodexMcpServer],
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitCodexMcpForm = useCallback(
|
|
||||||
async (formData: CodexMcpFormState, editingServer: McpServer | null) => {
|
|
||||||
await saveCodexMcpServer(formData, editingServer);
|
|
||||||
await fetchCodexMcpServers();
|
|
||||||
setSaveStatus('success');
|
|
||||||
setShowCodexMcpForm(false);
|
|
||||||
setEditingCodexMcpServer(null);
|
|
||||||
},
|
|
||||||
[fetchCodexMcpServers, saveCodexMcpServer],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCodexMcpDelete = useCallback(
|
|
||||||
async (serverName: string) => {
|
|
||||||
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeleteError(null);
|
|
||||||
try {
|
|
||||||
await deleteCodexMcpServer(serverName);
|
|
||||||
await fetchCodexMcpServers();
|
|
||||||
setDeleteError(null);
|
|
||||||
setSaveStatus('success');
|
|
||||||
} catch (error) {
|
|
||||||
setDeleteError(getErrorMessage(error));
|
|
||||||
setSaveStatus('error');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteCodexMcpServer, fetchCodexMcpServers],
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
|
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
|
||||||
@@ -536,11 +197,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
fetchMcpServers(),
|
|
||||||
fetchCursorMcpServers(),
|
|
||||||
fetchCodexMcpServers(),
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
setClaudePermissions(createEmptyClaudePermissions());
|
setClaudePermissions(createEmptyClaudePermissions());
|
||||||
@@ -549,7 +205,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
setCodexPermissionMode('default');
|
setCodexPermissionMode('default');
|
||||||
setProjectSortOrder('name');
|
setProjectSortOrder('name');
|
||||||
}
|
}
|
||||||
}, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
|
}, []);
|
||||||
|
|
||||||
const openLoginForProvider = useCallback((provider: AgentProvider) => {
|
const openLoginForProvider = useCallback((provider: AgentProvider) => {
|
||||||
setLoginProvider(provider);
|
setLoginProvider(provider);
|
||||||
@@ -628,26 +284,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openMcpForm = useCallback((server?: McpServer) => {
|
|
||||||
setEditingMcpServer(server || null);
|
|
||||||
setShowMcpForm(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeMcpForm = useCallback(() => {
|
|
||||||
setShowMcpForm(false);
|
|
||||||
setEditingMcpServer(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const openCodexMcpForm = useCallback((server?: McpServer) => {
|
|
||||||
setEditingCodexMcpServer(server || null);
|
|
||||||
setShowCodexMcpForm(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const closeCodexMcpForm = useCallback(() => {
|
|
||||||
setShowCodexMcpForm(false);
|
|
||||||
setEditingCodexMcpServer(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
return;
|
return;
|
||||||
@@ -727,7 +363,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
isDarkMode,
|
isDarkMode,
|
||||||
toggleDarkMode,
|
toggleDarkMode,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
deleteError,
|
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
setProjectSortOrder,
|
setProjectSortOrder,
|
||||||
codeEditorSettings,
|
codeEditorSettings,
|
||||||
@@ -740,21 +375,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
|||||||
setNotificationPreferences,
|
setNotificationPreferences,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
setCodexPermissionMode,
|
setCodexPermissionMode,
|
||||||
mcpServers,
|
|
||||||
cursorMcpServers,
|
|
||||||
codexMcpServers,
|
|
||||||
showMcpForm,
|
|
||||||
editingMcpServer,
|
|
||||||
openMcpForm,
|
|
||||||
closeMcpForm,
|
|
||||||
submitMcpForm,
|
|
||||||
handleMcpDelete,
|
|
||||||
showCodexMcpForm,
|
|
||||||
editingCodexMcpServer,
|
|
||||||
openCodexMcpForm,
|
|
||||||
closeCodexMcpForm,
|
|
||||||
submitCodexMcpForm,
|
|
||||||
handleCodexMcpDelete,
|
|
||||||
providerAuthStatus,
|
providerAuthStatus,
|
||||||
geminiPermissionMode,
|
geminiPermissionMode,
|
||||||
setGeminiPermissionMode,
|
setGeminiPermissionMode,
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ export type ProjectSortOrder = 'name' | 'date';
|
|||||||
export type SaveStatus = 'success' | 'error' | null;
|
export type SaveStatus = 'success' | 'error' | null;
|
||||||
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||||
export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';
|
export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';
|
||||||
export type McpImportMode = 'form' | 'json';
|
|
||||||
export type McpScope = 'user' | 'local';
|
|
||||||
export type McpTransportType = 'stdio' | 'sse' | 'http';
|
|
||||||
|
|
||||||
export type SettingsProject = {
|
export type SettingsProject = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,61 +19,6 @@ export type SettingsProject = {
|
|||||||
|
|
||||||
export type AuthStatus = ProviderAuthStatus;
|
export type AuthStatus = ProviderAuthStatus;
|
||||||
|
|
||||||
export type KeyValueMap = Record<string, string>;
|
|
||||||
|
|
||||||
export type McpServerConfig = {
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
env?: KeyValueMap;
|
|
||||||
url?: string;
|
|
||||||
headers?: KeyValueMap;
|
|
||||||
timeout?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type McpServer = {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
type?: string;
|
|
||||||
scope?: string;
|
|
||||||
projectPath?: string;
|
|
||||||
config?: McpServerConfig;
|
|
||||||
raw?: unknown;
|
|
||||||
created?: string;
|
|
||||||
updated?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ClaudeMcpFormConfig = {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
env: KeyValueMap;
|
|
||||||
url: string;
|
|
||||||
headers: KeyValueMap;
|
|
||||||
timeout: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ClaudeMcpFormState = {
|
|
||||||
name: string;
|
|
||||||
type: McpTransportType;
|
|
||||||
scope: McpScope;
|
|
||||||
projectPath: string;
|
|
||||||
config: ClaudeMcpFormConfig;
|
|
||||||
importMode: McpImportMode;
|
|
||||||
jsonInput: string;
|
|
||||||
raw?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CodexMcpFormConfig = {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
env: KeyValueMap;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CodexMcpFormState = {
|
|
||||||
name: string;
|
|
||||||
type: 'stdio';
|
|
||||||
config: CodexMcpFormConfig;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ClaudePermissionsState = {
|
export type ClaudePermissionsState = {
|
||||||
allowedTools: string[];
|
allowedTools: string[];
|
||||||
disallowedTools: string[];
|
disallowedTools: string[];
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { X } from 'lucide-react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
|
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
|
||||||
import { Button } from '../../../shared/view/ui';
|
import { Button } from '../../../shared/view/ui';
|
||||||
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
|
|
||||||
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
|
|
||||||
import SettingsSidebar from '../view/SettingsSidebar';
|
import SettingsSidebar from '../view/SettingsSidebar';
|
||||||
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||||
@@ -23,7 +21,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
deleteError,
|
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
setProjectSortOrder,
|
setProjectSortOrder,
|
||||||
codeEditorSettings,
|
codeEditorSettings,
|
||||||
@@ -36,21 +33,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
setCursorPermissions,
|
setCursorPermissions,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
setCodexPermissionMode,
|
setCodexPermissionMode,
|
||||||
mcpServers,
|
|
||||||
cursorMcpServers,
|
|
||||||
codexMcpServers,
|
|
||||||
showMcpForm,
|
|
||||||
editingMcpServer,
|
|
||||||
openMcpForm,
|
|
||||||
closeMcpForm,
|
|
||||||
submitMcpForm,
|
|
||||||
handleMcpDelete,
|
|
||||||
showCodexMcpForm,
|
|
||||||
editingCodexMcpServer,
|
|
||||||
openCodexMcpForm,
|
|
||||||
closeCodexMcpForm,
|
|
||||||
submitCodexMcpForm,
|
|
||||||
handleCodexMcpDelete,
|
|
||||||
providerAuthStatus,
|
providerAuthStatus,
|
||||||
geminiPermissionMode,
|
geminiPermissionMode,
|
||||||
setGeminiPermissionMode,
|
setGeminiPermissionMode,
|
||||||
@@ -151,14 +133,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
onCodexPermissionModeChange={setCodexPermissionMode}
|
onCodexPermissionModeChange={setCodexPermissionMode}
|
||||||
geminiPermissionMode={geminiPermissionMode}
|
geminiPermissionMode={geminiPermissionMode}
|
||||||
onGeminiPermissionModeChange={setGeminiPermissionMode}
|
onGeminiPermissionModeChange={setGeminiPermissionMode}
|
||||||
mcpServers={mcpServers}
|
projects={projects}
|
||||||
cursorMcpServers={cursorMcpServers}
|
|
||||||
codexMcpServers={codexMcpServers}
|
|
||||||
onOpenMcpForm={openMcpForm}
|
|
||||||
onDeleteMcpServer={handleMcpDelete}
|
|
||||||
onOpenCodexMcpForm={openCodexMcpForm}
|
|
||||||
onDeleteCodexMcpServer={handleCodexMcpDelete}
|
|
||||||
deleteError={deleteError}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -195,20 +170,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ClaudeMcpFormModal
|
|
||||||
isOpen={showMcpForm}
|
|
||||||
editingServer={editingMcpServer}
|
|
||||||
projects={projects}
|
|
||||||
onClose={closeMcpForm}
|
|
||||||
onSubmit={submitMcpForm}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodexMcpFormModal
|
|
||||||
isOpen={showCodexMcpForm}
|
|
||||||
editingServer={editingCodexMcpServer}
|
|
||||||
onClose={closeCodexMcpForm}
|
|
||||||
onSubmit={submitCodexMcpForm}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,478 +0,0 @@
|
|||||||
import { FolderOpen, Globe, X } from 'lucide-react';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import type { FormEvent } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Input } from '../../../../shared/view/ui';
|
|
||||||
import { DEFAULT_CLAUDE_MCP_FORM } from '../../constants/constants';
|
|
||||||
import type { ClaudeMcpFormState, McpServer, McpScope, McpTransportType, SettingsProject } from '../../types/types';
|
|
||||||
|
|
||||||
type ClaudeMcpFormModalProps = {
|
|
||||||
isOpen: boolean;
|
|
||||||
editingServer: McpServer | null;
|
|
||||||
projects: SettingsProject[];
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (formData: ClaudeMcpFormState, editingServer: McpServer | null) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSafeTransportType = (value: unknown): McpTransportType => {
|
|
||||||
if (value === 'sse' || value === 'http') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'stdio';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSafeScope = (value: unknown): McpScope => (value === 'local' ? 'local' : 'user');
|
|
||||||
|
|
||||||
const getErrorMessage = (error: unknown): string => (
|
|
||||||
error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
);
|
|
||||||
|
|
||||||
const createFormStateFromServer = (server: McpServer): ClaudeMcpFormState => ({
|
|
||||||
name: server.name || '',
|
|
||||||
type: getSafeTransportType(server.type),
|
|
||||||
scope: getSafeScope(server.scope),
|
|
||||||
projectPath: server.projectPath || '',
|
|
||||||
config: {
|
|
||||||
command: server.config?.command || '',
|
|
||||||
args: server.config?.args || [],
|
|
||||||
env: server.config?.env || {},
|
|
||||||
url: server.config?.url || '',
|
|
||||||
headers: server.config?.headers || {},
|
|
||||||
timeout: server.config?.timeout || 30000,
|
|
||||||
},
|
|
||||||
importMode: 'form',
|
|
||||||
jsonInput: '',
|
|
||||||
raw: server.raw,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function ClaudeMcpFormModal({
|
|
||||||
isOpen,
|
|
||||||
editingServer,
|
|
||||||
projects,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
}: ClaudeMcpFormModalProps) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
const [formData, setFormData] = useState<ClaudeMcpFormState>(DEFAULT_CLAUDE_MCP_FORM);
|
|
||||||
const [jsonValidationError, setJsonValidationError] = useState('');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const isEditing = Boolean(editingServer);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setJsonValidationError('');
|
|
||||||
if (editingServer) {
|
|
||||||
setFormData(createFormStateFromServer(editingServer));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData(DEFAULT_CLAUDE_MCP_FORM);
|
|
||||||
}, [editingServer, isOpen]);
|
|
||||||
|
|
||||||
const canSubmit = useMemo(() => {
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.importMode === 'json') {
|
|
||||||
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.scope === 'local' && !formData.projectPath.trim()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.type === 'stdio') {
|
|
||||||
return Boolean(formData.config.command.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Boolean(formData.config.url.trim());
|
|
||||||
}, [formData, jsonValidationError]);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateConfig = <K extends keyof ClaudeMcpFormState['config']>(
|
|
||||||
key: K,
|
|
||||||
value: ClaudeMcpFormState['config'][K],
|
|
||||||
) => {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
config: {
|
|
||||||
...prev.config,
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleJsonValidation = (value: string) => {
|
|
||||||
if (!value.trim()) {
|
|
||||||
setJsonValidationError('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value) as { type?: string; command?: string; url?: string };
|
|
||||||
if (!parsed.type) {
|
|
||||||
setJsonValidationError(t('mcpForm.validation.missingType'));
|
|
||||||
} else if (parsed.type === 'stdio' && !parsed.command) {
|
|
||||||
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
|
|
||||||
} else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
|
|
||||||
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));
|
|
||||||
} else {
|
|
||||||
setJsonValidationError('');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setJsonValidationError(t('mcpForm.validation.invalidJson'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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={() => setFormData((prev) => ({ ...prev, 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={() => setFormData((prev) => ({ ...prev, 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.importMode === 'form' && 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">
|
|
||||||
{formData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}
|
|
||||||
</span>
|
|
||||||
{formData.scope === 'local' && formData.projectPath && (
|
|
||||||
<span className="text-xs text-muted-foreground">- {formData.projectPath}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.importMode === 'form' && !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="flex gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData((prev) => ({ ...prev, scope: 'user', projectPath: '' }))}
|
|
||||||
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
||||||
formData.scope === 'user'
|
|
||||||
? '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">
|
|
||||||
<Globe className="h-4 w-4" />
|
|
||||||
<span>{t('mcpForm.scope.userGlobal')}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData((prev) => ({ ...prev, scope: 'local' }))}
|
|
||||||
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
||||||
formData.scope === 'local'
|
|
||||||
? '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">
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
<span>{t('mcpForm.scope.projectLocal')}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{formData.scope === 'user'
|
|
||||||
? t('mcpForm.scope.userDescription')
|
|
||||||
: t('mcpForm.scope.projectDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.scope === 'local' && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.selectProject')} *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.projectPath}
|
|
||||||
onChange={(event) => {
|
|
||||||
setFormData((prev) => ({ ...prev, projectPath: 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>
|
|
||||||
{projects.map((project) => (
|
|
||||||
<option key={project.name} value={project.path || project.fullPath}>
|
|
||||||
{project.displayName || project.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{formData.projectPath && (
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{t('mcpForm.projectPath', { path: formData.projectPath })}
|
|
||||||
</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) => setFormData((prev) => ({ ...prev, 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.type}
|
|
||||||
onChange={(event) => {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
type: getSafeTransportType(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"
|
|
||||||
>
|
|
||||||
<option value="stdio">stdio</option>
|
|
||||||
<option value="sse">SSE</option>
|
|
||||||
<option value="http">HTTP</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isEditing && Boolean(formData.raw) && formData.importMode === 'form' && (
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
|
||||||
<h4 className="mb-2 text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.configDetails', {
|
|
||||||
configFile: editingServer?.scope === 'global' ? '~/.claude.json' : 'project config',
|
|
||||||
})}
|
|
||||||
</h4>
|
|
||||||
<pre className="overflow-x-auto rounded bg-gray-100 p-3 text-xs dark:bg-gray-800">
|
|
||||||
{JSON.stringify(formData.raw, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.importMode === 'json' && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.jsonConfig')} *
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.jsonInput}
|
|
||||||
onChange={(event) => {
|
|
||||||
const value = event.target.value;
|
|
||||||
setFormData((prev) => ({ ...prev, jsonInput: value }));
|
|
||||||
handleJsonValidation(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": "/path/to/server",\n "args": ["--api-key", "abc123"],\n "env": {\n "CACHE_DIR": "/tmp"\n }\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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.importMode === 'form' && formData.type === '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.config.command}
|
|
||||||
onChange={(event) => updateConfig('command', event.target.value)}
|
|
||||||
placeholder="/path/to/mcp-server"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.arguments')}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.config.args.join('\n')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const args = event.target.value.split('\n').filter((arg) => arg.trim());
|
|
||||||
updateConfig('args', args);
|
|
||||||
}}
|
|
||||||
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 abc123"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.url')} *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={formData.config.url}
|
|
||||||
onChange={(event) => updateConfig('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={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
event.target.value.split('\n').forEach((line) => {
|
|
||||||
const [key, ...valueParts] = line.split('=');
|
|
||||||
if (key && key.trim()) {
|
|
||||||
env[key.trim()] = valueParts.join('=').trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
updateConfig('env', env);
|
|
||||||
}}
|
|
||||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
rows={3}
|
|
||||||
placeholder="API_KEY=your-key DEBUG=true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.headers')}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={Object.entries(formData.config.headers).map(([key, value]) => `${key}=${value}`).join('\n')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
event.target.value.split('\n').forEach((line) => {
|
|
||||||
const [key, ...valueParts] = line.split('=');
|
|
||||||
if (key && key.trim()) {
|
|
||||||
headers[key.trim()] = valueParts.join('=').trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
updateConfig('headers', headers);
|
|
||||||
}}
|
|
||||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
|
||||||
rows={3}
|
|
||||||
placeholder="Authorization=Bearer token X-API-Key=your-key"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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 hover:bg-purple-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isSubmitting
|
|
||||||
? t('mcpForm.actions.saving')
|
|
||||||
: isEditing
|
|
||||||
? t('mcpForm.actions.updateServer')
|
|
||||||
: t('mcpForm.actions.addServer')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import type { FormEvent } from 'react';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Button, Input } from '../../../../shared/view/ui';
|
|
||||||
import { DEFAULT_CODEX_MCP_FORM } from '../../constants/constants';
|
|
||||||
import type { CodexMcpFormState, McpServer } from '../../types/types';
|
|
||||||
|
|
||||||
type CodexMcpFormModalProps = {
|
|
||||||
isOpen: boolean;
|
|
||||||
editingServer: McpServer | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (formData: CodexMcpFormState, editingServer: McpServer | null) => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getErrorMessage = (error: unknown): string => (
|
|
||||||
error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
);
|
|
||||||
|
|
||||||
const createFormStateFromServer = (server: McpServer): CodexMcpFormState => ({
|
|
||||||
name: server.name || '',
|
|
||||||
type: 'stdio',
|
|
||||||
config: {
|
|
||||||
command: server.config?.command || '',
|
|
||||||
args: server.config?.args || [],
|
|
||||||
env: server.config?.env || {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function CodexMcpFormModal({
|
|
||||||
isOpen,
|
|
||||||
editingServer,
|
|
||||||
onClose,
|
|
||||||
onSubmit,
|
|
||||||
}: CodexMcpFormModalProps) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
const [formData, setFormData] = useState<CodexMcpFormState>(DEFAULT_CODEX_MCP_FORM);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingServer) {
|
|
||||||
setFormData(createFormStateFromServer(editingServer));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setFormData(DEFAULT_CODEX_MCP_FORM);
|
|
||||||
}, [editingServer, isOpen]);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<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-lg 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">
|
|
||||||
{editingServer ? 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">
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.serverName')} *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
|
|
||||||
placeholder={t('mcpForm.placeholders.serverName')}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.command')} *
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={formData.config.command}
|
|
||||||
onChange={(event) => {
|
|
||||||
const command = event.target.value;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
config: { ...prev.config, command },
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
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.config.args.join('\n')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const args = event.target.value.split('\n').filter((arg) => arg.trim());
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
config: { ...prev.config, args },
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
placeholder="--port 3000"
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('mcpForm.fields.envVars')}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
|
|
||||||
onChange={(event) => {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
event.target.value.split('\n').forEach((line) => {
|
|
||||||
const [key, ...valueParts] = line.split('=');
|
|
||||||
if (key && valueParts.length > 0) {
|
|
||||||
env[key.trim()] = valueParts.join('=').trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
config: { ...prev.config, env },
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
placeholder="API_KEY=xxx DEBUG=true"
|
|
||||||
rows={3}
|
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t('mcpForm.actions.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting || !formData.name.trim() || !formData.config.command.trim()}
|
|
||||||
className="bg-green-600 text-white hover:bg-green-700"
|
|
||||||
>
|
|
||||||
{isSubmitting
|
|
||||||
? t('mcpForm.actions.saving')
|
|
||||||
: editingServer
|
|
||||||
? t('mcpForm.actions.updateServer')
|
|
||||||
: t('mcpForm.actions.addServer')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { AgentCategory, AgentProvider } from '../../../types/types';
|
import type { AgentCategory, AgentProvider } from '../../../types/types';
|
||||||
|
|
||||||
|
import type { AgentContext, AgentsSettingsTabProps } from './types';
|
||||||
import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
|
import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
|
||||||
import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
|
import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
|
||||||
import AgentSelectorSection from './sections/AgentSelectorSection';
|
import AgentSelectorSection from './sections/AgentSelectorSection';
|
||||||
import type { AgentContext, AgentsSettingsTabProps } from './types';
|
|
||||||
|
|
||||||
export default function AgentsSettingsTab({
|
export default function AgentsSettingsTab({
|
||||||
providerAuthStatus,
|
providerAuthStatus,
|
||||||
@@ -16,14 +18,7 @@ export default function AgentsSettingsTab({
|
|||||||
onCodexPermissionModeChange,
|
onCodexPermissionModeChange,
|
||||||
geminiPermissionMode,
|
geminiPermissionMode,
|
||||||
onGeminiPermissionModeChange,
|
onGeminiPermissionModeChange,
|
||||||
mcpServers,
|
projects,
|
||||||
cursorMcpServers,
|
|
||||||
codexMcpServers,
|
|
||||||
deleteError,
|
|
||||||
onOpenMcpForm,
|
|
||||||
onDeleteMcpServer,
|
|
||||||
onOpenCodexMcpForm,
|
|
||||||
onDeleteCodexMcpServer,
|
|
||||||
}: AgentsSettingsTabProps) {
|
}: AgentsSettingsTabProps) {
|
||||||
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
|
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
|
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
|
||||||
@@ -79,14 +74,7 @@ export default function AgentsSettingsTab({
|
|||||||
onCodexPermissionModeChange={onCodexPermissionModeChange}
|
onCodexPermissionModeChange={onCodexPermissionModeChange}
|
||||||
geminiPermissionMode={geminiPermissionMode}
|
geminiPermissionMode={geminiPermissionMode}
|
||||||
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
|
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
|
||||||
mcpServers={mcpServers}
|
projects={projects}
|
||||||
cursorMcpServers={cursorMcpServers}
|
|
||||||
codexMcpServers={codexMcpServers}
|
|
||||||
deleteError={deleteError}
|
|
||||||
onOpenMcpForm={onOpenMcpForm}
|
|
||||||
onDeleteMcpServer={onDeleteMcpServer}
|
|
||||||
onOpenCodexMcpForm={onOpenCodexMcpForm}
|
|
||||||
onDeleteCodexMcpServer={onDeleteCodexMcpServer}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentCategoryContentSectionProps } from '../types';
|
import type { AgentCategoryContentSectionProps } from '../types';
|
||||||
|
import { McpServers } from '../../../../../mcp';
|
||||||
|
|
||||||
import AccountContent from './content/AccountContent';
|
import AccountContent from './content/AccountContent';
|
||||||
import McpServersContent from './content/McpServersContent';
|
|
||||||
import PermissionsContent from './content/PermissionsContent';
|
import PermissionsContent from './content/PermissionsContent';
|
||||||
|
|
||||||
export default function AgentCategoryContentSection({
|
export default function AgentCategoryContentSection({
|
||||||
@@ -13,18 +14,8 @@ export default function AgentCategoryContentSection({
|
|||||||
onCursorPermissionsChange,
|
onCursorPermissionsChange,
|
||||||
codexPermissionMode,
|
codexPermissionMode,
|
||||||
onCodexPermissionModeChange,
|
onCodexPermissionModeChange,
|
||||||
mcpServers,
|
projects,
|
||||||
cursorMcpServers,
|
|
||||||
codexMcpServers,
|
|
||||||
deleteError,
|
|
||||||
onOpenMcpForm,
|
|
||||||
onDeleteMcpServer,
|
|
||||||
onOpenCodexMcpForm,
|
|
||||||
onDeleteCodexMcpServer,
|
|
||||||
}: AgentCategoryContentSectionProps) {
|
}: AgentCategoryContentSectionProps) {
|
||||||
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
|
|
||||||
const noopCursorMcpAction = () => {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto p-3 md:p-4">
|
<div className="flex-1 overflow-y-auto p-3 md:p-4">
|
||||||
{selectedCategory === 'account' && (
|
{selectedCategory === 'account' && (
|
||||||
@@ -79,35 +70,10 @@ export default function AgentCategoryContentSection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedCategory === 'mcp' && selectedAgent === 'claude' && (
|
{selectedCategory === 'mcp' && (
|
||||||
<McpServersContent
|
<McpServers
|
||||||
agent="claude"
|
selectedProvider={selectedAgent}
|
||||||
servers={mcpServers}
|
currentProjects={projects}
|
||||||
onAdd={() => onOpenMcpForm()}
|
|
||||||
onEdit={(server) => onOpenMcpForm(server)}
|
|
||||||
onDelete={onDeleteMcpServer}
|
|
||||||
deleteError={deleteError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
|
|
||||||
<McpServersContent
|
|
||||||
agent="cursor"
|
|
||||||
servers={cursorMcpServers}
|
|
||||||
onAdd={noopCursorMcpAction}
|
|
||||||
onEdit={noopCursorMcpAction}
|
|
||||||
onDelete={noopCursorMcpAction}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedCategory === 'mcp' && selectedAgent === 'codex' && (
|
|
||||||
<McpServersContent
|
|
||||||
agent="codex"
|
|
||||||
servers={codexMcpServers}
|
|
||||||
onAdd={() => onOpenCodexMcpForm()}
|
|
||||||
onEdit={(server) => onOpenCodexMcpForm(server)}
|
|
||||||
onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}
|
|
||||||
deleteError={deleteError}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,352 +0,0 @@
|
|||||||
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Badge, Button } from '../../../../../../../shared/view/ui';
|
|
||||||
import { IS_PLATFORM } from '../../../../../../../constants/config';
|
|
||||||
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
|
|
||||||
import type { McpServer } from '../../../../../types/types';
|
|
||||||
|
|
||||||
const getTransportIcon = (type: string | undefined) => {
|
|
||||||
if (type === 'stdio') {
|
|
||||||
return <Terminal className="h-4 w-4" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'sse') {
|
|
||||||
return <Zap className="h-4 w-4" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'http') {
|
|
||||||
return <Globe className="h-4 w-4" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Server className="h-4 w-4" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const maskSecret = (value: unknown): string => {
|
|
||||||
const normalizedValue = String(value ?? '');
|
|
||||||
if (normalizedValue.length <= 4) {
|
|
||||||
return '****';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ClaudeMcpServersProps = {
|
|
||||||
agent: 'claude';
|
|
||||||
servers: McpServer[];
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (server: McpServer) => void;
|
|
||||||
onDelete: (serverId: string, scope?: string) => void;
|
|
||||||
deleteError?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ClaudeMcpServers({
|
|
||||||
servers,
|
|
||||||
onAdd,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
deleteError,
|
|
||||||
}: Omit<ClaudeMcpServersProps, 'agent'>) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
|
|
||||||
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">{t('mcpServers.description.claude')}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('mcpServers.addButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{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}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{servers.map((server) => {
|
|
||||||
const serverId = server.id || server.name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
{getTransportIcon(server.type)}
|
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{server.type || 'stdio'}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{server.scope === 'local'
|
|
||||||
? t('mcpServers.scope.local')
|
|
||||||
: server.scope === 'user'
|
|
||||||
? t('mcpServers.scope.user')
|
|
||||||
: server.scope}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
|
||||||
{server.type === 'stdio' && server.config?.command && (
|
|
||||||
<div>
|
|
||||||
{t('mcpServers.config.command')}:{' '}
|
|
||||||
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
|
||||||
<div>
|
|
||||||
{t('mcpServers.config.url')}:{' '}
|
|
||||||
<code className="rounded bg-muted px-1 text-xs">{server.config.url}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{server.config?.args && server.config.args.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{t('mcpServers.config.args')}:{' '}
|
|
||||||
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-4 flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => onEdit(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={() => onDelete(serverId, server.scope)}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{servers.length === 0 && (
|
|
||||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!IS_PLATFORM && (
|
|
||||||
<PremiumFeatureCard
|
|
||||||
icon={<Users className="h-5 w-5" />}
|
|
||||||
title="Team MCP Configs"
|
|
||||||
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CursorMcpServersProps = {
|
|
||||||
agent: 'cursor';
|
|
||||||
servers: McpServer[];
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (server: McpServer) => void;
|
|
||||||
onDelete: (serverId: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpServersProps, 'agent'>) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
|
|
||||||
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">{t('mcpServers.description.cursor')}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('mcpServers.addButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{servers.map((server) => {
|
|
||||||
const serverId = server.id || server.name;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{server.config?.command && (
|
|
||||||
<div>
|
|
||||||
{t('mcpServers.config.command')}:{' '}
|
|
||||||
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => onEdit(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={() => onDelete(serverId)}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{servers.length === 0 && (
|
|
||||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type CodexMcpServersProps = {
|
|
||||||
agent: 'codex';
|
|
||||||
servers: McpServer[];
|
|
||||||
onAdd: () => void;
|
|
||||||
onEdit: (server: McpServer) => void;
|
|
||||||
onDelete: (serverId: string) => void;
|
|
||||||
deleteError?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit<CodexMcpServersProps, 'agent'>) {
|
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Server className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Button onClick={onAdd} className="bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600" size="sm">
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
{t('mcpServers.addButton')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{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}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{servers.map((server) => (
|
|
||||||
<div key={server.name} className="rounded-lg border border-border bg-card/50 p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
<span className="font-medium text-foreground">{server.name}</span>
|
|
||||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-muted-foreground">
|
|
||||||
{server.config?.command && (
|
|
||||||
<div>
|
|
||||||
{t('mcpServers.config.command')}:{' '}
|
|
||||||
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{server.config?.args && server.config.args.length > 0 && (
|
|
||||||
<div>
|
|
||||||
{t('mcpServers.config.args')}:{' '}
|
|
||||||
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
|
||||||
<div>
|
|
||||||
{t('mcpServers.config.environment')}:{' '}
|
|
||||||
<code className="rounded bg-muted px-1 text-xs">
|
|
||||||
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-4 flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => onEdit(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={() => onDelete(server.name)}
|
|
||||||
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>
|
|
||||||
))}
|
|
||||||
{servers.length === 0 && (
|
|
||||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;
|
|
||||||
|
|
||||||
export default function McpServersContent(props: McpServersContentProps) {
|
|
||||||
if (props.agent === 'claude') {
|
|
||||||
return <ClaudeMcpServers {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.agent === 'cursor') {
|
|
||||||
return <CursorMcpServers {...props} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <CodexMcpServers {...props} />;
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
CursorPermissionsState,
|
CursorPermissionsState,
|
||||||
CodexPermissionMode,
|
CodexPermissionMode,
|
||||||
GeminiPermissionMode,
|
GeminiPermissionMode,
|
||||||
McpServer,
|
SettingsProject,
|
||||||
} from '../../../types/types';
|
} from '../../../types/types';
|
||||||
|
|
||||||
export type AgentContext = {
|
export type AgentContext = {
|
||||||
@@ -28,14 +28,7 @@ export type AgentsSettingsTabProps = {
|
|||||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||||
geminiPermissionMode: GeminiPermissionMode;
|
geminiPermissionMode: GeminiPermissionMode;
|
||||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||||
mcpServers: McpServer[];
|
projects: SettingsProject[];
|
||||||
cursorMcpServers: McpServer[];
|
|
||||||
codexMcpServers: McpServer[];
|
|
||||||
deleteError: string | null;
|
|
||||||
onOpenMcpForm: (server?: McpServer) => void;
|
|
||||||
onDeleteMcpServer: (serverId: string, scope?: string) => void;
|
|
||||||
onOpenCodexMcpForm: (server?: McpServer) => void;
|
|
||||||
onDeleteCodexMcpServer: (serverId: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentCategoryTabsSectionProps = {
|
export type AgentCategoryTabsSectionProps = {
|
||||||
@@ -61,12 +54,5 @@ export type AgentCategoryContentSectionProps = {
|
|||||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||||
geminiPermissionMode: GeminiPermissionMode;
|
geminiPermissionMode: GeminiPermissionMode;
|
||||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||||
mcpServers: McpServer[];
|
projects: SettingsProject[];
|
||||||
cursorMcpServers: McpServer[];
|
|
||||||
codexMcpServers: McpServer[];
|
|
||||||
deleteError: string | null;
|
|
||||||
onOpenMcpForm: (server?: McpServer) => void;
|
|
||||||
onDeleteMcpServer: (serverId: string, scope?: string) => void;
|
|
||||||
onOpenCodexMcpForm: (server?: McpServer) => void;
|
|
||||||
onDeleteCodexMcpServer: (serverId: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user