mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-30 01:11:40 +00:00
Refactor provider runtimes for sessions, auth, and MCP management (#666)
* feat: implement MCP provider registry and service
- Add provider registry to manage LLM providers (Claude, Codex, Cursor, Gemini).
- Create provider routes for MCP server operations (list, upsert, delete, run).
- Implement MCP service for handling server operations and validations.
- Introduce abstract provider class and MCP provider base for shared functionality.
- Add tests for MCP server operations across different providers and scopes.
- Define shared interfaces and types for MCP functionality.
- Implement utility functions for handling JSON config files and API responses.
* chore: remove dead code related to MCP server
* refactor: put /api/providers in index.js and remove /providers prefix from provider.routes.ts
* 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`
* fix(mcp): form with multiline text handling for args, env, headers, and envVars
* feat(mcp): add global MCP server creation flow
Add a separate global MCP add path in the settings MCP module so users can create
one shared MCP server configuration across Claude, Cursor, Codex, and Gemini from
the same screen.
The provider-specific add flow is still kept next to it because these two actions
have different intent. A global MCP server must be constrained to the subset of
configuration that every provider can accept, while a provider-specific server can
still use that provider's own supported scopes, transports, and fields. Naming the
buttons as "Add Global MCP Server" and "Add <Provider> MCP Server" makes that
distinction explicit without forcing users to infer it from the selected tab.
This also moves the explanatory copy to button hover text to keep the MCP toolbar
compact while still documenting the difference between global and provider-only
adds at the point of action.
Implementation details:
- Add global MCP form mode with shared user/project scopes and stdio/http transports.
- Submit global creates through `/api/providers/mcp/servers/global`.
- Reuse the existing MCP form modal with configurable scopes, transports, labels,
and descriptions instead of duplicating form logic.
- Disable provider-only fields for the global flow because those fields cannot be
safely written to every provider.
- Clear the MCP server cache globally after a global add because every provider tab
may have changed.
- Surface partial global add failures with provider-specific error messages.
Validation:
- npx eslint src/components/mcp/view/McpServers.tsx
- npm run typecheck
- npm run build:client
* feat: implement platform-specific provider visibility for cursor agent
* refactor(providers): centralize message handling in provider module
Move provider-specific normalizeMessage and fetchHistory logic out of the legacy
server/providers adapters and into the refactored provider classes so callers can
depend on the main provider contract instead of parallel adapter plumbing.
Add a providers service to resolve concrete providers through the registry and
delegate message normalization/history loading from realtime handlers and the
unified messages route. Add shared TypeScript message/history types and normalized
message helpers so provider implementations and callers use the same contract.
Remove the old adapter registry/files now that Claude, Codex, Cursor, and Gemini
implement the required behavior directly.
* refactor(providers): move auth status checks into provider runtimes
Move provider authentication status logic out of the CLI auth route so auth checks
live with the provider implementations that understand each provider's install
and credential model.
Add provider-specific auth runtime classes for Claude, Codex, Cursor, and Gemini,
and expose them through the shared provider contract as `provider.auth`. Add a
provider auth service that resolves providers through the registry and delegates
status checks via `auth.getStatus()`.
Keep the existing `/api/cli/<provider>/status` endpoints, but make them thin route
adapters over the new provider auth service. This removes duplicated route-local
credential parsing and makes auth status a first-class provider capability beside
MCP and message handling.
* refactor(providers): clarify provider auth and MCP naming
Rename provider auth/MCP contracts to remove the overloaded Runtime suffix so
the shared interfaces read as stable provider capabilities instead of execution
implementation details.
Add a consistent provider-first auth class naming convention by renaming
ClaudeAuthProvider, CodexAuthProvider, CursorAuthProvider, and GeminiAuthProvider
to ClaudeProviderAuth, CodexProviderAuth, CursorProviderAuth, and
GeminiProviderAuth.
This keeps the provider module API easier to scan and aligns auth naming with
the main provider ownership model.
* refactor(providers): move session message delegation into sessions service
Move provider-backed session history and message normalization calls out of the
generic providers service so the service name reflects the behavior it owns.
Add a dedicated sessions service for listing session-capable providers,
normalizing live provider events, and fetching persisted session history through
the provider registry. Update realtime handlers and the unified messages route to
depend on `sessionsService` instead of `providersService`.
This separates session message operations from other provider concerns such as
auth and MCP, keeping the provider services easier to navigate as the module
grows.
* refactor(providers): move auth status routes under provider API
Move provider authentication status endpoints out of the legacy `/api/cli` route
namespace so auth status is exposed through the same provider module that owns
provider auth and MCP behavior.
Add `GET /api/providers/:provider/auth/status` to the provider router and route
it through the provider auth service. Remove the old `cli-auth` route file and
`/api/cli` mount now that provider auth status is handled by the unified provider
API.
Update the frontend provider auth endpoint map to call the new provider-scoped
routes and rename the endpoint constant to reflect that it is no longer CLI
specific.
* chore(api): remove unused backend endpoints after MCP audit
Remove legacy backend routes that no longer have frontend or internal
callers, including the old Claude/Codex MCP APIs, unused Cursor and Codex
helper endpoints, stale TaskMaster detection/next/initialize routes,
and unused command/project helpers.
This reduces duplicated MCP behavior now handled by the provider-based
MCP API, shrinks the exposed backend surface, and removes probe/service
code that only existed for deleted endpoints.
Add an MCP settings API audit document to capture the route-usage
analysis and explain why the legacy MCP endpoints were considered safe
to remove.
* refactor(providers): remove debug logging from Claude authentication status checks
* refactor(cursor): lazy-load better-sqlite3 and remove unused type definitions
* refactor(cursor): remove SSE from CursorMcpProvider constructor and error message
* refactor(auth): standardize API response structure and remove unused error handling
* refactor: make providers use dedicated session handling classes
* refactor: remove legacy provider selection UI and logic
* fix(server/providers): harden and correct session history normalization/pagination
Address correctness and safety issues in provider session adapters while
preserving existing normalized message shapes.
Claude sessions:
- Ensure user text content parts generate unique normalized message ids.
- Replace duplicate `${baseId}_text` ids with index-suffixed ids to avoid
collisions when one user message contains multiple text segments.
Cursor sessions:
- Add session id sanitization before constructing SQLite paths to prevent
path traversal via crafted session ids.
- Enforce containment by resolving the computed DB path and asserting it stays
under ~/.cursor/chats/<cwdId>.
- Refactor blob parsing to a two-pass flow: first build blobMap and collect
JSON blobs, then parse binary parent refs against the fully populated map.
- Fix pagination semantics so limit=0 returns an empty page instead of full
history, with consistent total/hasMore/offset/limit metadata.
Gemini sessions:
- Honor FetchHistoryOptions pagination by reading limit/offset and slicing
normalized history accordingly.
- Return consistent hasMore/offset/limit metadata for paged responses.
Validation:
- eslint passed for touched files.
- server TypeScript check passed (tsc --noEmit -p server/tsconfig.json).
---------
This commit is contained in:
@@ -12,7 +12,7 @@ import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText }
|
||||
* that the existing UI components expect.
|
||||
*
|
||||
* Internal/system content (e.g. <system-reminder>, <command-name>) is already
|
||||
* filtered server-side by the Claude adapter (server/providers/utils.js).
|
||||
* filtered server-side by the Claude provider module.
|
||||
*/
|
||||
export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] {
|
||||
const converted: ChatMessage[] = [];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
|
||||
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
@@ -45,11 +46,11 @@ type ProviderSelectionEmptyStateProps = {
|
||||
setInput: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
|
||||
interface ProviderGroup {
|
||||
type ProviderGroup = {
|
||||
id: LLMProvider;
|
||||
name: string;
|
||||
models: { value: string; label: string }[];
|
||||
}
|
||||
};
|
||||
|
||||
const PROVIDER_GROUPS: ProviderGroup[] = [
|
||||
{ id: "claude", name: "Anthropic", models: CLAUDE_MODELS.OPTIONS },
|
||||
@@ -105,7 +106,21 @@ export default function ProviderSelectionEmptyState({
|
||||
setInput,
|
||||
}: ProviderSelectionEmptyStateProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const visibleProviderGroups = useMemo(
|
||||
() => (isWindowsServer ? PROVIDER_GROUPS.filter((p) => p.id !== "cursor") : PROVIDER_GROUPS),
|
||||
[isWindowsServer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && provider === "cursor") {
|
||||
setProvider("claude");
|
||||
localStorage.setItem("selected-provider", "claude");
|
||||
}
|
||||
}, [isWindowsServer, provider, setProvider]);
|
||||
|
||||
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
|
||||
defaultValue: "Start the next task",
|
||||
});
|
||||
@@ -126,13 +141,8 @@ export default function ProviderSelectionEmptyState({
|
||||
return found?.label || currentModel;
|
||||
}, [provider, currentModel]);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
const setModelForProvider = useCallback(
|
||||
(providerId: LLMProvider, modelValue: string) => {
|
||||
// Set provider
|
||||
setProvider(providerId);
|
||||
localStorage.setItem("selected-provider", providerId);
|
||||
|
||||
// Set model for the correct provider
|
||||
if (providerId === "claude") {
|
||||
setClaudeModel(modelValue);
|
||||
localStorage.setItem("claude-model", modelValue);
|
||||
@@ -146,19 +156,25 @@ export default function ProviderSelectionEmptyState({
|
||||
setCursorModel(modelValue);
|
||||
localStorage.setItem("cursor-model", modelValue);
|
||||
}
|
||||
},
|
||||
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel],
|
||||
);
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(providerId: LLMProvider, modelValue: string) => {
|
||||
setProvider(providerId);
|
||||
localStorage.setItem("selected-provider", providerId);
|
||||
setModelForProvider(providerId, modelValue);
|
||||
setDialogOpen(false);
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
},
|
||||
[setProvider, setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, textareaRef],
|
||||
[setProvider, setModelForProvider, textareaRef],
|
||||
);
|
||||
|
||||
/* ── New session — provider + model picker ── */
|
||||
if (!selectedSession && !currentSessionId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Heading */}
|
||||
<div className="mb-8 text-center">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||
{t("providerSelection.title")}
|
||||
@@ -168,7 +184,6 @@ export default function ProviderSelectionEmptyState({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Model selector trigger — hero card style */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Card
|
||||
@@ -205,12 +220,18 @@ export default function ProviderSelectionEmptyState({
|
||||
<DialogContent className="max-w-md overflow-hidden p-0">
|
||||
<DialogTitle>Model Selector</DialogTitle>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("providerSelection.searchModels", { defaultValue: "Search models..." })} />
|
||||
<CommandInput
|
||||
placeholder={t("providerSelection.searchModels", {
|
||||
defaultValue: "Search models...",
|
||||
})}
|
||||
/>
|
||||
<CommandList className="max-h-[350px]">
|
||||
<CommandEmpty>
|
||||
{t("providerSelection.noModelsFound", { defaultValue: "No models found." })}
|
||||
{t("providerSelection.noModelsFound", {
|
||||
defaultValue: "No models found.",
|
||||
})}
|
||||
</CommandEmpty>
|
||||
{PROVIDER_GROUPS.map((group) => (
|
||||
{visibleProviderGroups.map((group) => (
|
||||
<CommandGroup
|
||||
key={group.id}
|
||||
heading={
|
||||
@@ -221,8 +242,7 @@ export default function ProviderSelectionEmptyState({
|
||||
}
|
||||
>
|
||||
{group.models.map((model) => {
|
||||
const isSelected =
|
||||
provider === group.id && currentModel === model.value;
|
||||
const isSelected = provider === group.id && currentModel === model.value;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${group.id}-${model.value}`}
|
||||
@@ -243,7 +263,6 @@ export default function ProviderSelectionEmptyState({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Ready prompt */}
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground/70">
|
||||
{
|
||||
{
|
||||
@@ -263,7 +282,6 @@ export default function ProviderSelectionEmptyState({
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Task banner */}
|
||||
{provider && tasksEnabled && isTaskMasterInstalled && (
|
||||
<div className="mt-5">
|
||||
<NextTaskBanner
|
||||
@@ -277,7 +295,6 @@ export default function ProviderSelectionEmptyState({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Existing session — continue prompt ── */
|
||||
if (selectedSession) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
||||
58
src/components/mcp/constants.ts
Normal file
58
src/components/mcp/constants.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
||||
|
||||
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
||||
|
||||
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: '',
|
||||
};
|
||||
248
src/components/mcp/hooks/useMcpServerForm.ts
Normal file
248
src/components/mcp/hooks/useMcpServerForm.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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 {
|
||||
formatKeyValueLines,
|
||||
getErrorMessage,
|
||||
getProjectPath,
|
||||
isMcpTransport,
|
||||
parseKeyValueLines,
|
||||
parseListLines,
|
||||
} from '../utils/mcpFormatting';
|
||||
|
||||
type UseMcpServerFormArgs = {
|
||||
provider: McpProvider;
|
||||
isOpen: boolean;
|
||||
editingServer: ProviderMcpServer | null;
|
||||
currentProjects: McpProject[];
|
||||
supportedScopes?: McpScope[];
|
||||
supportedTransports?: McpTransport[];
|
||||
unsupportedTransportMessage?: (transport: McpTransport) => string;
|
||||
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
|
||||
};
|
||||
|
||||
type MultilineFieldText = {
|
||||
args: string;
|
||||
env: string;
|
||||
headers: string;
|
||||
envVars: string;
|
||||
envHttpHeaders: string;
|
||||
};
|
||||
|
||||
const cloneDefaultForm = (
|
||||
provider: McpProvider,
|
||||
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
|
||||
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
|
||||
): McpFormState => ({
|
||||
...DEFAULT_MCP_FORM,
|
||||
scope: supportedScopes[0],
|
||||
transport: supportedTransports[0],
|
||||
args: [],
|
||||
env: {},
|
||||
headers: {},
|
||||
envVars: [],
|
||||
envHttpHeaders: {},
|
||||
});
|
||||
|
||||
const createFormStateFromServer = (
|
||||
provider: McpProvider,
|
||||
server: ProviderMcpServer,
|
||||
supportedScopes?: McpScope[],
|
||||
supportedTransports?: McpTransport[],
|
||||
): McpFormState => ({
|
||||
...cloneDefaultForm(provider, supportedScopes, supportedTransports),
|
||||
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 createMultilineTextFromForm = (formData: McpFormState): MultilineFieldText => ({
|
||||
args: formData.args.join('\n'),
|
||||
env: formatKeyValueLines(formData.env),
|
||||
headers: formatKeyValueLines(formData.headers),
|
||||
envVars: formData.envVars.join('\n'),
|
||||
envHttpHeaders: formatKeyValueLines(formData.envHttpHeaders),
|
||||
});
|
||||
|
||||
const normalizeScope = (supportedScopes: McpScope[], value: McpScope): McpScope => (
|
||||
supportedScopes.includes(value) ? value : supportedScopes[0]
|
||||
);
|
||||
|
||||
const normalizeTransport = (supportedTransports: McpTransport[], value: McpTransport): McpTransport => (
|
||||
supportedTransports.includes(value) ? value : supportedTransports[0]
|
||||
);
|
||||
|
||||
export function useMcpServerForm({
|
||||
provider,
|
||||
isOpen,
|
||||
editingServer,
|
||||
currentProjects,
|
||||
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
|
||||
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
|
||||
unsupportedTransportMessage,
|
||||
onSubmit,
|
||||
}: UseMcpServerFormArgs) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [formData, setFormData] = useState<McpFormState>(() => (
|
||||
cloneDefaultForm(provider, supportedScopes, supportedTransports)
|
||||
));
|
||||
const [multilineText, setMultilineText] = useState<MultilineFieldText>(() => (
|
||||
createMultilineTextFromForm(cloneDefaultForm(provider, supportedScopes, supportedTransports))
|
||||
));
|
||||
const [jsonValidationError, setJsonValidationError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isEditing = Boolean(editingServer);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setJsonValidationError('');
|
||||
if (editingServer) {
|
||||
const nextFormData = createFormStateFromServer(provider, editingServer, supportedScopes, supportedTransports);
|
||||
setFormData(nextFormData);
|
||||
setMultilineText(createMultilineTextFromForm(nextFormData));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFormData = cloneDefaultForm(provider, supportedScopes, supportedTransports);
|
||||
setFormData(nextFormData);
|
||||
setMultilineText(createMultilineTextFromForm(nextFormData));
|
||||
}, [editingServer, isOpen, provider, supportedScopes, supportedTransports]);
|
||||
|
||||
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(supportedScopes, scope),
|
||||
workspacePath: scope === 'user' ? '' : prev.workspacePath,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateTransport = (transport: McpTransport) => {
|
||||
setFormData((prev) => ({ ...prev, transport: normalizeTransport(supportedTransports, 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 (!supportedTransports.includes(transportInput)) {
|
||||
setJsonValidationError(
|
||||
unsupportedTransportMessage?.(transportInput) ?? `${provider} does not support ${transportInput} MCP servers`,
|
||||
);
|
||||
} else if (transportInput === 'stdio' && !parsed.command) {
|
||||
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
|
||||
} else if ((transportInput === 'http' || transportInput === 'sse') && !parsed.url) {
|
||||
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 updateMultilineText = <K extends keyof MultilineFieldText>(key: K, value: MultilineFieldText[K]) => {
|
||||
setMultilineText((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const createSubmitFormData = (): McpFormState => ({
|
||||
...formData,
|
||||
args: parseListLines(multilineText.args),
|
||||
env: parseKeyValueLines(multilineText.env),
|
||||
headers: parseKeyValueLines(multilineText.headers),
|
||||
envVars: parseListLines(multilineText.envVars),
|
||||
envHttpHeaders: parseKeyValueLines(multilineText.envHttpHeaders),
|
||||
});
|
||||
|
||||
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 {
|
||||
// Textareas keep raw strings while editing so users can create blank
|
||||
// lines or partial KEY=value entries without the form rewriting them.
|
||||
await onSubmit(createSubmitFormData(), editingServer);
|
||||
} catch (error) {
|
||||
alert(`Error: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
formData,
|
||||
setFormData,
|
||||
multilineText,
|
||||
projectOptions,
|
||||
isEditing,
|
||||
isSubmitting,
|
||||
jsonValidationError,
|
||||
canSubmit,
|
||||
updateForm,
|
||||
updateScope,
|
||||
updateTransport,
|
||||
updateJsonInput,
|
||||
updateMultilineText,
|
||||
handleSubmit,
|
||||
};
|
||||
}
|
||||
535
src/components/mcp/hooks/useMcpServers.ts
Normal file
535
src/components/mcp/hooks/useMcpServers.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { MCP_GLOBAL_SUPPORTED_TRANSPORTS, MCP_PROVIDER_NAMES, MCP_SUPPORTED_SCOPES } from '../constants';
|
||||
import type {
|
||||
ApiResponse,
|
||||
GlobalMcpServerResult,
|
||||
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 GlobalMcpServerResponse = {
|
||||
results: GlobalMcpServerResult[];
|
||||
};
|
||||
|
||||
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 saveGlobalServer = async (
|
||||
payload: UpsertProviderMcpServerPayload,
|
||||
): Promise<GlobalMcpServerResult[]> => {
|
||||
const response = await authenticatedFetch('/api/providers/mcp/servers/global', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await toResponseJson<ApiResponse<GlobalMcpServerResponse>>(response);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server to all providers'));
|
||||
}
|
||||
|
||||
return data.data.results || [];
|
||||
};
|
||||
|
||||
const didServerIdentityChange = (
|
||||
editingServer: ProviderMcpServer,
|
||||
payload: UpsertProviderMcpServerPayload,
|
||||
): 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 formatGlobalAddFailures = (failures: GlobalMcpServerResult[]): string => (
|
||||
failures
|
||||
.map((failure) => `${MCP_PROVIDER_NAMES[failure.provider]}: ${failure.error || 'Unknown error'}`)
|
||||
.join('; ')
|
||||
);
|
||||
|
||||
const sortServers = (servers: ProviderMcpServer[]): ProviderMcpServer[] => {
|
||||
const scopeOrder: Record<McpScope, number> = {
|
||||
user: 0,
|
||||
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 [isGlobalFormOpen, setIsGlobalFormOpen] = 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 openGlobalForm = useCallback(() => {
|
||||
setIsGlobalFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeGlobalForm = useCallback(() => {
|
||||
setIsGlobalFormOpen(false);
|
||||
}, []);
|
||||
|
||||
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 submitGlobalForm = useCallback(
|
||||
async (formData: McpFormState) => {
|
||||
const payload = createMcpPayloadFromForm(selectedProvider, formData, {
|
||||
supportedTransports: MCP_GLOBAL_SUPPORTED_TRANSPORTS,
|
||||
supportsWorkingDirectory: false,
|
||||
includeProviderSpecificFields: false,
|
||||
unsupportedTransportMessage: (transport) =>
|
||||
`Add MCP Server supports only stdio and http across all providers, not ${transport}.`,
|
||||
});
|
||||
|
||||
if (payload.scope === 'local') {
|
||||
throw new Error('Add MCP Server supports only user or project scope across all providers.');
|
||||
}
|
||||
|
||||
if (payload.scope !== 'user' && !payload.workspacePath) {
|
||||
throw new Error('Select a project for project-scoped MCP servers');
|
||||
}
|
||||
|
||||
// The global endpoint updates every provider, so clear every provider
|
||||
// cache entry instead of only the currently visible provider tab.
|
||||
const results = await saveGlobalServer(payload);
|
||||
mcpServersCache.clear();
|
||||
await refreshServers({ force: true });
|
||||
|
||||
const failures = results.filter((result) => !result.created);
|
||||
if (failures.length > 0) {
|
||||
setSaveStatus('error');
|
||||
throw new Error(`Failed to add MCP server to all providers. ${formatGlobalAddFailures(failures)}`);
|
||||
}
|
||||
|
||||
setSaveStatus('success');
|
||||
closeGlobalForm();
|
||||
},
|
||||
[closeGlobalForm, refreshServers, selectedProvider],
|
||||
);
|
||||
|
||||
const deleteServer = useCallback(
|
||||
async (server: ProviderMcpServer) => {
|
||||
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
||||
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);
|
||||
setIsGlobalFormOpen(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,
|
||||
isGlobalFormOpen,
|
||||
editingServer,
|
||||
openForm,
|
||||
openGlobalForm,
|
||||
closeForm,
|
||||
closeGlobalForm,
|
||||
submitForm,
|
||||
submitGlobalForm,
|
||||
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';
|
||||
90
src/components/mcp/types.ts
Normal file
90
src/components/mcp/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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 McpFormMode = 'provider' | 'global';
|
||||
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 GlobalMcpServerResult = {
|
||||
provider: McpProvider;
|
||||
created: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
184
src/components/mcp/utils/mcpFormatting.ts
Normal file
184
src/components/mcp/utils/mcpFormatting.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { MCP_SUPPORTED_TRANSPORTS, MCP_SUPPORTS_WORKING_DIRECTORY } from '../constants';
|
||||
import type {
|
||||
KeyValueMap,
|
||||
McpFormState,
|
||||
McpProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
UpsertProviderMcpServerPayload,
|
||||
} from '../types';
|
||||
|
||||
type CreateMcpPayloadOptions = {
|
||||
supportedTransports?: McpTransport[];
|
||||
supportsWorkingDirectory?: boolean;
|
||||
includeProviderSpecificFields?: boolean;
|
||||
unsupportedTransportMessage?: (transport: McpTransport) => string;
|
||||
};
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
const assertSupportedTransport = (
|
||||
provider: McpProvider,
|
||||
transport: McpTransport,
|
||||
options?: CreateMcpPayloadOptions,
|
||||
) => {
|
||||
const supportedTransports = options?.supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
|
||||
if (supportedTransports.includes(transport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
options?.unsupportedTransportMessage?.(transport) ?? `${provider} does not support ${transport} MCP servers`,
|
||||
);
|
||||
};
|
||||
|
||||
export const parseJsonMcpPayload = (
|
||||
provider: McpProvider,
|
||||
formData: McpFormState,
|
||||
options?: CreateMcpPayloadOptions,
|
||||
): UpsertProviderMcpServerPayload => {
|
||||
const parsed = JSON.parse(formData.jsonInput) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
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');
|
||||
}
|
||||
|
||||
assertSupportedTransport(provider, transport, options);
|
||||
|
||||
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: (options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider])
|
||||
? readString(parsed.cwd)
|
||||
: undefined,
|
||||
url: readString(parsed.url),
|
||||
headers: readStringRecord(parsed.headers ?? parsed.http_headers) ?? {},
|
||||
envVars: (options?.includeProviderSpecificFields ?? provider === 'codex')
|
||||
? readStringArray(parsed.envVars ?? parsed.env_vars) ?? []
|
||||
: undefined,
|
||||
bearerTokenEnvVar: (options?.includeProviderSpecificFields ?? provider === 'codex')
|
||||
? readString(parsed.bearerTokenEnvVar ?? parsed.bearer_token_env_var)
|
||||
: undefined,
|
||||
envHttpHeaders: (options?.includeProviderSpecificFields ?? provider === 'codex')
|
||||
? readStringRecord(parsed.envHttpHeaders ?? parsed.env_http_headers) ?? {}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMcpPayloadFromForm = (
|
||||
provider: McpProvider,
|
||||
formData: McpFormState,
|
||||
options?: CreateMcpPayloadOptions,
|
||||
): UpsertProviderMcpServerPayload => {
|
||||
if (formData.importMode === 'json') {
|
||||
return parseJsonMcpPayload(provider, formData, options);
|
||||
}
|
||||
|
||||
assertSupportedTransport(provider, formData.transport, options);
|
||||
|
||||
const supportsWorkingDirectory = options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
||||
const includeProviderSpecificFields = options?.includeProviderSpecificFields ?? provider === 'codex';
|
||||
|
||||
return {
|
||||
name: formData.name.trim(),
|
||||
scope: formData.scope,
|
||||
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: supportsWorkingDirectory ? formData.cwd.trim() || undefined : undefined,
|
||||
url: formData.transport !== 'stdio' ? formData.url.trim() : undefined,
|
||||
headers: formData.transport !== 'stdio' ? formData.headers : undefined,
|
||||
envVars: includeProviderSpecificFields ? formData.envVars : undefined,
|
||||
bearerTokenEnvVar: includeProviderSpecificFields ? formData.bearerTokenEnvVar.trim() || undefined : undefined,
|
||||
envHttpHeaders: includeProviderSpecificFields ? formData.envHttpHeaders : undefined,
|
||||
};
|
||||
};
|
||||
281
src/components/mcp/view/McpServers.tsx
Normal file
281
src/components/mcp/view/McpServers.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
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_GLOBAL_SUPPORTED_SCOPES,
|
||||
MCP_GLOBAL_SUPPORTED_TRANSPORTS,
|
||||
MCP_PROVIDER_BUTTON_CLASSES,
|
||||
MCP_PROVIDER_NAMES,
|
||||
} from '../constants';
|
||||
import { useMcpServers } from '../hooks/useMcpServers';
|
||||
import { maskSecret } from '../utils/mcpFormatting';
|
||||
|
||||
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,
|
||||
isGlobalFormOpen,
|
||||
editingServer,
|
||||
openForm,
|
||||
openGlobalForm,
|
||||
closeForm,
|
||||
closeGlobalForm,
|
||||
submitForm,
|
||||
submitGlobalForm,
|
||||
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}`,
|
||||
});
|
||||
const globalButtonLabel = 'Add Global MCP Server';
|
||||
const providerButtonLabel = `Add ${providerName} MCP Server`;
|
||||
const globalAddDescription = 'Add Global MCP Server writes one common stdio or HTTP server to Claude, Cursor, Codex, and Gemini.';
|
||||
const providerAddDescription = `${providerButtonLabel} only changes ${providerName}.`;
|
||||
const globalModalDescription = 'Adds this MCP server to every provider: Claude, Cursor, Codex, and Gemini. '
|
||||
+ 'Only stdio and HTTP transports are supported because the same config must work across all providers.';
|
||||
|
||||
return (
|
||||
<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="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={openGlobalForm}
|
||||
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
|
||||
size="sm"
|
||||
title={globalAddDescription}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{globalButtonLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openForm()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={providerAddDescription}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{providerButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-4">
|
||||
{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>
|
||||
</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}
|
||||
title={editingServer ? undefined : providerButtonLabel}
|
||||
submitLabel={providerButtonLabel}
|
||||
onClose={closeForm}
|
||||
onSubmit={submitForm}
|
||||
/>
|
||||
|
||||
<McpServerFormModal
|
||||
provider={selectedProvider}
|
||||
mode="global"
|
||||
isOpen={isGlobalFormOpen}
|
||||
editingServer={null}
|
||||
currentProjects={currentProjects}
|
||||
title={globalButtonLabel}
|
||||
description={globalModalDescription}
|
||||
submitLabel={globalButtonLabel}
|
||||
supportedScopes={MCP_GLOBAL_SUPPORTED_SCOPES}
|
||||
supportedTransports={MCP_GLOBAL_SUPPORTED_TRANSPORTS}
|
||||
onClose={closeGlobalForm}
|
||||
onSubmit={(formData) => submitGlobalForm(formData)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
434
src/components/mcp/view/modals/McpServerFormModal.tsx
Normal file
434
src/components/mcp/view/modals/McpServerFormModal.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
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 type {
|
||||
McpFormMode,
|
||||
McpFormState,
|
||||
McpProject,
|
||||
McpProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderMcpServer,
|
||||
} from '../../types';
|
||||
|
||||
type McpServerFormModalProps = {
|
||||
provider: McpProvider;
|
||||
mode?: McpFormMode;
|
||||
isOpen: boolean;
|
||||
editingServer: ProviderMcpServer | null;
|
||||
currentProjects: McpProject[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
submitLabel?: string;
|
||||
supportedScopes?: McpScope[];
|
||||
supportedTransports?: McpTransport[];
|
||||
onClose: () => void;
|
||||
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
|
||||
};
|
||||
|
||||
const getScopeLabel = (scope: McpScope, mode: McpFormMode): string => {
|
||||
if (scope === 'user') {
|
||||
return mode === 'global' ? 'User (All Providers)' : 'User (Global)';
|
||||
}
|
||||
|
||||
if (scope === 'local') {
|
||||
return 'Claude Local';
|
||||
}
|
||||
|
||||
return mode === 'global' ? 'Project (All Providers)' : 'Project';
|
||||
};
|
||||
|
||||
const getScopeDescription = (scope: McpScope, mode: McpFormMode): string => {
|
||||
if (scope === 'user') {
|
||||
return mode === 'global'
|
||||
? 'Writes to each provider user config and is available across projects on this machine'
|
||||
: 'Available across all projects on your machine';
|
||||
}
|
||||
|
||||
if (scope === 'local') {
|
||||
return 'Stored in Claude user settings for the selected project';
|
||||
}
|
||||
|
||||
return mode === 'global'
|
||||
? 'Writes to the selected project workspace for every provider'
|
||||
: 'Stored in the selected project workspace';
|
||||
};
|
||||
|
||||
export default function McpServerFormModal({
|
||||
provider,
|
||||
mode = 'provider',
|
||||
isOpen,
|
||||
editingServer,
|
||||
currentProjects,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
supportedScopes,
|
||||
supportedTransports,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: McpServerFormModalProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const isGlobalMode = mode === 'global';
|
||||
const availableScopes = supportedScopes ?? MCP_SUPPORTED_SCOPES[provider];
|
||||
const availableTransports = supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
|
||||
const {
|
||||
formData,
|
||||
multilineText,
|
||||
projectOptions,
|
||||
isEditing,
|
||||
isSubmitting,
|
||||
jsonValidationError,
|
||||
canSubmit,
|
||||
updateForm,
|
||||
updateScope,
|
||||
updateTransport,
|
||||
updateJsonInput,
|
||||
updateMultilineText,
|
||||
handleSubmit,
|
||||
} = useMcpServerForm({
|
||||
provider,
|
||||
isOpen,
|
||||
editingServer,
|
||||
currentProjects,
|
||||
supportedScopes: availableScopes,
|
||||
supportedTransports: availableTransports,
|
||||
unsupportedTransportMessage: isGlobalMode
|
||||
? (transport) => `Add MCP Server supports only stdio and http across all providers, not ${transport}.`
|
||||
: undefined,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerName = MCP_PROVIDER_NAMES[provider];
|
||||
const modalTitle = title ?? (isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add'));
|
||||
const addButtonLabel = submitLabel ?? `${t('mcpForm.actions.addServer')} to ${providerName}`;
|
||||
const showProjectSelector = formData.scope !== 'user';
|
||||
const supportsHttpHeaders = formData.transport === 'http' || formData.transport === 'sse';
|
||||
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
||||
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
|
||||
|
||||
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">{modalTitle}</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">
|
||||
{description && (
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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, mode)}</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">
|
||||
{availableScopes.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, mode)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{getScopeDescription(formData.scope, mode)}</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"
|
||||
>
|
||||
{availableTransports.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={multilineText.args}
|
||||
onChange={(event) => updateMultilineText('args', 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={multilineText.env}
|
||||
onChange={(event) => updateMultilineText('env', 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={multilineText.headers}
|
||||
onChange={(event) => updateMultilineText('headers', 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>
|
||||
)}
|
||||
|
||||
{showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'stdio' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Environment Variable Names
|
||||
</label>
|
||||
<textarea
|
||||
value={multilineText.envVars}
|
||||
onChange={(event) => updateMultilineText('envVars', 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>
|
||||
)}
|
||||
|
||||
{showCodexOnlyFields && 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')
|
||||
: addButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
import {
|
||||
CLI_AUTH_STATUS_ENDPOINTS,
|
||||
CLI_PROVIDERS,
|
||||
PROVIDER_AUTH_STATUS_ENDPOINTS,
|
||||
createInitialProviderAuthStatusMap,
|
||||
} from '../types';
|
||||
import type {
|
||||
@@ -18,6 +18,11 @@ type ProviderAuthStatusPayload = {
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
type ProviderAuthStatusApiResponse = {
|
||||
success: boolean;
|
||||
data: ProviderAuthStatusPayload;
|
||||
};
|
||||
|
||||
const FALLBACK_STATUS_ERROR = 'Failed to check authentication status';
|
||||
const FALLBACK_UNKNOWN_ERROR = 'Unknown error';
|
||||
|
||||
@@ -69,7 +74,7 @@ export function useProviderAuthStatus(
|
||||
setProviderLoading(provider);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(CLI_AUTH_STATUS_ENDPOINTS[provider]);
|
||||
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
|
||||
|
||||
if (!response.ok) {
|
||||
setProviderStatus(provider, {
|
||||
@@ -82,8 +87,8 @@ export function useProviderAuthStatus(
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ProviderAuthStatusPayload;
|
||||
setProviderStatus(provider, toProviderAuthStatus(payload));
|
||||
const payload = (await response.json()) as ProviderAuthStatusApiResponse;
|
||||
setProviderStatus(provider, toProviderAuthStatus(payload.data));
|
||||
} catch (caughtError) {
|
||||
console.error(`Error checking ${provider} auth status:`, caughtError);
|
||||
setProviderStatus(provider, {
|
||||
|
||||
@@ -12,11 +12,11 @@ export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
||||
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
|
||||
export const CLI_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
claude: '/api/cli/claude/status',
|
||||
cursor: '/api/cli/cursor/status',
|
||||
codex: '/api/cli/codex/status',
|
||||
gemini: '/api/cli/gemini/status',
|
||||
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
claude: '/api/providers/claude/auth/status',
|
||||
cursor: '/api/providers/cursor/auth/status',
|
||||
codex: '/api/providers/codex/auth/status',
|
||||
gemini: '/api/providers/gemini/auth/status',
|
||||
};
|
||||
|
||||
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type {
|
||||
AgentCategory,
|
||||
AgentProvider,
|
||||
ClaudeMcpFormState,
|
||||
CodexMcpFormState,
|
||||
CodeEditorSettingsState,
|
||||
CursorPermissionsState,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
ProjectSortOrder,
|
||||
SettingsMainTab,
|
||||
} from '../types/types';
|
||||
@@ -20,7 +16,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
||||
'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 DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||
@@ -33,47 +29,6 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
||||
fontSize: '14',
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
|
||||
success: false,
|
||||
message: '',
|
||||
details: [],
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_TOOLS_RESULT: McpToolsResult = {
|
||||
success: false,
|
||||
tools: [],
|
||||
resources: [],
|
||||
prompts: [],
|
||||
};
|
||||
|
||||
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 = {
|
||||
allowedCommands: [],
|
||||
disallowedCommands: [],
|
||||
|
||||
@@ -8,16 +8,11 @@ import {
|
||||
} from '../constants/constants';
|
||||
import type {
|
||||
AgentProvider,
|
||||
ClaudeMcpFormState,
|
||||
ClaudePermissionsState,
|
||||
CodeEditorSettingsState,
|
||||
CodexMcpFormState,
|
||||
CodexPermissionMode,
|
||||
CursorPermissionsState,
|
||||
GeminiPermissionMode,
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
NotificationPreferencesState,
|
||||
ProjectSortOrder,
|
||||
SettingsMainTab,
|
||||
@@ -33,41 +28,6 @@ type UseSettingsControllerArgs = {
|
||||
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 McpTestResponse = {
|
||||
testResult?: McpTestResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type McpToolsResponse = {
|
||||
toolsResult?: McpToolsResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ClaudeSettingsStorage = {
|
||||
allowedTools?: string[];
|
||||
disallowedTools?: string[];
|
||||
@@ -103,10 +63,6 @@ const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||
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 => {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
@@ -135,25 +91,6 @@ const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
||||
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 createEmptyClaudePermissions = (): ClaudePermissionsState => ({
|
||||
@@ -184,7 +121,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
|
||||
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
|
||||
readCodeEditorSettings()
|
||||
@@ -202,18 +138,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
|
||||
const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);
|
||||
const [mcpTestResults, setMcpTestResults] = useState<Record<string, McpTestResult>>({});
|
||||
const [mcpServerTools, setMcpServerTools] = useState<Record<string, McpToolsResult>>({});
|
||||
const [mcpToolsLoading, setMcpToolsLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
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 [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
|
||||
const {
|
||||
@@ -222,361 +146,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
refreshProviderAuthStatuses,
|
||||
} = 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;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackResponse = await authenticatedFetch('/api/mcp/servers?scope=user');
|
||||
if (!fallbackResponse.ok) {
|
||||
console.error('Failed to fetch MCP servers');
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackData = await toResponseJson<{ servers?: McpServer[] }>(fallbackResponse);
|
||||
setMcpServers(fallbackData.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 testMcpServer = useCallback(async (serverId: string, scope = 'user') => {
|
||||
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<McpTestResponse>(response);
|
||||
throw new Error(error.error || 'Failed to test server');
|
||||
}
|
||||
|
||||
const data = await toResponseJson<McpTestResponse>(response);
|
||||
return data.testResult || { success: false, message: 'No test result returned' };
|
||||
}, []);
|
||||
|
||||
const discoverMcpTools = useCallback(async (serverId: string, scope = 'user') => {
|
||||
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<McpToolsResponse>(response);
|
||||
throw new Error(error.error || 'Failed to discover tools');
|
||||
}
|
||||
|
||||
const data = await toResponseJson<McpToolsResponse>(response);
|
||||
return data.toolsResult || { success: false, tools: [], resources: [], prompts: [] };
|
||||
}, []);
|
||||
|
||||
const handleMcpTest = useCallback(
|
||||
async (serverId: string, scope = 'user') => {
|
||||
try {
|
||||
setMcpTestResults((prev) => ({
|
||||
...prev,
|
||||
[serverId]: { success: false, message: 'Testing server...', details: [], loading: true },
|
||||
}));
|
||||
|
||||
const result = await testMcpServer(serverId, scope);
|
||||
setMcpTestResults((prev) => ({ ...prev, [serverId]: result }));
|
||||
} catch (error) {
|
||||
setMcpTestResults((prev) => ({
|
||||
...prev,
|
||||
[serverId]: {
|
||||
success: false,
|
||||
message: getErrorMessage(error),
|
||||
details: [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[testMcpServer],
|
||||
);
|
||||
|
||||
const handleMcpToolsDiscovery = useCallback(
|
||||
async (serverId: string, scope = 'user') => {
|
||||
try {
|
||||
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: true }));
|
||||
const result = await discoverMcpTools(serverId, scope);
|
||||
setMcpServerTools((prev) => ({ ...prev, [serverId]: result }));
|
||||
} catch {
|
||||
setMcpServerTools((prev) => ({
|
||||
...prev,
|
||||
[serverId]: { success: false, tools: [], resources: [], prompts: [] },
|
||||
}));
|
||||
} finally {
|
||||
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: false }));
|
||||
}
|
||||
},
|
||||
[discoverMcpTools],
|
||||
);
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
|
||||
@@ -628,11 +197,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fetchMcpServers(),
|
||||
fetchCursorMcpServers(),
|
||||
fetchCodexMcpServers(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
setClaudePermissions(createEmptyClaudePermissions());
|
||||
@@ -641,7 +205,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
setCodexPermissionMode('default');
|
||||
setProjectSortOrder('name');
|
||||
}
|
||||
}, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
|
||||
}, []);
|
||||
|
||||
const openLoginForProvider = useCallback((provider: AgentProvider) => {
|
||||
setLoginProvider(provider);
|
||||
@@ -720,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(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
@@ -819,7 +363,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
saveStatus,
|
||||
deleteError,
|
||||
projectSortOrder,
|
||||
setProjectSortOrder,
|
||||
codeEditorSettings,
|
||||
@@ -832,26 +375,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
setNotificationPreferences,
|
||||
codexPermissionMode,
|
||||
setCodexPermissionMode,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
showMcpForm,
|
||||
editingMcpServer,
|
||||
openMcpForm,
|
||||
closeMcpForm,
|
||||
submitMcpForm,
|
||||
handleMcpDelete,
|
||||
handleMcpTest,
|
||||
handleMcpToolsDiscovery,
|
||||
showCodexMcpForm,
|
||||
editingCodexMcpServer,
|
||||
openCodexMcpForm,
|
||||
closeCodexMcpForm,
|
||||
submitCodexMcpForm,
|
||||
handleCodexMcpDelete,
|
||||
providerAuthStatus,
|
||||
geminiPermissionMode,
|
||||
setGeminiPermissionMode,
|
||||
|
||||
@@ -9,9 +9,6 @@ export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SaveStatus = 'success' | 'error' | null;
|
||||
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||
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 = {
|
||||
name: string;
|
||||
@@ -22,80 +19,6 @@ export type SettingsProject = {
|
||||
|
||||
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 McpTestResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type McpTool = {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type McpToolsResult = {
|
||||
success?: boolean;
|
||||
tools?: McpTool[];
|
||||
resources?: unknown[];
|
||||
prompts?: unknown[];
|
||||
};
|
||||
|
||||
export type ClaudePermissionsState = {
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
|
||||
@@ -2,8 +2,6 @@ import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
|
||||
import { Button } from '../../../shared/view/ui';
|
||||
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
|
||||
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
|
||||
import SettingsSidebar from '../view/SettingsSidebar';
|
||||
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||
@@ -23,7 +21,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
saveStatus,
|
||||
deleteError,
|
||||
projectSortOrder,
|
||||
setProjectSortOrder,
|
||||
codeEditorSettings,
|
||||
@@ -36,26 +33,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
setCursorPermissions,
|
||||
codexPermissionMode,
|
||||
setCodexPermissionMode,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
showMcpForm,
|
||||
editingMcpServer,
|
||||
openMcpForm,
|
||||
closeMcpForm,
|
||||
submitMcpForm,
|
||||
handleMcpDelete,
|
||||
handleMcpTest,
|
||||
handleMcpToolsDiscovery,
|
||||
showCodexMcpForm,
|
||||
editingCodexMcpServer,
|
||||
openCodexMcpForm,
|
||||
closeCodexMcpForm,
|
||||
submitCodexMcpForm,
|
||||
handleCodexMcpDelete,
|
||||
providerAuthStatus,
|
||||
geminiPermissionMode,
|
||||
setGeminiPermissionMode,
|
||||
@@ -156,19 +133,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
onCodexPermissionModeChange={setCodexPermissionMode}
|
||||
geminiPermissionMode={geminiPermissionMode}
|
||||
onGeminiPermissionModeChange={setGeminiPermissionMode}
|
||||
mcpServers={mcpServers}
|
||||
cursorMcpServers={cursorMcpServers}
|
||||
codexMcpServers={codexMcpServers}
|
||||
mcpTestResults={mcpTestResults}
|
||||
mcpServerTools={mcpServerTools}
|
||||
mcpToolsLoading={mcpToolsLoading}
|
||||
onOpenMcpForm={openMcpForm}
|
||||
onDeleteMcpServer={handleMcpDelete}
|
||||
onTestMcpServer={handleMcpTest}
|
||||
onDiscoverMcpTools={handleMcpToolsDiscovery}
|
||||
onOpenCodexMcpForm={openCodexMcpForm}
|
||||
onDeleteCodexMcpServer={handleCodexMcpDelete}
|
||||
deleteError={deleteError}
|
||||
projects={projects}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -205,20 +170,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
|
||||
<ClaudeMcpFormModal
|
||||
isOpen={showMcpForm}
|
||||
editingServer={editingMcpServer}
|
||||
projects={projects}
|
||||
onClose={closeMcpForm}
|
||||
onSubmit={submitMcpForm}
|
||||
/>
|
||||
|
||||
<CodexMcpFormModal
|
||||
isOpen={showCodexMcpForm}
|
||||
editingServer={editingCodexMcpServer}
|
||||
onClose={closeCodexMcpForm}
|
||||
onSubmit={submitCodexMcpForm}
|
||||
/>
|
||||
</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,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useServerPlatform } from '../../../../../hooks/useServerPlatform';
|
||||
import type { AgentCategory, AgentProvider } from '../../../types/types';
|
||||
|
||||
import type { AgentContext, AgentsSettingsTabProps } from './types';
|
||||
import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
|
||||
import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
|
||||
import AgentSelectorSection from './sections/AgentSelectorSection';
|
||||
import type { AgentContext, AgentsSettingsTabProps } from './types';
|
||||
|
||||
export default function AgentsSettingsTab({
|
||||
providerAuthStatus,
|
||||
@@ -16,22 +19,26 @@ export default function AgentsSettingsTab({
|
||||
onCodexPermissionModeChange,
|
||||
geminiPermissionMode,
|
||||
onGeminiPermissionModeChange,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
deleteError,
|
||||
onOpenMcpForm,
|
||||
onDeleteMcpServer,
|
||||
onTestMcpServer,
|
||||
onDiscoverMcpTools,
|
||||
onOpenCodexMcpForm,
|
||||
onDeleteCodexMcpServer,
|
||||
projects,
|
||||
}: AgentsSettingsTabProps) {
|
||||
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
|
||||
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
|
||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
if (isWindowsServer) {
|
||||
return all.filter((id) => id !== 'cursor');
|
||||
}
|
||||
|
||||
return all;
|
||||
}, [isWindowsServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && selectedAgent === 'cursor') {
|
||||
setSelectedAgent('claude');
|
||||
}
|
||||
}, [isWindowsServer, selectedAgent]);
|
||||
|
||||
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
|
||||
claude: {
|
||||
@@ -61,6 +68,7 @@ export default function AgentsSettingsTab({
|
||||
return (
|
||||
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
|
||||
<AgentSelectorSection
|
||||
agents={visibleAgents}
|
||||
selectedAgent={selectedAgent}
|
||||
onSelectAgent={setSelectedAgent}
|
||||
agentContextById={agentContextById}
|
||||
@@ -84,19 +92,7 @@ export default function AgentsSettingsTab({
|
||||
onCodexPermissionModeChange={onCodexPermissionModeChange}
|
||||
geminiPermissionMode={geminiPermissionMode}
|
||||
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
|
||||
mcpServers={mcpServers}
|
||||
cursorMcpServers={cursorMcpServers}
|
||||
codexMcpServers={codexMcpServers}
|
||||
mcpTestResults={mcpTestResults}
|
||||
mcpServerTools={mcpServerTools}
|
||||
mcpToolsLoading={mcpToolsLoading}
|
||||
deleteError={deleteError}
|
||||
onOpenMcpForm={onOpenMcpForm}
|
||||
onDeleteMcpServer={onDeleteMcpServer}
|
||||
onTestMcpServer={onTestMcpServer}
|
||||
onDiscoverMcpTools={onDiscoverMcpTools}
|
||||
onOpenCodexMcpForm={onOpenCodexMcpForm}
|
||||
onDeleteCodexMcpServer={onDeleteCodexMcpServer}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentCategoryContentSectionProps } from '../types';
|
||||
import { McpServers } from '../../../../../mcp';
|
||||
|
||||
import AccountContent from './content/AccountContent';
|
||||
import McpServersContent from './content/McpServersContent';
|
||||
import PermissionsContent from './content/PermissionsContent';
|
||||
|
||||
export default function AgentCategoryContentSection({
|
||||
@@ -13,23 +14,8 @@ export default function AgentCategoryContentSection({
|
||||
onCursorPermissionsChange,
|
||||
codexPermissionMode,
|
||||
onCodexPermissionModeChange,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
deleteError,
|
||||
onOpenMcpForm,
|
||||
onDeleteMcpServer,
|
||||
onTestMcpServer,
|
||||
onDiscoverMcpTools,
|
||||
onOpenCodexMcpForm,
|
||||
onDeleteCodexMcpServer,
|
||||
projects,
|
||||
}: AgentCategoryContentSectionProps) {
|
||||
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
|
||||
const noopCursorMcpAction = () => {};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-3 md:p-4">
|
||||
{selectedCategory === 'account' && (
|
||||
@@ -84,40 +70,10 @@ export default function AgentCategoryContentSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'mcp' && selectedAgent === 'claude' && (
|
||||
<McpServersContent
|
||||
agent="claude"
|
||||
servers={mcpServers}
|
||||
onAdd={() => onOpenMcpForm()}
|
||||
onEdit={(server) => onOpenMcpForm(server)}
|
||||
onDelete={onDeleteMcpServer}
|
||||
onTest={onTestMcpServer}
|
||||
onDiscoverTools={onDiscoverMcpTools}
|
||||
testResults={mcpTestResults}
|
||||
serverTools={mcpServerTools}
|
||||
toolsLoading={mcpToolsLoading}
|
||||
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}
|
||||
{selectedCategory === 'mcp' && (
|
||||
<McpServers
|
||||
selectedProvider={selectedAgent}
|
||||
currentProjects={projects}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,6 @@ import SessionProviderLogo from '../../../../../llm-logo-provider/SessionProvide
|
||||
import type { AgentProvider } from '../../../../types/types';
|
||||
import type { AgentSelectorSectionProps } from '../types';
|
||||
|
||||
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
|
||||
const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
@@ -13,6 +11,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
};
|
||||
|
||||
export default function AgentSelectorSection({
|
||||
agents,
|
||||
selectedAgent,
|
||||
onSelectAgent,
|
||||
agentContextById,
|
||||
@@ -20,7 +19,7 @@ export default function AgentSelectorSection({
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3">
|
||||
<PillBar className="w-full md:w-auto">
|
||||
{AGENT_PROVIDERS.map((agent) => {
|
||||
{agents.map((agent) => {
|
||||
const dotColor =
|
||||
agent === 'claude' ? 'bg-blue-500' :
|
||||
agent === 'cursor' ? 'bg-purple-500' :
|
||||
|
||||
@@ -1,391 +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, McpToolsResult, McpTestResult } 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;
|
||||
onTest: (serverId: string, scope?: string) => void;
|
||||
onDiscoverTools: (serverId: string, scope?: string) => void;
|
||||
testResults: Record<string, McpTestResult>;
|
||||
serverTools: Record<string, McpToolsResult>;
|
||||
toolsLoading: Record<string, boolean>;
|
||||
deleteError?: string | null;
|
||||
};
|
||||
|
||||
function ClaudeMcpServers({
|
||||
servers,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
testResults,
|
||||
serverTools,
|
||||
deleteError,
|
||||
}: Omit<ClaudeMcpServersProps, 'agent' | 'onTest' | 'onDiscoverTools' | 'toolsLoading'>) {
|
||||
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;
|
||||
const testResult = testResults[serverId];
|
||||
const toolsResult = serverTools[serverId];
|
||||
|
||||
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>
|
||||
|
||||
{testResult && (
|
||||
<div className={`mt-2 rounded p-2 text-xs ${
|
||||
testResult.success
|
||||
? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-200'
|
||||
: 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{testResult.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsResult && toolsResult.tools && toolsResult.tools.length > 0 && (
|
||||
<div className="mt-2 rounded bg-blue-50 p-2 text-xs text-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
<div className="font-medium">
|
||||
{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: toolsResult.tools.length })}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{toolsResult.tools.slice(0, 5).map((tool, index) => (
|
||||
<code key={`${tool.name}-${index}`} className="rounded bg-blue-100 px-1 dark:bg-blue-800">
|
||||
{tool.name}
|
||||
</code>
|
||||
))}
|
||||
{toolsResult.tools.length > 5 && (
|
||||
<span className="text-xs opacity-75">
|
||||
{t('mcpServers.tools.more', { count: toolsResult.tools.length - 5 })}
|
||||
</span>
|
||||
)}
|
||||
</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,9 +6,7 @@ import type {
|
||||
CursorPermissionsState,
|
||||
CodexPermissionMode,
|
||||
GeminiPermissionMode,
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
SettingsProject,
|
||||
} from '../../../types/types';
|
||||
|
||||
export type AgentContext = {
|
||||
@@ -30,19 +28,7 @@ export type AgentsSettingsTabProps = {
|
||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||
geminiPermissionMode: GeminiPermissionMode;
|
||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||
mcpServers: McpServer[];
|
||||
cursorMcpServers: McpServer[];
|
||||
codexMcpServers: McpServer[];
|
||||
mcpTestResults: Record<string, McpTestResult>;
|
||||
mcpServerTools: Record<string, McpToolsResult>;
|
||||
mcpToolsLoading: Record<string, boolean>;
|
||||
deleteError: string | null;
|
||||
onOpenMcpForm: (server?: McpServer) => void;
|
||||
onDeleteMcpServer: (serverId: string, scope?: string) => void;
|
||||
onTestMcpServer: (serverId: string, scope?: string) => void;
|
||||
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
|
||||
onOpenCodexMcpForm: (server?: McpServer) => void;
|
||||
onDeleteCodexMcpServer: (serverId: string) => void;
|
||||
projects: SettingsProject[];
|
||||
};
|
||||
|
||||
export type AgentCategoryTabsSectionProps = {
|
||||
@@ -51,6 +37,7 @@ export type AgentCategoryTabsSectionProps = {
|
||||
};
|
||||
|
||||
export type AgentSelectorSectionProps = {
|
||||
agents: AgentProvider[];
|
||||
selectedAgent: AgentProvider;
|
||||
onSelectAgent: (agent: AgentProvider) => void;
|
||||
agentContextById: AgentContextByProvider;
|
||||
@@ -68,17 +55,5 @@ export type AgentCategoryContentSectionProps = {
|
||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||
geminiPermissionMode: GeminiPermissionMode;
|
||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||
mcpServers: McpServer[];
|
||||
cursorMcpServers: McpServer[];
|
||||
codexMcpServers: McpServer[];
|
||||
mcpTestResults: Record<string, McpTestResult>;
|
||||
mcpServerTools: Record<string, McpToolsResult>;
|
||||
mcpToolsLoading: Record<string, boolean>;
|
||||
deleteError: string | null;
|
||||
onOpenMcpForm: (server?: McpServer) => void;
|
||||
onDeleteMcpServer: (serverId: string, scope?: string) => void;
|
||||
onTestMcpServer: (serverId: string, scope?: string) => void;
|
||||
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
|
||||
onOpenCodexMcpForm: (server?: McpServer) => void;
|
||||
onDeleteCodexMcpServer: (serverId: string) => void;
|
||||
projects: SettingsProject[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user