diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index 8a6f8f8b64..dd32b6ae31 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { ChevronDown, Plus, Search, X } from 'lucide-react' +import { Braces, ChevronDown, List, Plus, Search, X } from 'lucide-react' import { useParams } from 'next/navigation' import { Badge, @@ -13,6 +13,7 @@ import { ModalContent, ModalFooter, ModalHeader, + Textarea, Tooltip, } from '@/components/emcn' import { Input } from '@/components/ui' @@ -438,6 +439,9 @@ export function MCP({ initialServerId }: MCPProps) { const [showAddForm, setShowAddForm] = useState(false) const [formData, setFormData] = useState(DEFAULT_FORM_DATA) const [isAddingServer, setIsAddingServer] = useState(false) + const [addFormMode, setAddFormMode] = useState<'form' | 'json'>('form') + const [jsonInput, setJsonInput] = useState('') + const [jsonError, setJsonError] = useState(null) const [searchTerm, setSearchTerm] = useState('') const [deletingServers, setDeletingServers] = useState>(new Set()) @@ -501,6 +505,9 @@ export function MCP({ initialServerId }: MCPProps) { const resetForm = useCallback(() => { setFormData(DEFAULT_FORM_DATA) setShowAddForm(false) + setAddFormMode('form') + setJsonInput('') + setJsonError(null) resetEnvVarState() clearTestResult() }, [clearTestResult, resetEnvVarState]) @@ -650,6 +657,138 @@ export function MCP({ initialServerId }: MCPProps) { } }, [formData, testConnection, createServerMutation, workspaceId, headersToRecord, resetForm]) + /** + * Extracts string-only headers from an unknown value. + */ + const extractStringHeaders = useCallback((headers: unknown): Record => { + if (typeof headers !== 'object' || headers === null) return {} + return Object.fromEntries( + Object.entries(headers).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ) + }, []) + + /** + * Parses MCP JSON config into form data. + * Accepts both `{ mcpServers: { name: { url, headers } } }` and `{ url, headers }` formats. + */ + const parseJsonConfig = useCallback( + (json: string): { name: string; url: string; headers: Record } | null => { + try { + const parsed = JSON.parse(json) + + if (parsed.mcpServers && typeof parsed.mcpServers === 'object') { + const entries = Object.entries(parsed.mcpServers) + if (entries.length === 0) { + setJsonError('No servers found in mcpServers') + return null + } + const [name, config] = entries[0] as [string, Record] + if (!config.url || typeof config.url !== 'string') { + setJsonError('Server config must include a "url" field') + return null + } + setJsonError(null) + return { + name, + url: config.url, + headers: extractStringHeaders(config.headers), + } + } + + if (parsed.url && typeof parsed.url === 'string') { + setJsonError(null) + return { + name: '', + url: parsed.url, + headers: extractStringHeaders(parsed.headers), + } + } + + setJsonError('JSON must contain "mcpServers" or a "url" field') + return null + } catch { + setJsonError('Invalid JSON') + return null + } + }, + [extractStringHeaders] + ) + + /** + * Validates parsed JSON config for name and domain requirements. + * Returns the config if valid, null otherwise (sets jsonError on failure). + */ + const validateJsonConfig = useCallback((): { + name: string + url: string + headers: Record + } | null => { + const config = parseJsonConfig(jsonInput) + if (!config) return null + + if (!config.name) { + setJsonError( + 'Server name is required. Use the mcpServers format: { "mcpServers": { "name": { ... } } }' + ) + return null + } + + if (!isDomainAllowed(config.url, allowedMcpDomains)) { + setJsonError('Domain not permitted by server policy') + return null + } + + return config + }, [jsonInput, parseJsonConfig, allowedMcpDomains]) + + /** + * Adds an MCP server from parsed JSON config. + */ + const handleAddServerFromJson = useCallback(async () => { + const config = validateJsonConfig() + if (!config) return + + setIsAddingServer(true) + try { + const serverConfig = { + name: config.name, + transport: 'streamable-http' as const, + url: config.url, + headers: config.headers, + timeout: 30000, + workspaceId, + } + + const connectionResult = await testConnection(serverConfig) + + if (!connectionResult.success) { + logger.error('Connection test failed, server not added:', connectionResult.error) + return + } + + await createServerMutation.mutateAsync({ + workspaceId, + config: { + name: config.name, + transport: 'streamable-http', + url: config.url, + timeout: 30000, + headers: config.headers, + enabled: true, + }, + }) + + logger.info(`Added MCP server from JSON: ${config.name}`) + resetForm() + } catch (error) { + logger.error('Failed to add MCP server from JSON:', error) + } finally { + setIsAddingServer(false) + } + }, [validateJsonConfig, testConnection, createServerMutation, workspaceId, resetForm]) + /** * Opens the delete confirmation dialog for an MCP server. */ @@ -1458,102 +1597,184 @@ export function MCP({ initialServerId }: MCPProps) { {shouldShowForm && !serversLoading && (
- - { - if (testResult) clearTestResult() - handleNameChange(e.target.value) - }} - className='h-9' - /> - - - - handleInputChange('url', e.target.value)} - onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)} - /> - {isAddDomainBlocked && ( -

- Domain not permitted by server policy -

- )} -
+
+ + + + + + {addFormMode === 'form' ? 'Switch to JSON' : 'Switch to form'} + + +
-
-
- - Headers - - -
+ {addFormMode === 'json' ? ( + <> +