diff --git a/apps/server/package.json b/apps/server/package.json index 081c7f23..9f27c2a3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@automaker/prompts": "^1.0.0", "@automaker/types": "^1.0.0", "@automaker/utils": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.2.1", diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 80433f5b..269f78ac 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -18,7 +18,7 @@ import type { Options } from '@anthropic-ai/claude-agent-sdk'; import path from 'path'; import { resolveModelString } from '@automaker/model-resolver'; -import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types'; +import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types'; import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform'; /** @@ -136,6 +136,53 @@ function getBaseOptions(): Partial { }; } +/** + * MCP permission options result + */ +interface McpPermissionOptions { + /** Whether tools should be restricted to a preset */ + shouldRestrictTools: boolean; + /** Options to spread when MCP bypass is enabled */ + bypassOptions: Partial; + /** Options to spread for MCP servers */ + mcpServerOptions: Partial; +} + +/** + * Build MCP-related options based on configuration. + * Centralizes the logic for determining permission modes and tool restrictions + * when MCP servers are configured. + * + * @param config - The SDK options config + * @returns Object with MCP permission settings to spread into final options + */ +function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions { + const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0; + // Default to true - this is a deliberate design choice for ease of use with MCP servers. + // Users can disable these in settings for stricter security. + const mcpAutoApprove = config.mcpAutoApproveTools ?? true; + const mcpUnrestricted = config.mcpUnrestrictedTools ?? true; + + // Determine if we should bypass permissions based on settings + const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; + // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) + const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; + + return { + shouldRestrictTools, + // Only include bypass options when MCP is configured and auto-approve is enabled + bypassOptions: shouldBypassPermissions + ? { + permissionMode: 'bypassPermissions' as const, + // Required flag when using bypassPermissions mode + allowDangerouslySkipPermissions: true, + } + : {}, + // Include MCP servers if configured + mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {}, + }; +} + /** * Build system prompt configuration based on autoLoadClaudeMd setting. * When autoLoadClaudeMd is true: @@ -219,8 +266,25 @@ export interface CreateSdkOptionsConfig { /** Enable sandbox mode for bash command isolation */ enableSandboxMode?: boolean; + + /** MCP servers to make available to the agent */ + mcpServers?: Record; + + /** Auto-approve MCP tool calls without permission prompts */ + mcpAutoApproveTools?: boolean; + + /** Allow unrestricted tools when MCP servers are enabled */ + mcpUnrestrictedTools?: boolean; } +// Re-export MCP types from @automaker/types for convenience +export type { + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, +} from '@automaker/types'; + /** * Create SDK options for spec generation * @@ -330,12 +394,18 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + return { ...getBaseOptions(), model: getModelForUseCase('chat', effectiveModel), maxTurns: MAX_TURNS.standard, cwd: config.cwd, - allowedTools: [...TOOL_PRESETS.chat], + // Only restrict tools if no MCP servers configured or unrestricted is disabled + ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }), + // Apply MCP bypass options if configured + ...mcpOptions.bypassOptions, ...(config.enableSandboxMode && { sandbox: { enabled: true, @@ -344,6 +414,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options { }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, }; } @@ -364,12 +435,18 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + return { ...getBaseOptions(), model: getModelForUseCase('auto', config.model), maxTurns: MAX_TURNS.maximum, cwd: config.cwd, - allowedTools: [...TOOL_PRESETS.fullAccess], + // Only restrict tools if no MCP servers configured or unrestricted is disabled + ...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }), + // Apply MCP bypass options if configured + ...mcpOptions.bypassOptions, ...(config.enableSandboxMode && { sandbox: { enabled: true, @@ -378,6 +455,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options { }), ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, }; } @@ -400,14 +478,27 @@ export function createCustomOptions( // Build CLAUDE.md auto-loading options if enabled const claudeMdOptions = buildClaudeMdOptions(config); + // Build MCP-related options + const mcpOptions = buildMcpOptions(config); + + // For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings + const effectiveAllowedTools = config.allowedTools + ? [...config.allowedTools] + : mcpOptions.shouldRestrictTools + ? [...TOOL_PRESETS.readOnly] + : undefined; + return { ...getBaseOptions(), model: getModelForUseCase('default', config.model), maxTurns: config.maxTurns ?? MAX_TURNS.maximum, cwd: config.cwd, - allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly], + ...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }), ...(config.sandbox && { sandbox: config.sandbox }), + // Apply MCP bypass options if configured + ...mcpOptions.bypassOptions, ...claudeMdOptions, ...(config.abortController && { abortController: config.abortController }), + ...mcpOptions.mcpServerOptions, }; } diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index dc057873..21211b33 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -4,6 +4,7 @@ import type { SettingsService } from '../services/settings-service.js'; import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils'; +import type { MCPServerConfig, McpServerConfig } from '@automaker/types'; /** * Get the autoLoadClaudeMd setting, with project settings taking precedence over global. @@ -136,3 +137,120 @@ function formatContextFileEntry(file: ContextFileInfo): string { const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : ''; return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`; } + +/** + * Get enabled MCP servers from global settings, converted to SDK format. + * Returns an empty object if settings service is not available or no servers are configured. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @returns Promise resolving to MCP servers in SDK format (keyed by name) + */ +export async function getMCPServersFromSettings( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise> { + if (!settingsService) { + return {}; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const mcpServers = globalSettings.mcpServers || []; + + // Filter to only enabled servers and convert to SDK format + const enabledServers = mcpServers.filter((s) => s.enabled !== false); + + if (enabledServers.length === 0) { + return {}; + } + + // Convert settings format to SDK format (keyed by name) + const sdkServers: Record = {}; + for (const server of enabledServers) { + sdkServers[server.name] = convertToSdkFormat(server); + } + + console.log( + `${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}` + ); + + return sdkServers; + } catch (error) { + console.error(`${logPrefix} Failed to load MCP servers setting:`, error); + return {}; + } +} + +/** + * Get MCP permission settings from global settings. + * + * @param settingsService - Optional settings service instance + * @param logPrefix - Prefix for log messages (e.g., '[AgentService]') + * @returns Promise resolving to MCP permission settings + */ +export async function getMCPPermissionSettings( + settingsService?: SettingsService | null, + logPrefix = '[SettingsHelper]' +): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> { + // Default values (both enabled for backwards compatibility) + const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true }; + + if (!settingsService) { + return defaults; + } + + try { + const globalSettings = await settingsService.getGlobalSettings(); + const result = { + mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true, + mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true, + }; + console.log( + `${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}` + ); + return result; + } catch (error) { + console.error(`${logPrefix} Failed to load MCP permission settings:`, error); + return defaults; + } +} + +/** + * Convert a settings MCPServerConfig to SDK McpServerConfig format. + * Validates required fields and throws informative errors if missing. + */ +function convertToSdkFormat(server: MCPServerConfig): McpServerConfig { + if (server.type === 'sse') { + if (!server.url) { + throw new Error(`SSE MCP server "${server.name}" is missing a URL.`); + } + return { + type: 'sse', + url: server.url, + headers: server.headers, + }; + } + + if (server.type === 'http') { + if (!server.url) { + throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`); + } + return { + type: 'http', + url: server.url, + headers: server.headers, + }; + } + + // Default to stdio + if (!server.command) { + throw new Error(`Stdio MCP server "${server.name}" is missing a command.`); + } + return { + type: 'stdio', + command: server.command, + args: server.args, + env: server.env, + }; +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 716e94ca..3c765304 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -36,16 +36,32 @@ export class ClaudeProvider extends BaseProvider { } = options; // Build Claude SDK options + // MCP permission logic - determines how to handle tool permissions when MCP servers are configured. + // This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since + // the provider is the final point where SDK options are constructed. + const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0; + // Default to true - deliberate design choice for ease of use. Users can disable in settings. + const mcpAutoApprove = options.mcpAutoApproveTools ?? true; + const mcpUnrestricted = options.mcpUnrestrictedTools ?? true; const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; - const toolsToUse = allowedTools || defaultTools; + + // Determine permission mode based on settings + const shouldBypassPermissions = hasMcpServers && mcpAutoApprove; + // Determine if we should restrict tools (only when no MCP or unrestricted is disabled) + const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted; const sdkOptions: Options = { model, systemPrompt, maxTurns, cwd, - allowedTools: toolsToUse, - permissionMode: 'default', + // Only restrict tools if explicitly set OR (no MCP / unrestricted disabled) + ...(allowedTools && shouldRestrictTools && { allowedTools }), + ...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }), + // When MCP servers are configured and auto-approve is enabled, use bypassPermissions + permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default', + // Required when using bypassPermissions mode + ...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }), abortController, // Resume existing SDK session if we have a session ID ...(sdkSessionId && conversationHistory && conversationHistory.length > 0 @@ -55,6 +71,8 @@ export class ClaudeProvider extends BaseProvider { ...(options.settingSources && { settingSources: options.settingSources }), // Forward sandbox configuration ...(options.sandbox && { sandbox: options.sandbox }), + // Forward MCP servers configuration + ...(options.mcpServers && { mcpServers: options.mcpServers }), }; // Build prompt payload diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts index 17b45066..a3dcf58c 100644 --- a/apps/server/src/providers/types.ts +++ b/apps/server/src/providers/types.ts @@ -1,41 +1,19 @@ /** * Shared types for AI model providers + * + * Re-exports types from @automaker/types for consistency across the codebase. */ -/** - * Configuration for a provider instance - */ -export interface ProviderConfig { - apiKey?: string; - cliPath?: string; - env?: Record; -} - -/** - * Message in conversation history - */ -export interface ConversationMessage { - role: 'user' | 'assistant'; - content: string | Array<{ type: string; text?: string; source?: object }>; -} - -/** - * Options for executing a query via a provider - */ -export interface ExecuteOptions { - prompt: string | Array<{ type: string; text?: string; source?: object }>; - model: string; - cwd: string; - systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string }; - maxTurns?: number; - allowedTools?: string[]; - mcpServers?: Record; - abortController?: AbortController; - conversationHistory?: ConversationMessage[]; // Previous messages for context - sdkSessionId?: string; // Claude SDK session ID for resuming conversations - settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load - sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration -} +// Re-export all provider types from @automaker/types +export type { + ProviderConfig, + ConversationMessage, + ExecuteOptions, + McpServerConfig, + McpStdioServerConfig, + McpSSEServerConfig, + McpHttpServerConfig, +} from '@automaker/types'; /** * Content block in a provider message (matches Claude SDK format) diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index dc6d4594..63c220db 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -21,6 +21,8 @@ import { getAutoLoadClaudeMdSetting, getEnableSandboxModeSetting, filterClaudeMdFromContext, + getMCPServersFromSettings, + getMCPPermissionSettings, } from '../lib/settings-helpers.js'; interface Message { @@ -227,6 +229,12 @@ export class AgentService { '[AgentService]' ); + // Load MCP servers from settings (global setting only) + const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]'); + + // Load MCP permission settings (global setting only) + const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]'); + // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) const contextResult = await loadContextFiles({ projectPath: effectiveWorkDir, @@ -252,6 +260,9 @@ export class AgentService { abortController: session.abortController!, autoLoadClaudeMd, enableSandboxMode, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -275,6 +286,9 @@ export class AgentService { settingSources: sdkOptions.settingSources, sandbox: sdkOptions.sandbox, // Pass sandbox configuration sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Build prompt content with images diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 987554f2..da9afcd1 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -36,6 +36,8 @@ import { getAutoLoadClaudeMdSetting, getEnableSandboxModeSetting, filterClaudeMdFromContext, + getMCPServersFromSettings, + getMCPPermissionSettings, } from '../lib/settings-helpers.js'; const execAsync = promisify(exec); @@ -1841,6 +1843,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. // Load enableSandboxMode setting (global setting only) const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]'); + // Load MCP servers from settings (global setting only) + const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]'); + + // Load MCP permission settings (global setting only) + const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]'); + // Build SDK options using centralized configuration for feature implementation const sdkOptions = createAutoModeOptions({ cwd: workDir, @@ -1848,6 +1856,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. abortController, autoLoadClaudeMd, enableSandboxMode, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); // Extract model, maxTurns, and allowedTools from SDK options @@ -1889,6 +1900,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set. systemPrompt: sdkOptions.systemPrompt, settingSources: sdkOptions.settingSources, sandbox: sdkOptions.sandbox, // Pass sandbox configuration + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting }; // Execute via provider @@ -2116,6 +2130,9 @@ After generating the revised spec, output: cwd: workDir, allowedTools: allowedTools, abortController, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let revisionText = ''; @@ -2253,6 +2270,9 @@ After generating the revised spec, output: cwd: workDir, allowedTools: allowedTools, abortController, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); let taskOutput = ''; @@ -2342,6 +2362,9 @@ Implement all the changes described in the plan above.`; cwd: workDir, allowedTools: allowedTools, abortController, + mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, + mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, + mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, }); for await (const msg of continuationStream) { diff --git a/apps/ui/src/components/views/context-view.tsx b/apps/ui/src/components/views/context-view.tsx index 1ea9c06e..d81d6210 100644 --- a/apps/ui/src/components/views/context-view.tsx +++ b/apps/ui/src/components/views/context-view.tsx @@ -178,6 +178,13 @@ export function ContextView() { // Ensure context directory exists await api.mkdir(contextPath); + // Ensure metadata file exists (create empty one if not) + const metadataPath = `${contextPath}/context-metadata.json`; + const metadataExists = await api.exists(metadataPath); + if (!metadataExists) { + await api.writeFile(metadataPath, JSON.stringify({ files: {} }, null, 2)); + } + // Load metadata for descriptions const metadata = await loadMetadata(); diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 6ea52add..70d19e5f 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -18,6 +18,7 @@ import { AudioSection } from './settings-view/audio/audio-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; +import { MCPServersSection } from './settings-view/mcp-servers'; import type { Project as SettingsProject, Theme } from './settings-view/shared/types'; import type { Project as ElectronProject } from '@/lib/electron'; @@ -116,6 +117,8 @@ export function SettingsView() { {showUsageTracking && } ); + case 'mcp-servers': + return ; case 'ai-enhancement': return ; case 'appearance': diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts index e32c2223..d478bb4d 100644 --- a/apps/ui/src/components/views/settings-view/config/navigation.ts +++ b/apps/ui/src/components/views/settings-view/config/navigation.ts @@ -9,6 +9,7 @@ import { FlaskConical, Trash2, Sparkles, + Plug, } from 'lucide-react'; import type { SettingsViewId } from '../hooks/use-settings-view'; @@ -22,6 +23,7 @@ export interface NavigationItem { export const NAV_ITEMS: NavigationItem[] = [ { id: 'api-keys', label: 'API Keys', icon: Key }, { id: 'claude', label: 'Claude', icon: Terminal }, + { id: 'mcp-servers', label: 'MCP Servers', icon: Plug }, { id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles }, { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'terminal', label: 'Terminal', icon: SquareTerminal }, diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts index 2e3f784f..48c406b2 100644 --- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts +++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts @@ -3,6 +3,7 @@ import { useState, useCallback } from 'react'; export type SettingsViewId = | 'api-keys' | 'claude' + | 'mcp-servers' | 'ai-enhancement' | 'appearance' | 'terminal' diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/index.ts b/apps/ui/src/components/views/settings-view/mcp-servers/index.ts new file mode 100644 index 00000000..bd267fe2 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/index.ts @@ -0,0 +1 @@ +export { MCPServersSection } from './mcp-servers-section'; diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx new file mode 100644 index 00000000..70863dd1 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/mcp-servers-section.tsx @@ -0,0 +1,647 @@ +import { useState, useEffect } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Plug, + Plus, + Pencil, + Trash2, + Terminal, + Globe, + FileJson, + Download, + RefreshCw, +} from 'lucide-react'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { toast } from 'sonner'; +import type { MCPServerConfig } from '@automaker/types'; +import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration'; + +type ServerType = 'stdio' | 'sse' | 'http'; + +interface ServerFormData { + name: string; + description: string; + type: ServerType; + command: string; + args: string; + url: string; +} + +const defaultFormData: ServerFormData = { + name: '', + description: '', + type: 'stdio', + command: '', + args: '', + url: '', +}; + +export function MCPServersSection() { + const { + mcpServers, + addMCPServer, + updateMCPServer, + removeMCPServer, + mcpAutoApproveTools, + mcpUnrestrictedTools, + setMcpAutoApproveTools, + setMcpUnrestrictedTools, + } = useAppStore(); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [editingServer, setEditingServer] = useState(null); + const [formData, setFormData] = useState(defaultFormData); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + const [importJson, setImportJson] = useState(''); + const [isRefreshing, setIsRefreshing] = useState(false); + + // Auto-load MCP servers from settings file on mount + useEffect(() => { + loadMCPServersFromServer().catch((error) => { + console.error('Failed to load MCP servers on mount:', error); + }); + }, []); + + 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 (error) { + toast.error('Error refreshing MCP servers'); + } finally { + setIsRefreshing(false); + } + }; + + 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 || '', + }); + 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; + } + + const serverData: Omit = { + 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+/); + } + } else { + serverData.url = formData.url.trim(); + } + + if (editingServer) { + updateMCPServer(editingServer.id, serverData); + toast.success('MCP server updated'); + } else { + addMCPServer(serverData); + toast.success('MCP server added'); + } + + await syncSettingsToServer(); + handleCloseDialog(); + }; + + const handleToggleEnabled = async (server: MCPServerConfig) => { + updateMCPServer(server.id, { enabled: !server.enabled }); + await syncSettingsToServer(); + toast.success(server.enabled ? 'Server disabled' : 'Server enabled'); + }; + + const handleDelete = async (id: string) => { + removeMCPServer(id); + await syncSettingsToServer(); + setDeleteConfirmId(null); + toast.success('MCP server removed'); + }; + + const getServerIcon = (type: ServerType = 'stdio') => { + if (type === 'stdio') return Terminal; + return Globe; + }; + + const handleImportJson = async () => { + try { + const parsed = JSON.parse(importJson); + + // Support both formats: + // 1. Claude Code format: { "mcpServers": { "name": { command, args, ... } } } + // 2. Direct format: { "name": { command, args, ... } } + const servers = parsed.mcpServers || parsed; + + if (typeof servers !== 'object' || Array.isArray(servers)) { + toast.error('Invalid format: expected object with server configurations'); + return; + } + + let addedCount = 0; + let skippedCount = 0; + + for (const [name, config] of Object.entries(servers)) { + if (typeof config !== 'object' || config === null) continue; + + const serverConfig = config as Record; + + // Check if server with this name already exists + if (mcpServers.some((s) => s.name === name)) { + skippedCount++; + continue; + } + + const serverData: Omit = { + name, + type: (serverConfig.type as ServerType) || 'stdio', + enabled: true, + }; + + if (serverData.type === 'stdio') { + if (!serverConfig.command) { + console.warn(`Skipping ${name}: no command specified`); + skippedCount++; + continue; + } + 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; + } + } else { + if (!serverConfig.url) { + console.warn(`Skipping ${name}: no url specified`); + skippedCount++; + continue; + } + serverData.url = serverConfig.url as string; + if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) { + serverData.headers = serverConfig.headers as Record; + } + } + + addMCPServer(serverData); + addedCount++; + } + + await syncSettingsToServer(); + + if (addedCount > 0) { + toast.success(`Imported ${addedCount} MCP server${addedCount > 1 ? 's' : ''}`); + } + if (skippedCount > 0) { + toast.info( + `Skipped ${skippedCount} server${skippedCount > 1 ? 's' : ''} (already exist or invalid)` + ); + } + if (addedCount === 0 && skippedCount === 0) { + toast.warning('No servers found in JSON'); + } + + setIsImportDialogOpen(false); + setImportJson(''); + } catch (error) { + toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error')); + } + }; + + const handleExportJson = () => { + const exportData: Record> = {}; + + for (const server of mcpServers) { + const serverConfig: Record = { + type: server.type || 'stdio', + }; + + 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[server.name] = serverConfig; + } + + const json = JSON.stringify({ mcpServers: exportData }, null, 2); + navigator.clipboard.writeText(json); + toast.success('Copied to clipboard'); + }; + + return ( +
+ {/* Header */} +
+
+
+
+
+ +
+

MCP Servers

+
+

+ Configure Model Context Protocol servers to extend agent capabilities. +

+
+
+ + {mcpServers.length > 0 && ( + + )} + + +
+
+
+ + {/* Permission Settings */} + {mcpServers.length > 0 && ( +
+
+
+
+ +

+ Allow MCP tool calls without permission prompts (recommended) +

+
+ { + setMcpAutoApproveTools(checked); + await syncSettingsToServer(); + }} + data-testid="mcp-auto-approve-toggle" + /> +
+
+
+ +

+ Allow all tools when MCP is enabled (don't filter to default set) +

+
+ { + setMcpUnrestrictedTools(checked); + await syncSettingsToServer(); + }} + data-testid="mcp-unrestricted-toggle" + /> +
+
+
+ )} + + {/* Server List */} +
+ {mcpServers.length === 0 ? ( +
+ +

No MCP servers configured

+

Add a server to extend agent capabilities

+
+ ) : ( +
+ {mcpServers.map((server) => { + const Icon = getServerIcon(server.type); + return ( +
+
+
+ +
+
+
{server.name}
+ {server.description && ( +
{server.description}
+ )} +
+ {server.type === 'stdio' + ? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}` + : server.url} +
+
+
+
+ handleToggleEnabled(server)} + data-testid={`mcp-server-toggle-${server.id}`} + /> + + +
+
+ ); + })} +
+ )} +
+ + {/* Add/Edit Dialog */} + + + + {editingServer ? 'Edit MCP Server' : 'Add MCP Server'} + + Configure an MCP server to extend agent capabilities with custom tools. + + +
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="my-mcp-server" + data-testid="mcp-server-name-input" + /> +
+
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="What this server provides..." + data-testid="mcp-server-description-input" + /> +
+
+ + +
+ {formData.type === 'stdio' ? ( + <> +
+ + setFormData({ ...formData, command: e.target.value })} + placeholder="npx, node, python, etc." + data-testid="mcp-server-command-input" + /> +
+
+ + setFormData({ ...formData, args: e.target.value })} + placeholder="-y @modelcontextprotocol/server-filesystem" + data-testid="mcp-server-args-input" + /> +
+ + ) : ( +
+ + setFormData({ ...formData, url: e.target.value })} + placeholder="https://example.com/mcp" + data-testid="mcp-server-url-input" + /> +
+ )} +
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + setDeleteConfirmId(null)}> + + + Delete MCP Server + + Are you sure you want to delete this MCP server? This action cannot be undone. + + + + + + + + + + {/* Import JSON Dialog */} + + + + Import MCP Servers + + Paste JSON configuration in Claude Code format. Servers with duplicate names will be + skipped. + + +
+