diff --git a/apps/server/package.json b/apps/server/package.json index 081c7f23e..9f27c2a3c 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/index.ts b/apps/server/src/index.ts index 72999c138..4c8f0421d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -50,6 +50,8 @@ import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; +import { createMCPRoutes } from './routes/mcp/index.js'; +import { MCPTestService } from './services/mcp-test-service.js'; import { createPipelineRoutes } from './routes/pipeline/index.js'; import { pipelineService } from './services/pipeline-service.js'; @@ -103,9 +105,13 @@ if (ENABLE_REQUEST_LOGGING) { }) ); } +// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks +// from malicious websites. MCP server endpoints can execute arbitrary commands, +// so allowing any origin would enable RCE from any website visited while Automaker runs. +const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007']; app.use( cors({ - origin: process.env.CORS_ORIGIN || '*', + origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS, credentials: true, }) ); @@ -121,6 +127,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService); const featureLoader = new FeatureLoader(); const autoModeService = new AutoModeService(events, settingsService); const claudeUsageService = new ClaudeUsageService(); +const mcpTestService = new MCPTestService(settingsService); // Initialize services (async () => { @@ -164,6 +171,7 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); +app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService)); // Create HTTP server diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 80433f5b0..327ec0591 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 for autonomous workflow. Security is enforced when adding servers + // via the security warning dialog that explains the risks. + 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 dc0578735..fee359d5e 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,121 @@ 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 to true for autonomous workflow. Security is enforced when adding servers + // via the security warning dialog that explains the risks. + 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 716e94ca0..7c978785b 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -36,16 +36,33 @@ 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 for autonomous workflow. Security is enforced when adding servers + // via the security warning dialog that explains the risks. + 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 +72,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 17b450666..a3dcf58cd 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/routes/mcp/common.ts b/apps/server/src/routes/mcp/common.ts new file mode 100644 index 000000000..5da4789cc --- /dev/null +++ b/apps/server/src/routes/mcp/common.ts @@ -0,0 +1,20 @@ +/** + * Common utilities for MCP routes + */ + +/** + * Extract error message from unknown error + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Log error with prefix + */ +export function logError(error: unknown, message: string): void { + console.error(`[MCP] ${message}:`, error); +} diff --git a/apps/server/src/routes/mcp/index.ts b/apps/server/src/routes/mcp/index.ts new file mode 100644 index 000000000..2c3a023f4 --- /dev/null +++ b/apps/server/src/routes/mcp/index.ts @@ -0,0 +1,36 @@ +/** + * MCP routes - HTTP API for testing MCP servers + * + * Provides endpoints for: + * - Testing MCP server connections + * - Listing available tools from MCP servers + * + * Mounted at /api/mcp in the main server. + */ + +import { Router } from 'express'; +import type { MCPTestService } from '../../services/mcp-test-service.js'; +import { createTestServerHandler } from './routes/test-server.js'; +import { createListToolsHandler } from './routes/list-tools.js'; + +/** + * Create MCP router with all endpoints + * + * Endpoints: + * - POST /test - Test MCP server connection + * - POST /tools - List tools from MCP server + * + * @param mcpTestService - Instance of MCPTestService for testing connections + * @returns Express Router configured with all MCP endpoints + */ +export function createMCPRoutes(mcpTestService: MCPTestService): Router { + const router = Router(); + + // Test MCP server connection + router.post('/test', createTestServerHandler(mcpTestService)); + + // List tools from MCP server + router.post('/tools', createListToolsHandler(mcpTestService)); + + return router; +} diff --git a/apps/server/src/routes/mcp/routes/list-tools.ts b/apps/server/src/routes/mcp/routes/list-tools.ts new file mode 100644 index 000000000..d21518041 --- /dev/null +++ b/apps/server/src/routes/mcp/routes/list-tools.ts @@ -0,0 +1,57 @@ +/** + * POST /api/mcp/tools - List tools for an MCP server + * + * Lists available tools for an MCP server. + * Similar to test but focused on tool discovery. + * + * SECURITY: Only accepts serverId to look up saved configs. Does NOT accept + * arbitrary serverConfig to prevent drive-by command execution attacks. + * Users must explicitly save a server config through the UI before testing. + * + * Request body: + * { serverId: string } - Get tools by server ID from settings + * + * Response: { success: boolean, tools?: MCPToolInfo[], error?: string } + */ + +import type { Request, Response } from 'express'; +import type { MCPTestService } from '../../../services/mcp-test-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface ListToolsRequest { + serverId: string; +} + +/** + * Create handler factory for POST /api/mcp/tools + */ +export function createListToolsHandler(mcpTestService: MCPTestService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body as ListToolsRequest; + + if (!body.serverId || typeof body.serverId !== 'string') { + res.status(400).json({ + success: false, + error: 'serverId is required', + }); + return; + } + + const result = await mcpTestService.testServerById(body.serverId); + + // Return only tool-related information + res.json({ + success: result.success, + tools: result.tools, + error: result.error, + }); + } catch (error) { + logError(error, 'List tools failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/mcp/routes/test-server.ts b/apps/server/src/routes/mcp/routes/test-server.ts new file mode 100644 index 000000000..fb7581cfc --- /dev/null +++ b/apps/server/src/routes/mcp/routes/test-server.ts @@ -0,0 +1,50 @@ +/** + * POST /api/mcp/test - Test MCP server connection and list tools + * + * Tests connection to an MCP server and returns available tools. + * + * SECURITY: Only accepts serverId to look up saved configs. Does NOT accept + * arbitrary serverConfig to prevent drive-by command execution attacks. + * Users must explicitly save a server config through the UI before testing. + * + * Request body: + * { serverId: string } - Test server by ID from settings + * + * Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number } + */ + +import type { Request, Response } from 'express'; +import type { MCPTestService } from '../../../services/mcp-test-service.js'; +import { getErrorMessage, logError } from '../common.js'; + +interface TestServerRequest { + serverId: string; +} + +/** + * Create handler factory for POST /api/mcp/test + */ +export function createTestServerHandler(mcpTestService: MCPTestService) { + return async (req: Request, res: Response): Promise => { + try { + const body = req.body as TestServerRequest; + + if (!body.serverId || typeof body.serverId !== 'string') { + res.status(400).json({ + success: false, + error: 'serverId is required', + }); + return; + } + + const result = await mcpTestService.testServerById(body.serverId); + res.json(result); + } catch (error) { + logError(error, 'Test server failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index dc6d45948..63c220dbc 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 557fc79cf..9ba483615 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -37,6 +37,8 @@ import { getAutoLoadClaudeMdSetting, getEnableSandboxModeSetting, filterClaudeMdFromContext, + getMCPServersFromSettings, + getMCPPermissionSettings, } from '../lib/settings-helpers.js'; const execAsync = promisify(exec); @@ -1996,6 +1998,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, @@ -2003,6 +2011,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 @@ -2044,6 +2055,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 @@ -2271,6 +2285,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 = ''; @@ -2408,6 +2425,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 = ''; @@ -2497,6 +2517,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/server/src/services/mcp-test-service.ts b/apps/server/src/services/mcp-test-service.ts new file mode 100644 index 000000000..d16627228 --- /dev/null +++ b/apps/server/src/services/mcp-test-service.ts @@ -0,0 +1,208 @@ +/** + * MCP Test Service + * + * Provides functionality to test MCP server connections and list available tools. + * Supports stdio, SSE, and HTTP transport types. + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { MCPServerConfig, MCPToolInfo } from '@automaker/types'; +import type { SettingsService } from './settings-service.js'; + +const DEFAULT_TIMEOUT = 10000; // 10 seconds + +export interface MCPTestResult { + success: boolean; + tools?: MCPToolInfo[]; + error?: string; + connectionTime?: number; + serverInfo?: { + name?: string; + version?: string; + }; +} + +/** + * MCP Test Service for testing server connections and listing tools + */ +export class MCPTestService { + private settingsService: SettingsService; + + constructor(settingsService: SettingsService) { + this.settingsService = settingsService; + } + + /** + * Test connection to an MCP server and list its tools + */ + async testServer(serverConfig: MCPServerConfig): Promise { + const startTime = Date.now(); + let client: Client | null = null; + + try { + client = new Client({ + name: 'automaker-mcp-test', + version: '1.0.0', + }); + + // Create transport based on server type + const transport = await this.createTransport(serverConfig); + + // Connect with timeout + await Promise.race([ + client.connect(transport), + this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'), + ]); + + // List tools with timeout + const toolsResult = await Promise.race([ + client.listTools(), + this.timeout<{ + tools: Array<{ + name: string; + description?: string; + inputSchema?: Record; + }>; + }>(DEFAULT_TIMEOUT, 'List tools timeout'), + ]); + + const connectionTime = Date.now() - startTime; + + // Convert tools to MCPToolInfo format + const tools: MCPToolInfo[] = (toolsResult.tools || []).map( + (tool: { name: string; description?: string; inputSchema?: Record }) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + enabled: true, + }) + ); + + return { + success: true, + tools, + connectionTime, + serverInfo: { + name: serverConfig.name, + version: undefined, // Could be extracted from server info if available + }, + }; + } catch (error) { + const connectionTime = Date.now() - startTime; + return { + success: false, + error: this.getErrorMessage(error), + connectionTime, + }; + } finally { + // Clean up client connection + if (client) { + try { + await client.close(); + } catch { + // Ignore cleanup errors + } + } + } + } + + /** + * Test server by ID (looks up config from settings) + */ + async testServerById(serverId: string): Promise { + try { + const globalSettings = await this.settingsService.getGlobalSettings(); + const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId); + + if (!serverConfig) { + return { + success: false, + error: `Server with ID "${serverId}" not found`, + }; + } + + return this.testServer(serverConfig); + } catch (error) { + return { + success: false, + error: this.getErrorMessage(error), + }; + } + } + + /** + * Create appropriate transport based on server type + */ + private async createTransport( + config: MCPServerConfig + ): Promise { + if (config.type === 'sse') { + if (!config.url) { + throw new Error('URL is required for SSE transport'); + } + // Use eventSourceInit workaround for SSE headers (SDK bug workaround) + // See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436 + const headers = config.headers; + return new SSEClientTransport(new URL(config.url), { + requestInit: headers ? { headers } : undefined, + eventSourceInit: headers + ? { + fetch: (url: string | URL | Request, init?: RequestInit) => { + const fetchHeaders = new Headers(init?.headers || {}); + for (const [key, value] of Object.entries(headers)) { + fetchHeaders.set(key, value); + } + return fetch(url, { ...init, headers: fetchHeaders }); + }, + } + : undefined, + }); + } + + if (config.type === 'http') { + if (!config.url) { + throw new Error('URL is required for HTTP transport'); + } + return new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: config.headers + ? { + headers: config.headers, + } + : undefined, + }); + } + + // Default to stdio + if (!config.command) { + throw new Error('Command is required for stdio transport'); + } + + return new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + }); + } + + /** + * Create a timeout promise + */ + private timeout(ms: number, message: string): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error(message)), ms); + }); + } + + /** + * Extract error message from unknown error + */ + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); + } +} diff --git a/apps/server/tests/unit/lib/settings-helpers.test.ts b/apps/server/tests/unit/lib/settings-helpers.test.ts new file mode 100644 index 000000000..a89e9ed64 --- /dev/null +++ b/apps/server/tests/unit/lib/settings-helpers.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js'; +import type { SettingsService } from '@/services/settings-service.js'; + +describe('settings-helpers.ts', () => { + describe('getMCPServersFromSettings', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return empty object when settingsService is null', async () => { + const result = await getMCPServersFromSettings(null); + expect(result).toEqual({}); + }); + + it('should return empty object when settingsService is undefined', async () => { + const result = await getMCPServersFromSettings(undefined); + expect(result).toEqual({}); + }); + + it('should return empty object when no MCP servers configured', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should return empty object when mcpServers is undefined', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should convert enabled stdio server to SDK format', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'test-server', + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { NODE_ENV: 'test' }, + enabled: true, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({ + 'test-server': { + type: 'stdio', + command: 'node', + args: ['server.js'], + env: { NODE_ENV: 'test' }, + }, + }); + }); + + it('should convert enabled SSE server to SDK format', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'sse-server', + type: 'sse', + url: 'http://localhost:3000/sse', + headers: { Authorization: 'Bearer token' }, + enabled: true, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({ + 'sse-server': { + type: 'sse', + url: 'http://localhost:3000/sse', + headers: { Authorization: 'Bearer token' }, + }, + }); + }); + + it('should convert enabled HTTP server to SDK format', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'http-server', + type: 'http', + url: 'http://localhost:3000/api', + headers: { 'X-API-Key': 'secret' }, + enabled: true, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({ + 'http-server': { + type: 'http', + url: 'http://localhost:3000/api', + headers: { 'X-API-Key': 'secret' }, + }, + }); + }); + + it('should filter out disabled servers', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'enabled-server', + type: 'stdio', + command: 'node', + enabled: true, + }, + { + id: '2', + name: 'disabled-server', + type: 'stdio', + command: 'python', + enabled: false, + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(Object.keys(result)).toHaveLength(1); + expect(result['enabled-server']).toBeDefined(); + expect(result['disabled-server']).toBeUndefined(); + }); + + it('should treat servers without enabled field as enabled', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'implicit-enabled', + type: 'stdio', + command: 'node', + // enabled field not set + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result['implicit-enabled']).toBeDefined(); + }); + + it('should handle multiple enabled servers', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true }, + { id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(Object.keys(result)).toHaveLength(2); + expect(result['server1']).toBeDefined(); + expect(result['server2']).toBeDefined(); + }); + + it('should return empty object and log error on exception', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService, '[Test]'); + expect(result).toEqual({}); + expect(console.error).toHaveBeenCalled(); + }); + + it('should throw error for SSE server without URL', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'bad-sse', + type: 'sse', + enabled: true, + // url missing + }, + ], + }), + } as unknown as SettingsService; + + // The error is caught and logged, returns empty + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should throw error for HTTP server without URL', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'bad-http', + type: 'http', + enabled: true, + // url missing + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should throw error for stdio server without command', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'bad-stdio', + type: 'stdio', + enabled: true, + // command missing + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result).toEqual({}); + }); + + it('should default to stdio type when type is not specified', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpServers: [ + { + id: '1', + name: 'no-type', + command: 'node', + enabled: true, + // type not specified, should default to stdio + }, + ], + }), + } as unknown as SettingsService; + + const result = await getMCPServersFromSettings(mockSettingsService); + expect(result['no-type']).toEqual({ + type: 'stdio', + command: 'node', + args: undefined, + env: undefined, + }); + }); + }); + + describe('getMCPPermissionSettings', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should return defaults when settingsService is null', async () => { + const result = await getMCPPermissionSettings(null); + expect(result).toEqual({ + mcpAutoApproveTools: true, + mcpUnrestrictedTools: true, + }); + }); + + it('should return defaults when settingsService is undefined', async () => { + const result = await getMCPPermissionSettings(undefined); + expect(result).toEqual({ + mcpAutoApproveTools: true, + mcpUnrestrictedTools: true, + }); + }); + + it('should return settings from service', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpAutoApproveTools: false, + mcpUnrestrictedTools: false, + }), + } as unknown as SettingsService; + + const result = await getMCPPermissionSettings(mockSettingsService); + expect(result).toEqual({ + mcpAutoApproveTools: false, + mcpUnrestrictedTools: false, + }); + }); + + it('should default to true when settings are undefined', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getMCPPermissionSettings(mockSettingsService); + expect(result).toEqual({ + mcpAutoApproveTools: true, + mcpUnrestrictedTools: true, + }); + }); + + it('should handle mixed settings', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpAutoApproveTools: true, + mcpUnrestrictedTools: false, + }), + } as unknown as SettingsService; + + const result = await getMCPPermissionSettings(mockSettingsService); + expect(result).toEqual({ + mcpAutoApproveTools: true, + mcpUnrestrictedTools: false, + }); + }); + + it('should return defaults and log error on exception', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), + } as unknown as SettingsService; + + const result = await getMCPPermissionSettings(mockSettingsService, '[Test]'); + expect(result).toEqual({ + mcpAutoApproveTools: true, + mcpUnrestrictedTools: true, + }); + expect(console.error).toHaveBeenCalled(); + }); + + it('should use custom log prefix', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + mcpAutoApproveTools: true, + mcpUnrestrictedTools: true, + }), + } as unknown as SettingsService; + + await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]'); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[CustomPrefix]')); + }); + }); +}); diff --git a/apps/ui/src/components/views/context-view.tsx b/apps/ui/src/components/views/context-view.tsx index 1ea9c06e2..d81d6210d 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 6ea52add6..70d19e5f8 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 e32c2223a..d478bb4d3 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 2e3f784f9..48c406b2b 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/components/index.ts b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts new file mode 100644 index 000000000..db49d81d9 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/index.ts @@ -0,0 +1,4 @@ +export { MCPServerHeader } from './mcp-server-header'; +export { MCPPermissionSettings } from './mcp-permission-settings'; +export { MCPToolsWarning } from './mcp-tools-warning'; +export { MCPServerCard } from './mcp-server-card'; diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx new file mode 100644 index 000000000..e65e25bb4 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-permission-settings.tsx @@ -0,0 +1,96 @@ +import { ShieldAlert } from 'lucide-react'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { syncSettingsToServer } from '@/hooks/use-settings-migration'; +import { cn } from '@/lib/utils'; + +interface MCPPermissionSettingsProps { + mcpAutoApproveTools: boolean; + mcpUnrestrictedTools: boolean; + onAutoApproveChange: (checked: boolean) => void; + onUnrestrictedChange: (checked: boolean) => void; +} + +export function MCPPermissionSettings({ + mcpAutoApproveTools, + mcpUnrestrictedTools, + onAutoApproveChange, + onUnrestrictedChange, +}: MCPPermissionSettingsProps) { + const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools; + + return ( +
+
+
+ { + onAutoApproveChange(checked); + await syncSettingsToServer(); + }} + data-testid="mcp-auto-approve-toggle" + className="mt-0.5" + /> +
+ +

+ When enabled, the AI agent can use MCP tools without permission prompts. +

+ {mcpAutoApproveTools && ( +

+ + Bypasses normal permission checks +

+ )} +
+
+ +
+ { + onUnrestrictedChange(checked); + await syncSettingsToServer(); + }} + data-testid="mcp-unrestricted-toggle" + className="mt-0.5" + /> +
+ +

+ When enabled, the AI agent can use any tool, not just the default set. +

+ {mcpUnrestrictedTools && ( +

+ + Agent has full tool access including file writes and bash +

+ )} +
+
+ + {hasAnyEnabled && ( +
+

Security Note

+

+ These settings reduce security restrictions for MCP tool usage. Only enable if you + trust all configured MCP servers. +

+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx new file mode 100644 index 000000000..babf4bda5 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-card.tsx @@ -0,0 +1,169 @@ +import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; +import type { MCPServerConfig } from '@automaker/types'; +import type { ServerTestState } from '../types'; +import { getServerIcon, getTestStatusIcon, maskSensitiveUrl } from '../utils'; +import { MCPToolsList } from '../mcp-tools-list'; + +interface MCPServerCardProps { + server: MCPServerConfig; + testState?: ServerTestState; + isExpanded: boolean; + onToggleExpanded: () => void; + onTest: () => void; + onToggleEnabled: () => void; + onEditJson: () => void; + onEdit: () => void; + onDelete: () => void; +} + +export function MCPServerCard({ + server, + testState, + isExpanded, + onToggleExpanded, + onTest, + onToggleEnabled, + onEditJson, + onEdit, + onDelete, +}: MCPServerCardProps) { + const Icon = getServerIcon(server.type); + const hasTools = testState?.tools && testState.tools.length > 0; + + return ( + +
+
+
+ + + +
+
+ + + + + +
+
+ {hasTools && ( + +
+
Available Tools
+ +
+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx new file mode 100644 index 000000000..a85fc3050 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-server-header.tsx @@ -0,0 +1,87 @@ +import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +interface MCPServerHeaderProps { + isRefreshing: boolean; + hasServers: boolean; + onRefresh: () => void; + onExport: () => void; + onEditAllJson: () => void; + onImport: () => void; + onAdd: () => void; +} + +export function MCPServerHeader({ + isRefreshing, + hasServers, + onRefresh, + onExport, + onEditAllJson, + onImport, + onAdd, +}: MCPServerHeaderProps) { + return ( +
+
+
+
+
+ +
+

MCP Servers

+
+

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

+
+
+ + {hasServers && ( + <> + + + + )} + + +
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-tools-warning.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-tools-warning.tsx new file mode 100644 index 000000000..019a0bbb2 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/components/mcp-tools-warning.tsx @@ -0,0 +1,25 @@ +import { AlertTriangle } from 'lucide-react'; +import { MAX_RECOMMENDED_TOOLS } from '../constants'; + +interface MCPToolsWarningProps { + totalTools: number; +} + +export function MCPToolsWarning({ totalTools }: MCPToolsWarningProps) { + return ( +
+
+ +
+

+ High tool count detected ({totalTools} tools) +

+

+ Having more than {MAX_RECOMMENDED_TOOLS} MCP tools may degrade AI model performance. + Consider disabling unused servers or removing unnecessary tools. +

+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/constants.ts b/apps/ui/src/components/views/settings-view/mcp-servers/constants.ts new file mode 100644 index 000000000..8a41cd8b1 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/constants.ts @@ -0,0 +1,14 @@ +// Patterns that indicate sensitive values in URLs or config +export const SENSITIVE_PARAM_PATTERNS = [ + /api[-_]?key/i, + /api[-_]?token/i, + /auth/i, + /token/i, + /secret/i, + /password/i, + /credential/i, + /bearer/i, +]; + +// Maximum recommended MCP tools before performance degradation +export const MAX_RECOMMENDED_TOOLS = 80; diff --git a/apps/ui/src/components/views/settings-view/mcp-servers/dialogs/add-edit-server-dialog.tsx b/apps/ui/src/components/views/settings-view/mcp-servers/dialogs/add-edit-server-dialog.tsx new file mode 100644 index 000000000..5671ab60b --- /dev/null +++ b/apps/ui/src/components/views/settings-view/mcp-servers/dialogs/add-edit-server-dialog.tsx @@ -0,0 +1,161 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { MCPServerConfig } from '@automaker/types'; +import type { ServerFormData, ServerType } from '../types'; + +interface AddEditServerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editingServer: MCPServerConfig | null; + formData: ServerFormData; + onFormDataChange: (data: ServerFormData) => void; + onSave: () => void; + onCancel: () => void; +} + +export function AddEditServerDialog({ + open, + onOpenChange, + editingServer, + formData, + onFormDataChange, + onSave, + onCancel, +}: AddEditServerDialogProps) { + return ( + + + + {editingServer ? 'Edit MCP Server' : 'Add MCP Server'} + + Configure an MCP server to extend agent capabilities with custom tools. + + +
+
+ + onFormDataChange({ ...formData, name: e.target.value })} + placeholder="my-mcp-server" + data-testid="mcp-server-name-input" + /> +
+
+ + onFormDataChange({ ...formData, description: e.target.value })} + placeholder="What this server provides..." + data-testid="mcp-server-description-input" + /> +
+
+ + +
+ {formData.type === 'stdio' ? ( + <> +
+ + onFormDataChange({ ...formData, command: e.target.value })} + placeholder="npx, node, python, etc." + data-testid="mcp-server-command-input" + /> +
+
+ + onFormDataChange({ ...formData, args: e.target.value })} + placeholder="-y @modelcontextprotocol/server-filesystem" + data-testid="mcp-server-args-input" + /> +
+
+ +