Merge pull request #286 from mzubair481/feature/mcp-server-support

feat: add MCP server support
This commit is contained in:
Web Dev Cody
2025-12-28 22:42:12 -05:00
committed by GitHub
45 changed files with 3628 additions and 44 deletions

View File

@@ -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

View File

@@ -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<Options> {
};
}
/**
* 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>;
/** Options to spread for MCP servers */
mcpServerOptions: Partial<Options>;
}
/**
* 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<string, McpServerConfig>;
/** 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,
};
}

View File

@@ -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<Record<string, McpServerConfig>> {
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<string, McpServerConfig> = {};
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,
};
}

View File

@@ -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

View File

@@ -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<string, string>;
}
/**
* 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<string, unknown>;
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)

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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),
});
}
};
}

View File

@@ -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<void> => {
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),
});
}
};
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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<MCPTestResult> {
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<string, unknown>;
}>;
}>(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<string, unknown> }) => ({
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<MCPTestResult> {
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<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
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<T>(ms: number, message: string): Promise<T> {
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);
}
}