-
Notifications
You must be signed in to change notification settings - Fork 576
fix: race condition and JSON format issues in MCP server management #319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1f493d2
3fbe662
1a11405
628ccc9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -276,8 +276,15 @@ export function useMCPServers() { | |
| // If editing an existing server, save directly (user already approved it) | ||
| if (editingServer) { | ||
| updateMCPServer(editingServer.id, serverData); | ||
|
|
||
| const syncSuccess = await syncSettingsToServer(); | ||
|
|
||
| if (!syncSuccess) { | ||
| toast.error('Failed to save MCP server to disk'); | ||
| return; | ||
| } | ||
|
|
||
| toast.success('MCP server updated'); | ||
| await syncSettingsToServer(); | ||
| handleCloseDialog(); | ||
| return; | ||
| } | ||
|
|
@@ -303,14 +310,28 @@ export function useMCPServers() { | |
|
|
||
| if (pendingServerData.type === 'add' && pendingServerData.serverData) { | ||
| addMCPServer(pendingServerData.serverData); | ||
|
|
||
| const syncSuccess = await syncSettingsToServer(); | ||
|
|
||
| if (!syncSuccess) { | ||
| toast.error('Failed to save MCP server to disk'); | ||
| return; | ||
| } | ||
|
|
||
| toast.success('MCP server added'); | ||
| await syncSettingsToServer(); | ||
| handleCloseDialog(); | ||
| } else if (pendingServerData.type === 'import' && pendingServerData.importServers) { | ||
| for (const serverData of pendingServerData.importServers) { | ||
| addMCPServer(serverData); | ||
| } | ||
| await syncSettingsToServer(); | ||
|
|
||
| const syncSuccess = await syncSettingsToServer(); | ||
|
|
||
| if (!syncSuccess) { | ||
| toast.error('Failed to save MCP servers to disk'); | ||
| return; | ||
| } | ||
|
|
||
| const count = pendingServerData.importServers.length; | ||
| toast.success(`Imported ${count} MCP server${count > 1 ? 's' : ''}`); | ||
| setIsImportDialogOpen(false); | ||
|
|
@@ -323,13 +344,27 @@ export function useMCPServers() { | |
|
|
||
| const handleToggleEnabled = async (server: MCPServerConfig) => { | ||
| updateMCPServer(server.id, { enabled: !server.enabled }); | ||
| await syncSettingsToServer(); | ||
|
|
||
| const syncSuccess = await syncSettingsToServer(); | ||
|
|
||
| if (!syncSuccess) { | ||
| toast.error('Failed to save MCP server to disk'); | ||
| return; | ||
| } | ||
|
|
||
| toast.success(server.enabled ? 'Server disabled' : 'Server enabled'); | ||
| }; | ||
|
|
||
| const handleDelete = async (id: string) => { | ||
| removeMCPServer(id); | ||
| await syncSettingsToServer(); | ||
|
|
||
| const syncSuccess = await syncSettingsToServer(); | ||
|
|
||
| if (!syncSuccess) { | ||
| toast.error('Failed to save changes to disk'); | ||
| return; | ||
| } | ||
|
|
||
| setDeleteConfirmId(null); | ||
| toast.success('MCP server removed'); | ||
| }; | ||
|
Comment on lines
358
to
370
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deleted server reappears after failed sync. If 🤖 Prompt for AI Agents |
||
|
|
@@ -569,11 +604,11 @@ export function useMCPServers() { | |
| }; | ||
|
|
||
| const handleOpenGlobalJsonEdit = () => { | ||
| // Build the full mcpServers config object | ||
| const exportData: Record<string, Record<string, unknown>> = {}; | ||
|
|
||
| for (const server of mcpServers) { | ||
| // Build array of servers with IDs preserved | ||
| const serversArray = mcpServers.map((server) => { | ||
| const serverConfig: Record<string, unknown> = { | ||
| id: server.id, // Preserve ID | ||
| name: server.name, // Preserve name | ||
| type: server.type || 'stdio', | ||
| }; | ||
|
|
||
|
|
@@ -596,102 +631,199 @@ export function useMCPServers() { | |
| } | ||
| } | ||
|
|
||
| exportData[server.name] = serverConfig; | ||
| } | ||
| return serverConfig; | ||
| }); | ||
|
|
||
| setGlobalJsonValue(JSON.stringify({ mcpServers: exportData }, null, 2)); | ||
| setGlobalJsonValue(JSON.stringify({ mcpServers: serversArray }, null, 2)); | ||
| setIsGlobalJsonEditOpen(true); | ||
| }; | ||
|
|
||
| const handleSaveGlobalJsonEdit = async () => { | ||
| try { | ||
| const parsed = JSON.parse(globalJsonValue); | ||
|
|
||
| // Support both formats | ||
| // Support both formats: array and object | ||
| const servers = parsed.mcpServers || parsed; | ||
|
|
||
| if (typeof servers !== 'object' || Array.isArray(servers)) { | ||
| toast.error('Invalid format: expected object with server configurations'); | ||
| if (Array.isArray(servers)) { | ||
| // Array format (new format with IDs) | ||
| await handleSaveGlobalJsonArray(servers); | ||
| } else if (typeof servers === 'object' && servers !== null) { | ||
| // Object format (legacy Claude Desktop format) | ||
| await handleSaveGlobalJsonObject(servers); | ||
| } else { | ||
| toast.error('Invalid format: expected array or object with server configurations'); | ||
| return; | ||
| } | ||
|
|
||
| // Validate all servers first | ||
| for (const [name, config] of Object.entries(servers)) { | ||
| if (typeof config !== 'object' || config === null) { | ||
| toast.error(`Invalid config for "${name}"`); | ||
| return; | ||
| } | ||
| const syncSuccess = await syncSettingsToServer(); | ||
|
|
||
| const serverConfig = config as Record<string, unknown>; | ||
| const serverType = (serverConfig.type as string) || 'stdio'; | ||
| if (!syncSuccess) { | ||
| toast.error('Failed to save MCP servers to disk'); | ||
| return; | ||
| } | ||
|
|
||
| if (serverType === 'stdio') { | ||
| if (!serverConfig.command || typeof serverConfig.command !== 'string') { | ||
| toast.error(`Command is required for "${name}" (stdio)`); | ||
| return; | ||
| } | ||
| } else if (serverType === 'sse' || serverType === 'http') { | ||
| if (!serverConfig.url || typeof serverConfig.url !== 'string') { | ||
| toast.error(`URL is required for "${name}" (${serverType})`); | ||
| return; | ||
| } | ||
| toast.success('MCP servers configuration updated'); | ||
| setIsGlobalJsonEditOpen(false); | ||
| setGlobalJsonValue(''); | ||
| } catch (error) { | ||
| toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error')); | ||
| } | ||
|
Comment on lines
+669
to
+671
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current error handling prefixes all errors with 'Invalid JSON:', which can be misleading for data validation errors (e.g., a missing server name). It's better to distinguish between JSON syntax errors and other validation errors to provide clearer feedback to the user. } catch (error) {
if (error instanceof SyntaxError) {
toast.error('Invalid JSON: ' + error.message);
} else if (error instanceof Error) {
toast.error(error.message);
} else {
toast.error('An unknown error occurred while saving.');
}
} |
||
| }; | ||
|
|
||
| // Helper: Process array format (with IDs) | ||
| const handleSaveGlobalJsonArray = async (serversArray: unknown[]) => { | ||
| // Validate all servers first | ||
| for (const config of serversArray) { | ||
| if (typeof config !== 'object' || config === null) { | ||
| toast.error('Invalid server config in array'); | ||
| throw new Error('Invalid server config'); | ||
| } | ||
|
Comment on lines
+678
to
+681
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To avoid showing duplicate error toasts, the if (typeof config !== 'object' || config === null) {
throw new Error('Invalid server config in array');
} |
||
|
|
||
| const serverConfig = config as Record<string, unknown>; | ||
| const name = serverConfig.name as string; | ||
| const serverType = (serverConfig.type as string) || 'stdio'; | ||
|
|
||
| if (!name || typeof name !== 'string') { | ||
| toast.error('Server name is required'); | ||
| throw new Error('Server name required'); | ||
| } | ||
|
|
||
| if (serverType === 'stdio') { | ||
| if (!serverConfig.command || typeof serverConfig.command !== 'string') { | ||
| toast.error(`Command is required for "${name}" (stdio)`); | ||
| throw new Error('Command required'); | ||
| } | ||
| } else if (serverType === 'sse' || serverType === 'http') { | ||
| if (!serverConfig.url || typeof serverConfig.url !== 'string') { | ||
| toast.error(`URL is required for "${name}" (${serverType})`); | ||
| throw new Error('URL required'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // 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>(); | ||
| // Create maps of existing servers by ID and 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 [name, config] of Object.entries(servers)) { | ||
| const serverConfig = config as Record<string, unknown>; | ||
| const serverType = (serverConfig.type as ServerType) || 'stdio'; | ||
| // Update or add servers | ||
| for (const config of serversArray) { | ||
| const serverConfig = config as Record<string, unknown>; | ||
| const id = serverConfig.id as string | undefined; | ||
| const name = serverConfig.name as string; | ||
| const serverType = (serverConfig.type as ServerType) || 'stdio'; | ||
|
|
||
| const serverData: Omit<MCPServerConfig, 'id'> = { | ||
| name, | ||
| type: serverType, | ||
| description: (serverConfig.description as string) || undefined, | ||
| enabled: serverConfig.enabled !== false, | ||
| }; | ||
| const serverData: Omit<MCPServerConfig, 'id'> = { | ||
| name, | ||
| 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>; | ||
| } | ||
| 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>; | ||
| } | ||
| } | ||
|
|
||
| const existing = existingByName.get(name); | ||
| if (existing) { | ||
| updateMCPServer(existing.id, serverData); | ||
| } else { | ||
| addMCPServer(serverData); | ||
| // Try to match by ID first, then by name | ||
| const existing = id ? existingById.get(id) : existingByName.get(name); | ||
| if (existing) { | ||
| updateMCPServer(existing.id, serverData); | ||
| processedIds.add(existing.id); | ||
| } else { | ||
| addMCPServer(serverData); | ||
| } | ||
| } | ||
|
|
||
| // Remove servers that are no longer in the JSON | ||
| for (const server of mcpServers) { | ||
| if (!processedIds.has(server.id)) { | ||
| removeMCPServer(server.id); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| // Helper: Process object format (legacy Claude Desktop format) | ||
| const handleSaveGlobalJsonObject = async (servers: Record<string, unknown>) => { | ||
| // Validate all servers first | ||
| for (const [name, config] of Object.entries(servers)) { | ||
| if (typeof config !== 'object' || config === null) { | ||
| toast.error(`Invalid config for "${name}"`); | ||
| throw new Error('Invalid config'); | ||
| } | ||
|
|
||
| const serverConfig = config as Record<string, unknown>; | ||
| 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)`); | ||
| throw new Error('Command required'); | ||
| } | ||
| } else if (serverType === 'sse' || serverType === 'http') { | ||
| if (!serverConfig.url || typeof serverConfig.url !== 'string') { | ||
| toast.error(`URL is required for "${name}" (${serverType})`); | ||
| throw new Error('URL required'); | ||
| } | ||
| processedNames.add(name); | ||
| } | ||
| } | ||
|
|
||
| // 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>(); | ||
|
|
||
| // Remove servers that are no longer in the JSON | ||
| for (const server of mcpServers) { | ||
| if (!processedNames.has(server.name)) { | ||
| removeMCPServer(server.id); | ||
| // Update or add servers | ||
| for (const [name, config] of Object.entries(servers)) { | ||
| const serverConfig = config as Record<string, unknown>; | ||
| const serverType = (serverConfig.type as ServerType) || 'stdio'; | ||
|
|
||
| const serverData: Omit<MCPServerConfig, 'id'> = { | ||
| name, | ||
| 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>; | ||
| } | ||
| } | ||
|
|
||
| await syncSettingsToServer(); | ||
| const existing = existingByName.get(name); | ||
| if (existing) { | ||
| updateMCPServer(existing.id, serverData); | ||
| } else { | ||
| addMCPServer(serverData); | ||
| } | ||
| processedNames.add(name); | ||
| } | ||
|
|
||
| toast.success('MCP servers configuration updated'); | ||
| setIsGlobalJsonEditOpen(false); | ||
| setGlobalJsonValue(''); | ||
| } catch (error) { | ||
| toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error')); | ||
| // Remove servers that are no longer in the JSON | ||
| for (const server of mcpServers) { | ||
| if (!processedNames.has(server.name)) { | ||
| removeMCPServer(server.id); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
State inconsistency on sync failure.
When
syncSettingsToServer()fails, the local Zustand store already contains the update fromupdateMCPServer(), but the disk state doesn't. The user sees an error toast, yet the UI shows the updated server. On next app restart, the changes will be lost.Consider rolling back the local state on sync failure, or at minimum, inform the user that the change exists only locally.
🔎 Proposed fix with rollback
if (editingServer) { + // Capture previous state for potential rollback + const previousState = mcpServers.find(s => s.id === editingServer.id); updateMCPServer(editingServer.id, serverData); const syncSuccess = await syncSettingsToServer(); if (!syncSuccess) { + // Rollback local state + if (previousState) { + updateMCPServer(editingServer.id, previousState); + } toast.error('Failed to save MCP server to disk'); return; }