mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-03 08:53:36 +00:00
- Replaced console.log and console.error statements with logger methods from @automaker/utils in various UI components, ensuring consistent log formatting and improved readability. - Enhanced error handling by utilizing logger methods to provide clearer context for issues encountered during operations. - Updated multiple views and hooks to integrate the new logging system, improving maintainability and debugging capabilities. This update significantly enhances the observability of UI components, facilitating easier troubleshooting and monitoring.
1003 lines
32 KiB
TypeScript
1003 lines
32 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
import { useAppStore } from '@/store/app-store';
|
|
|
|
const logger = createLogger('MCPServers');
|
|
import { toast } from 'sonner';
|
|
import type { MCPServerConfig } from '@automaker/types';
|
|
import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration';
|
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
import type { ServerFormData, ServerTestState } from '../types';
|
|
import { defaultFormData } from '../types';
|
|
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
|
import type { ServerType } from '../types';
|
|
|
|
/** Pending server data waiting for security confirmation */
|
|
interface PendingServerData {
|
|
type: 'add' | 'import';
|
|
serverData?: Omit<MCPServerConfig, 'id'>;
|
|
importServers?: Array<Omit<MCPServerConfig, 'id'>>;
|
|
serverType: ServerType;
|
|
command?: string;
|
|
args?: string[];
|
|
url?: string;
|
|
}
|
|
|
|
export function useMCPServers() {
|
|
const {
|
|
mcpServers,
|
|
addMCPServer,
|
|
updateMCPServer,
|
|
removeMCPServer,
|
|
mcpAutoApproveTools,
|
|
mcpUnrestrictedTools,
|
|
setMcpAutoApproveTools,
|
|
setMcpUnrestrictedTools,
|
|
} = useAppStore();
|
|
|
|
// State
|
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(null);
|
|
const [formData, setFormData] = useState<ServerFormData>(defaultFormData);
|
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
|
const [importJson, setImportJson] = useState('');
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [serverTestStates, setServerTestStates] = useState<Record<string, ServerTestState>>({});
|
|
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
|
const [jsonEditServer, setJsonEditServer] = useState<MCPServerConfig | null>(null);
|
|
const [jsonEditValue, setJsonEditValue] = useState('');
|
|
const [isGlobalJsonEditOpen, setIsGlobalJsonEditOpen] = useState(false);
|
|
const [globalJsonValue, setGlobalJsonValue] = useState('');
|
|
const autoTestedServersRef = useRef<Set<string>>(new Set());
|
|
const pendingSyncServerIdsRef = useRef<Set<string>>(new Set());
|
|
|
|
// Security warning dialog state
|
|
const [isSecurityWarningOpen, setIsSecurityWarningOpen] = useState(false);
|
|
const [pendingServerData, setPendingServerData] = useState<PendingServerData | null>(null);
|
|
|
|
// Computed values
|
|
const totalToolsCount = useMemo(() => {
|
|
let count = 0;
|
|
for (const server of mcpServers) {
|
|
if (server.enabled !== false) {
|
|
const testState = serverTestStates[server.id];
|
|
if (testState?.status === 'success' && testState.tools) {
|
|
count += testState.tools.length;
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}, [mcpServers, serverTestStates]);
|
|
|
|
const showToolsWarning = totalToolsCount > MAX_RECOMMENDED_TOOLS;
|
|
|
|
// Auto-load MCP servers from settings file on mount
|
|
useEffect(() => {
|
|
loadMCPServersFromServer().catch((error) => {
|
|
logger.error('Failed to load MCP servers on mount:', error);
|
|
});
|
|
}, []);
|
|
|
|
// Test a single server (extracted for reuse)
|
|
const testServer = useCallback(async (server: MCPServerConfig, silent = false) => {
|
|
setServerTestStates((prev) => ({
|
|
...prev,
|
|
[server.id]: { status: 'testing' },
|
|
}));
|
|
|
|
try {
|
|
const api = getHttpApiClient();
|
|
const result = await api.mcp.testServer(server.id);
|
|
|
|
if (result.success) {
|
|
setServerTestStates((prev) => ({
|
|
...prev,
|
|
[server.id]: {
|
|
status: 'success',
|
|
tools: result.tools,
|
|
connectionTime: result.connectionTime,
|
|
},
|
|
}));
|
|
// Only auto-expand on manual test, not on auto-test (silent)
|
|
if (!silent) {
|
|
setExpandedServers((prev) => new Set([...prev, server.id]));
|
|
toast.success(
|
|
`Connected to ${server.name} (${result.tools?.length || 0} tools, ${result.connectionTime}ms)`
|
|
);
|
|
}
|
|
} else {
|
|
setServerTestStates((prev) => ({
|
|
...prev,
|
|
[server.id]: {
|
|
status: 'error',
|
|
error: result.error,
|
|
connectionTime: result.connectionTime,
|
|
},
|
|
}));
|
|
if (!silent) {
|
|
toast.error(`Failed to connect: ${result.error}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
setServerTestStates((prev) => ({
|
|
...prev,
|
|
[server.id]: {
|
|
status: 'error',
|
|
error: errorMessage,
|
|
},
|
|
}));
|
|
if (!silent) {
|
|
toast.error(`Test failed: ${errorMessage}`);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
// Auto-test all enabled servers on mount (skip servers pending sync)
|
|
useEffect(() => {
|
|
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
|
const serversToTest = enabledServers.filter(
|
|
(s) => !autoTestedServersRef.current.has(s.id) && !pendingSyncServerIdsRef.current.has(s.id)
|
|
);
|
|
|
|
if (serversToTest.length > 0) {
|
|
// Mark all as being tested
|
|
serversToTest.forEach((s) => autoTestedServersRef.current.add(s.id));
|
|
|
|
// Test all servers in parallel (silently - no toast spam)
|
|
serversToTest.forEach((server) => {
|
|
testServer(server, true);
|
|
});
|
|
}
|
|
}, [mcpServers, testServer]);
|
|
|
|
const handleRefresh = async () => {
|
|
setIsRefreshing(true);
|
|
try {
|
|
const success = await loadMCPServersFromServer();
|
|
if (success) {
|
|
toast.success('MCP servers refreshed from settings');
|
|
} else {
|
|
toast.error('Failed to refresh MCP servers');
|
|
}
|
|
} catch {
|
|
toast.error('Error refreshing MCP servers');
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
};
|
|
|
|
const handleTestServer = (server: MCPServerConfig) => {
|
|
testServer(server, false); // false = show toast notifications
|
|
};
|
|
|
|
const toggleServerExpanded = (serverId: string) => {
|
|
setExpandedServers((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(serverId)) {
|
|
next.delete(serverId);
|
|
} else {
|
|
next.add(serverId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleOpenAddDialog = () => {
|
|
setFormData(defaultFormData);
|
|
setEditingServer(null);
|
|
setIsAddDialogOpen(true);
|
|
};
|
|
|
|
const handleOpenEditDialog = (server: MCPServerConfig) => {
|
|
setFormData({
|
|
name: server.name,
|
|
description: server.description || '',
|
|
type: server.type || 'stdio',
|
|
command: server.command || '',
|
|
args: server.args?.join(' ') || '',
|
|
url: server.url || '',
|
|
headers: server.headers ? JSON.stringify(server.headers, null, 2) : '',
|
|
env: server.env ? JSON.stringify(server.env, null, 2) : '',
|
|
});
|
|
setEditingServer(server);
|
|
setIsAddDialogOpen(true);
|
|
};
|
|
|
|
const handleCloseDialog = () => {
|
|
setIsAddDialogOpen(false);
|
|
setEditingServer(null);
|
|
setFormData(defaultFormData);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!formData.name.trim()) {
|
|
toast.error('Server name is required');
|
|
return;
|
|
}
|
|
|
|
if (formData.type === 'stdio' && !formData.command.trim()) {
|
|
toast.error('Command is required for stdio servers');
|
|
return;
|
|
}
|
|
|
|
if ((formData.type === 'sse' || formData.type === 'http') && !formData.url.trim()) {
|
|
toast.error('URL is required for SSE/HTTP servers');
|
|
return;
|
|
}
|
|
|
|
// Parse headers if provided
|
|
let parsedHeaders: Record<string, string> | undefined;
|
|
if (formData.headers.trim()) {
|
|
try {
|
|
parsedHeaders = JSON.parse(formData.headers.trim());
|
|
if (typeof parsedHeaders !== 'object' || Array.isArray(parsedHeaders)) {
|
|
toast.error('Headers must be a JSON object');
|
|
return;
|
|
}
|
|
} catch {
|
|
toast.error('Invalid JSON for headers');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Parse env if provided
|
|
let parsedEnv: Record<string, string> | undefined;
|
|
if (formData.env.trim()) {
|
|
try {
|
|
parsedEnv = JSON.parse(formData.env.trim());
|
|
if (typeof parsedEnv !== 'object' || Array.isArray(parsedEnv)) {
|
|
toast.error('Environment variables must be a JSON object');
|
|
return;
|
|
}
|
|
} catch {
|
|
toast.error('Invalid JSON for environment variables');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
|
name: formData.name.trim(),
|
|
description: formData.description.trim() || undefined,
|
|
type: formData.type,
|
|
enabled: editingServer?.enabled ?? true,
|
|
};
|
|
|
|
if (formData.type === 'stdio') {
|
|
serverData.command = formData.command.trim();
|
|
if (formData.args.trim()) {
|
|
serverData.args = formData.args.trim().split(/\s+/);
|
|
}
|
|
if (parsedEnv) {
|
|
serverData.env = parsedEnv;
|
|
}
|
|
} else {
|
|
serverData.url = formData.url.trim();
|
|
if (parsedHeaders) {
|
|
serverData.headers = parsedHeaders;
|
|
}
|
|
}
|
|
|
|
// If editing an existing server, save directly (user already approved it)
|
|
if (editingServer) {
|
|
const previousData = { ...editingServer };
|
|
updateMCPServer(editingServer.id, serverData);
|
|
const syncSuccess = await syncSettingsToServer();
|
|
if (!syncSuccess) {
|
|
// Rollback local state on sync failure
|
|
updateMCPServer(editingServer.id, previousData);
|
|
toast.error('Failed to save MCP server to disk');
|
|
return;
|
|
}
|
|
toast.success('MCP server updated');
|
|
handleCloseDialog();
|
|
return;
|
|
}
|
|
|
|
// For new servers, show security warning first
|
|
setPendingServerData({
|
|
type: 'add',
|
|
serverData,
|
|
serverType: formData.type,
|
|
command: formData.type === 'stdio' ? formData.command.trim() : undefined,
|
|
args:
|
|
formData.type === 'stdio' && formData.args.trim()
|
|
? formData.args.trim().split(/\s+/)
|
|
: undefined,
|
|
url: formData.type !== 'stdio' ? formData.url.trim() : undefined,
|
|
});
|
|
setIsSecurityWarningOpen(true);
|
|
};
|
|
|
|
/** Called when user confirms the security warning for adding a server */
|
|
const handleSecurityWarningConfirm = async () => {
|
|
if (!pendingServerData) return;
|
|
|
|
if (pendingServerData.type === 'add' && pendingServerData.serverData) {
|
|
// Capture existing IDs before adding to find the new server reliably
|
|
const existingIds = new Set(mcpServers.map((s) => s.id));
|
|
addMCPServer(pendingServerData.serverData);
|
|
|
|
// Find the newly added server by comparing IDs
|
|
const newServers = useAppStore.getState().mcpServers;
|
|
const newServer = newServers.find((s) => !existingIds.has(s.id));
|
|
if (newServer) {
|
|
pendingSyncServerIdsRef.current.add(newServer.id);
|
|
}
|
|
|
|
const syncSuccess = await syncSettingsToServer();
|
|
|
|
// Clear pending sync and trigger auto-test after sync
|
|
if (newServer) {
|
|
pendingSyncServerIdsRef.current.delete(newServer.id);
|
|
if (syncSuccess && newServer.enabled !== false) {
|
|
testServer(newServer, true);
|
|
}
|
|
}
|
|
|
|
if (!syncSuccess) {
|
|
toast.error('Failed to save MCP server to disk');
|
|
setIsSecurityWarningOpen(false);
|
|
setPendingServerData(null);
|
|
return;
|
|
}
|
|
toast.success('MCP server added');
|
|
handleCloseDialog();
|
|
} else if (pendingServerData.type === 'import' && pendingServerData.importServers) {
|
|
// Capture existing IDs before adding to find the new servers reliably
|
|
const existingIds = new Set(mcpServers.map((s) => s.id));
|
|
|
|
for (const serverData of pendingServerData.importServers) {
|
|
addMCPServer(serverData);
|
|
}
|
|
|
|
// Find all newly added servers by comparing IDs
|
|
const newServers = useAppStore.getState().mcpServers.filter((s) => !existingIds.has(s.id));
|
|
newServers.forEach((s) => pendingSyncServerIdsRef.current.add(s.id));
|
|
|
|
const syncSuccess = await syncSettingsToServer();
|
|
|
|
// Clear pending sync and trigger auto-test after sync
|
|
newServers.forEach((s) => pendingSyncServerIdsRef.current.delete(s.id));
|
|
if (syncSuccess) {
|
|
for (const server of newServers) {
|
|
if (server.enabled !== false) {
|
|
testServer(server, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!syncSuccess) {
|
|
toast.error('Failed to save MCP servers to disk');
|
|
setIsSecurityWarningOpen(false);
|
|
setPendingServerData(null);
|
|
return;
|
|
}
|
|
const count = pendingServerData.importServers.length;
|
|
toast.success(`Imported ${count} MCP server${count > 1 ? 's' : ''}`);
|
|
setIsImportDialogOpen(false);
|
|
setImportJson('');
|
|
}
|
|
|
|
setIsSecurityWarningOpen(false);
|
|
setPendingServerData(null);
|
|
};
|
|
|
|
const handleToggleEnabled = async (server: MCPServerConfig) => {
|
|
const wasDisabled = server.enabled === false;
|
|
const previousEnabled = server.enabled;
|
|
updateMCPServer(server.id, { enabled: !server.enabled });
|
|
const syncSuccess = await syncSettingsToServer();
|
|
if (!syncSuccess) {
|
|
// Rollback local state on sync failure
|
|
updateMCPServer(server.id, { enabled: previousEnabled });
|
|
toast.error('Failed to save settings to disk');
|
|
return;
|
|
}
|
|
toast.success(wasDisabled ? 'Server enabled' : 'Server disabled');
|
|
|
|
// Auto-test if server was just enabled
|
|
if (wasDisabled) {
|
|
const updatedServer = useAppStore.getState().mcpServers.find((s) => s.id === server.id);
|
|
if (updatedServer) {
|
|
testServer(updatedServer, true);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
removeMCPServer(id);
|
|
const syncSuccess = await syncSettingsToServer();
|
|
setDeleteConfirmId(null);
|
|
if (!syncSuccess) {
|
|
toast.error('Failed to save settings to disk');
|
|
return;
|
|
}
|
|
toast.success('MCP server removed');
|
|
};
|
|
|
|
/** Helper to parse a server config into importable format */
|
|
const parseServerConfig = (
|
|
name: string,
|
|
serverConfig: Record<string, unknown>
|
|
): Omit<MCPServerConfig, 'id'> | null => {
|
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
|
name,
|
|
type: (serverConfig.type as ServerType) || 'stdio',
|
|
enabled: serverConfig.enabled !== false,
|
|
};
|
|
|
|
if (serverConfig.description) {
|
|
serverData.description = serverConfig.description as string;
|
|
}
|
|
|
|
if (serverData.type === 'stdio') {
|
|
if (!serverConfig.command) {
|
|
logger.warn(`Skipping ${name}: no command specified`);
|
|
return null;
|
|
}
|
|
|
|
const rawCommand = serverConfig.command as string;
|
|
|
|
// Support both formats:
|
|
// 1. Separate command/args: { "command": "npx", "args": ["-y", "package"] }
|
|
// 2. Inline args (Claude Desktop format): { "command": "npx -y package" }
|
|
if (Array.isArray(serverConfig.args) && serverConfig.args.length > 0) {
|
|
serverData.command = rawCommand;
|
|
serverData.args = serverConfig.args as string[];
|
|
} else if (rawCommand.includes(' ')) {
|
|
const parts = rawCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [rawCommand];
|
|
serverData.command = parts[0];
|
|
if (parts.length > 1) {
|
|
serverData.args = parts.slice(1).map((arg) => arg.replace(/^["']|["']$/g, ''));
|
|
}
|
|
} else {
|
|
serverData.command = rawCommand;
|
|
}
|
|
|
|
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
|
serverData.env = serverConfig.env as Record<string, string>;
|
|
}
|
|
} else {
|
|
if (!serverConfig.url) {
|
|
logger.warn(`Skipping ${name}: no url specified`);
|
|
return null;
|
|
}
|
|
serverData.url = serverConfig.url as string;
|
|
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
|
serverData.headers = serverConfig.headers as Record<string, string>;
|
|
}
|
|
}
|
|
|
|
return serverData;
|
|
};
|
|
|
|
const handleImportJson = async () => {
|
|
try {
|
|
const parsed = JSON.parse(importJson);
|
|
|
|
// Support both formats:
|
|
// 1. Array format (new): { "mcpServers": [...] } or [...]
|
|
// 2. Object format (legacy): { "mcpServers": {...} } or { "name": {...} }
|
|
const servers = parsed.mcpServers || parsed;
|
|
|
|
const serversToImport: Array<Omit<MCPServerConfig, 'id'>> = [];
|
|
let skippedCount = 0;
|
|
|
|
if (Array.isArray(servers)) {
|
|
// Array format - each item has name property
|
|
for (const serverConfig of servers) {
|
|
if (typeof serverConfig !== 'object' || serverConfig === null) continue;
|
|
|
|
const config = serverConfig as Record<string, unknown>;
|
|
const name = config.name as string;
|
|
|
|
if (!name) {
|
|
logger.warn('Skipping server: no name specified');
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
// Check if server with this name already exists
|
|
if (mcpServers.some((s) => s.name === name)) {
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
const serverData = parseServerConfig(name, config);
|
|
if (serverData) {
|
|
serversToImport.push(serverData);
|
|
} else {
|
|
skippedCount++;
|
|
}
|
|
}
|
|
} else if (typeof servers === 'object' && servers !== null) {
|
|
// Object format - name is the key
|
|
for (const [name, config] of Object.entries(servers)) {
|
|
if (typeof config !== 'object' || config === null) continue;
|
|
|
|
// Check if server with this name already exists
|
|
if (mcpServers.some((s) => s.name === name)) {
|
|
skippedCount++;
|
|
continue;
|
|
}
|
|
|
|
const serverData = parseServerConfig(name, config as Record<string, unknown>);
|
|
if (serverData) {
|
|
serversToImport.push(serverData);
|
|
} else {
|
|
skippedCount++;
|
|
}
|
|
}
|
|
} else {
|
|
toast.error('Invalid format: expected array or object with server configurations');
|
|
return;
|
|
}
|
|
|
|
if (skippedCount > 0) {
|
|
toast.info(
|
|
`Skipped ${skippedCount} server${skippedCount > 1 ? 's' : ''} (already exist or invalid)`
|
|
);
|
|
}
|
|
|
|
if (serversToImport.length === 0) {
|
|
toast.warning('No new servers to import');
|
|
return;
|
|
}
|
|
|
|
// Show security warning before importing
|
|
// Use the first server's type for the warning (most imports are stdio)
|
|
const firstServer = serversToImport[0];
|
|
setPendingServerData({
|
|
type: 'import',
|
|
importServers: serversToImport,
|
|
serverType: firstServer.type || 'stdio',
|
|
command: firstServer.type === 'stdio' ? firstServer.command : undefined,
|
|
args: firstServer.type === 'stdio' ? firstServer.args : undefined,
|
|
url: firstServer.type !== 'stdio' ? firstServer.url : undefined,
|
|
});
|
|
setIsSecurityWarningOpen(true);
|
|
} catch (error) {
|
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
|
}
|
|
};
|
|
|
|
const handleExportJson = () => {
|
|
// Export as array format with IDs preserved for full fidelity
|
|
const exportData: Array<Record<string, unknown>> = [];
|
|
|
|
for (const server of mcpServers) {
|
|
const serverConfig: Record<string, unknown> = {
|
|
id: server.id,
|
|
name: server.name,
|
|
type: server.type || 'stdio',
|
|
enabled: server.enabled ?? true,
|
|
};
|
|
|
|
if (server.description) {
|
|
serverConfig.description = server.description;
|
|
}
|
|
|
|
if (server.type === 'stdio' || !server.type) {
|
|
serverConfig.command = server.command;
|
|
if (server.args?.length) serverConfig.args = server.args;
|
|
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
|
} else {
|
|
serverConfig.url = server.url;
|
|
if (server.headers && Object.keys(server.headers).length > 0)
|
|
serverConfig.headers = server.headers;
|
|
}
|
|
|
|
exportData.push(serverConfig);
|
|
}
|
|
|
|
const json = JSON.stringify({ mcpServers: exportData }, null, 2);
|
|
navigator.clipboard.writeText(json);
|
|
toast.success('Copied to clipboard');
|
|
};
|
|
|
|
const handleOpenJsonEdit = (server: MCPServerConfig) => {
|
|
// Build a clean config object for editing (excluding internal fields like id)
|
|
const editableConfig: Record<string, unknown> = {
|
|
name: server.name,
|
|
type: server.type || 'stdio',
|
|
};
|
|
|
|
if (server.description) {
|
|
editableConfig.description = server.description;
|
|
}
|
|
|
|
if (server.type === 'stdio' || !server.type) {
|
|
if (server.command) editableConfig.command = server.command;
|
|
if (server.args?.length) editableConfig.args = server.args;
|
|
if (server.env && Object.keys(server.env).length > 0) editableConfig.env = server.env;
|
|
} else {
|
|
if (server.url) editableConfig.url = server.url;
|
|
if (server.headers && Object.keys(server.headers).length > 0) {
|
|
editableConfig.headers = server.headers;
|
|
}
|
|
}
|
|
|
|
if (server.enabled === false) {
|
|
editableConfig.enabled = false;
|
|
}
|
|
|
|
setJsonEditValue(JSON.stringify(editableConfig, null, 2));
|
|
setJsonEditServer(server);
|
|
};
|
|
|
|
const handleSaveJsonEdit = async () => {
|
|
if (!jsonEditServer) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(jsonEditValue);
|
|
|
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
toast.error('Config must be a JSON object');
|
|
return;
|
|
}
|
|
|
|
// Validate required fields based on type
|
|
const serverType = parsed.type || 'stdio';
|
|
|
|
if (!parsed.name || typeof parsed.name !== 'string') {
|
|
toast.error('Name is required');
|
|
return;
|
|
}
|
|
|
|
if (serverType === 'stdio') {
|
|
if (!parsed.command || typeof parsed.command !== 'string') {
|
|
toast.error('Command is required for stdio servers');
|
|
return;
|
|
}
|
|
} else if (serverType === 'sse' || serverType === 'http') {
|
|
if (!parsed.url || typeof parsed.url !== 'string') {
|
|
toast.error('URL is required for SSE/HTTP servers');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Build update object
|
|
const updateData: Partial<MCPServerConfig> = {
|
|
name: parsed.name,
|
|
type: serverType,
|
|
description: parsed.description || undefined,
|
|
enabled: parsed.enabled !== false,
|
|
};
|
|
|
|
if (serverType === 'stdio') {
|
|
updateData.command = parsed.command;
|
|
updateData.args = Array.isArray(parsed.args) ? parsed.args : undefined;
|
|
updateData.env =
|
|
typeof parsed.env === 'object' && !Array.isArray(parsed.env) ? parsed.env : undefined;
|
|
// Clear HTTP fields
|
|
updateData.url = undefined;
|
|
updateData.headers = undefined;
|
|
} else {
|
|
updateData.url = parsed.url;
|
|
updateData.headers =
|
|
typeof parsed.headers === 'object' && !Array.isArray(parsed.headers)
|
|
? parsed.headers
|
|
: undefined;
|
|
// Clear stdio fields
|
|
updateData.command = undefined;
|
|
updateData.args = undefined;
|
|
updateData.env = undefined;
|
|
}
|
|
|
|
updateMCPServer(jsonEditServer.id, updateData);
|
|
const syncSuccess = await syncSettingsToServer();
|
|
if (!syncSuccess) {
|
|
toast.error('Failed to save settings to disk');
|
|
return;
|
|
}
|
|
toast.success('Server configuration updated');
|
|
setJsonEditServer(null);
|
|
setJsonEditValue('');
|
|
} catch (error) {
|
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
|
}
|
|
};
|
|
|
|
const handleOpenGlobalJsonEdit = () => {
|
|
// Build the full mcpServers config as array with IDs preserved
|
|
const exportData: Array<Record<string, unknown>> = [];
|
|
|
|
for (const server of mcpServers) {
|
|
const serverConfig: Record<string, unknown> = {
|
|
id: server.id,
|
|
name: server.name,
|
|
type: server.type || 'stdio',
|
|
enabled: server.enabled ?? true,
|
|
};
|
|
|
|
if (server.description) {
|
|
serverConfig.description = server.description;
|
|
}
|
|
|
|
if (server.type === 'stdio' || !server.type) {
|
|
serverConfig.command = server.command;
|
|
if (server.args?.length) serverConfig.args = server.args;
|
|
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
|
} else {
|
|
serverConfig.url = server.url;
|
|
if (server.headers && Object.keys(server.headers).length > 0) {
|
|
serverConfig.headers = server.headers;
|
|
}
|
|
}
|
|
|
|
exportData.push(serverConfig);
|
|
}
|
|
|
|
setGlobalJsonValue(JSON.stringify({ mcpServers: exportData }, null, 2));
|
|
setIsGlobalJsonEditOpen(true);
|
|
};
|
|
|
|
/** Helper to save array format (with IDs preserved) */
|
|
const handleSaveGlobalJsonArray = async (
|
|
serversArray: Array<Record<string, unknown>>
|
|
): Promise<boolean> => {
|
|
// Validate all servers first
|
|
const names = new Set<string>();
|
|
for (const serverConfig of serversArray) {
|
|
const name = serverConfig.name as string;
|
|
if (!name || typeof name !== 'string') {
|
|
toast.error('Each server must have a name');
|
|
return false;
|
|
}
|
|
if (names.has(name)) {
|
|
toast.error(`Duplicate server name found: "${name}"`);
|
|
return false;
|
|
}
|
|
names.add(name);
|
|
|
|
const serverType = (serverConfig.type as string) || 'stdio';
|
|
if (serverType === 'stdio') {
|
|
if (!serverConfig.command || typeof serverConfig.command !== 'string') {
|
|
toast.error(`Command is required for "${name}" (stdio)`);
|
|
return false;
|
|
}
|
|
} else if (serverType === 'sse' || serverType === 'http') {
|
|
if (!serverConfig.url || typeof serverConfig.url !== 'string') {
|
|
toast.error(`URL is required for "${name}" (${serverType})`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create maps for matching: by ID first, then by name
|
|
const existingById = new Map(mcpServers.map((s) => [s.id, s]));
|
|
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
|
const processedIds = new Set<string>();
|
|
|
|
// Update or add servers
|
|
for (const serverConfig of serversArray) {
|
|
const serverType = (serverConfig.type as ServerType) || 'stdio';
|
|
const serverId = serverConfig.id as string | undefined;
|
|
const serverName = serverConfig.name as string;
|
|
|
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
|
name: serverName,
|
|
type: serverType,
|
|
description: (serverConfig.description as string) || undefined,
|
|
enabled: serverConfig.enabled !== false,
|
|
};
|
|
|
|
if (serverType === 'stdio') {
|
|
serverData.command = serverConfig.command as string;
|
|
if (Array.isArray(serverConfig.args)) {
|
|
serverData.args = serverConfig.args as string[];
|
|
}
|
|
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
|
serverData.env = serverConfig.env as Record<string, string>;
|
|
}
|
|
} else {
|
|
serverData.url = serverConfig.url as string;
|
|
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
|
serverData.headers = serverConfig.headers as Record<string, string>;
|
|
}
|
|
}
|
|
|
|
// Match by ID first (allows renaming), then by name (backward compatibility)
|
|
const existingServer = serverId ? existingById.get(serverId) : existingByName.get(serverName);
|
|
|
|
if (existingServer) {
|
|
updateMCPServer(existingServer.id, serverData);
|
|
processedIds.add(existingServer.id);
|
|
} else {
|
|
addMCPServer(serverData);
|
|
// Get the newly added server ID
|
|
const newServers = useAppStore.getState().mcpServers;
|
|
const newServer = newServers.find((s) => s.name === serverName);
|
|
if (newServer) {
|
|
processedIds.add(newServer.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove servers that are no longer in the JSON
|
|
for (const server of mcpServers) {
|
|
if (!processedIds.has(server.id)) {
|
|
removeMCPServer(server.id);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
/** Helper to save object format (legacy Claude Desktop format) */
|
|
const handleSaveGlobalJsonObject = async (
|
|
serversObject: Record<string, Record<string, unknown>>
|
|
): Promise<boolean> => {
|
|
// Validate all servers first
|
|
for (const [name, config] of Object.entries(serversObject)) {
|
|
if (typeof config !== 'object' || config === null) {
|
|
toast.error(`Invalid config for "${name}"`);
|
|
return false;
|
|
}
|
|
|
|
const serverType = (config.type as string) || 'stdio';
|
|
if (serverType === 'stdio') {
|
|
if (!config.command || typeof config.command !== 'string') {
|
|
toast.error(`Command is required for "${name}" (stdio)`);
|
|
return false;
|
|
}
|
|
} else if (serverType === 'sse' || serverType === 'http') {
|
|
if (!config.url || typeof config.url !== 'string') {
|
|
toast.error(`URL is required for "${name}" (${serverType})`);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a map of existing servers by name for updating
|
|
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
|
const processedNames = new Set<string>();
|
|
|
|
// Update or add servers
|
|
for (const [name, config] of Object.entries(serversObject)) {
|
|
const serverType = (config.type as ServerType) || 'stdio';
|
|
|
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
|
name,
|
|
type: serverType,
|
|
description: (config.description as string) || undefined,
|
|
enabled: config.enabled !== false,
|
|
};
|
|
|
|
if (serverType === 'stdio') {
|
|
serverData.command = config.command as string;
|
|
if (Array.isArray(config.args)) {
|
|
serverData.args = config.args as string[];
|
|
}
|
|
if (typeof config.env === 'object' && config.env !== null) {
|
|
serverData.env = config.env as Record<string, string>;
|
|
}
|
|
} else {
|
|
serverData.url = config.url as string;
|
|
if (typeof config.headers === 'object' && config.headers !== null) {
|
|
serverData.headers = config.headers as Record<string, string>;
|
|
}
|
|
}
|
|
|
|
const existing = existingByName.get(name);
|
|
if (existing) {
|
|
updateMCPServer(existing.id, serverData);
|
|
} else {
|
|
addMCPServer(serverData);
|
|
}
|
|
processedNames.add(name);
|
|
}
|
|
|
|
// Remove servers that are no longer in the JSON
|
|
for (const server of mcpServers) {
|
|
if (!processedNames.has(server.name)) {
|
|
removeMCPServer(server.id);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleSaveGlobalJsonEdit = async () => {
|
|
try {
|
|
const parsed = JSON.parse(globalJsonValue);
|
|
|
|
// Support both formats:
|
|
// 1. Array format (new, with IDs): { mcpServers: [...] } or [...]
|
|
// 2. Object format (legacy Claude Desktop): { mcpServers: {...} } or {...}
|
|
const servers = parsed.mcpServers || parsed;
|
|
|
|
let success: boolean;
|
|
if (Array.isArray(servers)) {
|
|
// Array format - supports ID matching for renames
|
|
success = await handleSaveGlobalJsonArray(servers);
|
|
} else if (typeof servers === 'object' && servers !== null) {
|
|
// Object format - legacy Claude Desktop compatibility
|
|
success = await handleSaveGlobalJsonObject(servers);
|
|
} else {
|
|
toast.error('Invalid format: expected array or object with server configurations');
|
|
return;
|
|
}
|
|
|
|
if (!success) {
|
|
return;
|
|
}
|
|
|
|
const syncSuccess = await syncSettingsToServer();
|
|
if (!syncSuccess) {
|
|
toast.error('Failed to save settings to disk');
|
|
return;
|
|
}
|
|
toast.success('MCP servers configuration updated');
|
|
setIsGlobalJsonEditOpen(false);
|
|
setGlobalJsonValue('');
|
|
} catch (error) {
|
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
|
}
|
|
};
|
|
|
|
return {
|
|
// Store state
|
|
mcpServers,
|
|
mcpAutoApproveTools,
|
|
mcpUnrestrictedTools,
|
|
setMcpAutoApproveTools,
|
|
setMcpUnrestrictedTools,
|
|
|
|
// Dialog state
|
|
isAddDialogOpen,
|
|
setIsAddDialogOpen,
|
|
editingServer,
|
|
formData,
|
|
setFormData,
|
|
deleteConfirmId,
|
|
setDeleteConfirmId,
|
|
isImportDialogOpen,
|
|
setIsImportDialogOpen,
|
|
importJson,
|
|
setImportJson,
|
|
jsonEditServer,
|
|
setJsonEditServer,
|
|
jsonEditValue,
|
|
setJsonEditValue,
|
|
isGlobalJsonEditOpen,
|
|
setIsGlobalJsonEditOpen,
|
|
globalJsonValue,
|
|
setGlobalJsonValue,
|
|
|
|
// Security warning dialog state
|
|
isSecurityWarningOpen,
|
|
setIsSecurityWarningOpen,
|
|
pendingServerData,
|
|
|
|
// UI state
|
|
isRefreshing,
|
|
serverTestStates,
|
|
expandedServers,
|
|
|
|
// Computed
|
|
totalToolsCount,
|
|
showToolsWarning,
|
|
|
|
// Handlers
|
|
handleRefresh,
|
|
handleTestServer,
|
|
toggleServerExpanded,
|
|
handleOpenAddDialog,
|
|
handleOpenEditDialog,
|
|
handleCloseDialog,
|
|
handleSave,
|
|
handleToggleEnabled,
|
|
handleDelete,
|
|
handleImportJson,
|
|
handleExportJson,
|
|
handleOpenJsonEdit,
|
|
handleSaveJsonEdit,
|
|
handleOpenGlobalJsonEdit,
|
|
handleSaveGlobalJsonEdit,
|
|
handleSecurityWarningConfirm,
|
|
};
|
|
}
|