diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md new file mode 100644 index 0000000..c0c1973 --- /dev/null +++ b/docs/mcp-integration.md @@ -0,0 +1,322 @@ +# MCP Integration Guide + +MCP (Model Context Protocol) integration allows MiniAgent to use tools from external MCP servers, greatly expanding the available functionality beyond built-in tools. + +## Overview + +The MCP integration provides: + +- **Type-safe integration**: Full TypeScript support with proper error handling +- **Multi-server support**: Connect to multiple MCP servers simultaneously +- **Automatic tool discovery**: Tools are automatically loaded from running servers +- **Seamless tool usage**: MCP tools work exactly like regular tools in conversations +- **Configuration flexibility**: Support for environment variables, file-based config, and programmatic setup +- **Error resilience**: Robust error handling with retry logic and graceful fallbacks + +## Quick Start + +### 1. Installation + +The MCP SDK is already included as a dependency: + +```bash +npm install @continue-reasoning/mini-agent +``` + +### 2. Basic Usage + +```typescript +import { + createMCPAgent, + MCPConfigHelpers, + AgentEventType +} from '@continue-reasoning/mini-agent'; + +// Create MCP configuration +const config = { + agentConfig: { + model: 'gemini-2.0-flash', + workingDirectory: process.cwd(), + apiKey: process.env.GEMINI_API_KEY, + }, + chatConfig: { + apiKey: process.env.GEMINI_API_KEY!, + modelName: 'gemini-2.0-flash', + systemPrompt: 'You are a helpful assistant with filesystem and git tools.', + }, + toolSchedulerConfig: { + approvalMode: 'yolo', // Auto-approve tools for demo + }, + mcpConfig: { + servers: [ + // Filesystem operations + MCPConfigHelpers.createFilesystemServer('fs', process.cwd()), + // Git operations + MCPConfigHelpers.createGitServer('git', process.cwd()), + ], + }, +}; + +// Create and initialize agent +const agent = await createMCPAgent([], config); + +// Use the agent +for await (const event of agent.process([{ + role: 'user', + content: { type: 'text', text: 'List files and show git status' }, + metadata: { sessionId: 'demo' } +}], 'demo', new AbortController().signal)) { + if (event.type === AgentEventType.ResponseChunkTextDelta) { + process.stdout.write((event.data as any).content.text); + } +} +``` + +## Configuration + +### Server Configuration + +MCP servers are configured using `MCPServerConfig`: + +```typescript +interface MCPServerConfig { + name: string; // Unique server identifier + command: string; // Command to start the server + args?: string[]; // Command arguments + env?: Record; // Environment variables + cwd?: string; // Working directory + disabled?: boolean; // Whether to skip this server + timeout?: number; // Custom timeout (ms) + retryAttempts?: number; // Custom retry attempts +} +``` + +### Built-in Server Helpers + +The `MCPConfigHelpers` class provides convenient methods for common servers: + +#### Filesystem Server +```typescript +MCPConfigHelpers.createFilesystemServer('fs', '/allowed/path') +``` + +#### Git Server +```typescript +MCPConfigHelpers.createGitServer('git', '/repo/path') +``` + +#### Web Search Server +```typescript +MCPConfigHelpers.createWebSearchServer('search', 'api-key') +``` + +#### Database Server +```typescript +MCPConfigHelpers.createDatabaseServer('db', 'connection-string') +``` + +### Environment Variables + +Configure servers using environment variables: + +```bash +# Global MCP settings +MCP_TIMEOUT=30000 +MCP_RETRY_ATTEMPTS=3 +MCP_LOG_LEVEL=INFO +MCP_AUTO_RESTART=true + +# Server configuration +MCP_SERVER_FILESYSTEM_COMMAND=npx +MCP_SERVER_FILESYSTEM_ARGS=@modelcontextprotocol/server-filesystem,/path +MCP_SERVER_GIT_COMMAND=mcp-server-git +MCP_SERVER_GIT_CWD=/repo/path +``` + +### Configuration Files + +Load configuration from JSON files: + +```typescript +import { MCPConfigLoader } from '@continue-reasoning/mini-agent'; + +const config = MCPConfigLoader.loadFromFile('./mcp-config.json'); +``` + +Example `mcp-config.json`: +```json +{ + "servers": [ + { + "name": "filesystem", + "command": "npx", + "args": ["@modelcontextprotocol/server-filesystem", "/workspace"], + "disabled": false + }, + { + "name": "git", + "command": "mcp-server-git", + "cwd": "/workspace", + "timeout": 45000 + } + ], + "timeout": 30000, + "retryAttempts": 3, + "autoRestart": true +} +``` + +## Advanced Usage + +### Custom Tool Integration + +Mix MCP tools with custom tools: + +```typescript +import { BaseTool, createMCPAgent } from '@continue-reasoning/mini-agent'; + +class CustomTool extends BaseTool { + // ... your custom tool implementation +} + +const agent = await createMCPAgent([ + new CustomTool(), + // MCP tools will be automatically added +], config); +``` + +### Server Management + +Control MCP servers at runtime: + +```typescript +// Start/stop servers +await agent.startMCPServer('filesystem'); +await agent.stopMCPServer('git'); +await agent.restartMCPServer('search'); + +// Get server status +const status = agent.getMCPStatus(); +console.log(`Running servers: ${status.serverStatus.runningServers}`); +console.log(`Available tools: ${status.mcpTools.length}`); + +// Refresh tools from all servers +await agent.refreshMCPTools(); +``` + +### Error Handling + +```typescript +import { MCPError, MCPErrorType } from '@continue-reasoning/mini-agent'; + +try { + await agent.startMCPServer('problematic-server'); +} catch (error) { + if (error instanceof MCPError) { + switch (error.type) { + case MCPErrorType.ServerStartupFailed: + console.log('Server failed to start:', error.message); + break; + case MCPErrorType.ToolExecutionFailed: + console.log('Tool execution failed:', error.toolName, error.message); + break; + } + } +} +``` + +## Available MCP Servers + +### Official Servers + +- **@modelcontextprotocol/server-filesystem**: File system operations +- **@modelcontextprotocol/server-git**: Git repository operations +- **@modelcontextprotocol/server-postgres**: PostgreSQL database operations +- **@modelcontextprotocol/server-sqlite**: SQLite database operations + +### Community Servers + +- **mcp-server-web-search**: Web search capabilities +- **mcp-server-docker**: Docker container management +- **mcp-server-aws**: AWS services integration +- **mcp-server-github**: GitHub API integration + +## Best Practices + +### Security + +1. **Restrict filesystem access**: Only allow access to specific directories +2. **Validate server commands**: Ensure server executables are trusted +3. **Use confirmation mode**: Set `approvalMode: 'default'` for destructive operations +4. **Environment isolation**: Run servers in isolated environments when possible + +### Performance + +1. **Configure timeouts**: Set appropriate timeouts for your use case +2. **Limit concurrent tools**: Use `maxConcurrentTools` to prevent resource exhaustion +3. **Monitor server health**: Implement health checks for critical servers +4. **Use auto-restart**: Enable `autoRestart` for production deployments + +### Debugging + +1. **Enable debug logging**: Set `logLevel: LogLevel.DEBUG` in MCP config +2. **Monitor tool execution**: Use tool execution callbacks to track performance +3. **Check server logs**: Most MCP servers provide detailed logging +4. **Validate configurations**: Use `MCPConfigLoader.validate()` to check config + +## Troubleshooting + +### Common Issues + +#### Server Won't Start +``` +Error: Server startup failed after 3 attempts +``` + +**Solutions:** +- Check that the server command is in PATH +- Verify server dependencies are installed +- Check server logs for specific error messages +- Increase timeout and retry attempts + +#### Tool Execution Timeout +``` +Error: Tool execution timeout after 30000ms +``` + +**Solutions:** +- Increase timeout in server config +- Check server responsiveness with `ping()` +- Monitor server resource usage +- Consider using streaming tools for long operations + +#### Permission Denied +``` +Error: EACCES: permission denied +``` + +**Solutions:** +- Check file/directory permissions +- Verify server process user has required access +- Use absolute paths in configuration +- Consider running with appropriate privileges + +### Getting Help + +1. **Check server documentation**: Each MCP server has specific requirements +2. **Enable verbose logging**: Use debug mode to see detailed execution flow +3. **Test servers independently**: Verify servers work outside of MiniAgent +4. **Review configuration**: Use validation to catch config errors early + +## Examples + +See the complete working example in `examples/mcpExample.ts` for a full demonstration of MCP integration with filesystem and git tools. + +## Contributing + +When adding new MCP server support: + +1. Follow the existing helper pattern in `MCPConfigHelpers` +2. Add comprehensive tests for new functionality +3. Update documentation with usage examples +4. Consider security implications of new server types \ No newline at end of file diff --git a/examples/mcpExample.ts b/examples/mcpExample.ts new file mode 100644 index 0000000..eabefa0 --- /dev/null +++ b/examples/mcpExample.ts @@ -0,0 +1,340 @@ +/** + * @fileoverview MCP Integration Example + * + * This example demonstrates how to use MCP (Model Context Protocol) integration + * with the MiniAgent framework. It shows how to configure MCP servers, + * load tools, and use them in agent conversations. + */ + +import { + createMCPAgent, + MCPAgentConfig, + MCPConfigHelpers, + AgentEventType, + LogLevel, + BaseTool, + ToolResult, + Type, +} from '../src/index.js'; +import * as dotenv from 'dotenv'; + +// Load environment variables +dotenv.config(); + +/** + * Example custom tool to demonstrate mixed tool usage + */ +class CalculatorTool extends BaseTool<{ operation: string; a: number; b: number }> { + constructor() { + super( + 'calculator', + 'Calculator Tool', + 'Perform basic mathematical operations', + { + type: Type.OBJECT, + properties: { + operation: { + type: Type.STRING, + description: 'Mathematical operation: add, subtract, multiply, divide', + enum: ['add', 'subtract', 'multiply', 'divide'], + }, + a: { + type: Type.NUMBER, + description: 'First number', + }, + b: { + type: Type.NUMBER, + description: 'Second number', + }, + }, + required: ['operation', 'a', 'b'], + } + ); + } + + validateToolParams(params: { operation: string; a: number; b: number }): string | null { + const validOps = ['add', 'subtract', 'multiply', 'divide']; + if (!validOps.includes(params.operation)) { + return `Invalid operation. Must be one of: ${validOps.join(', ')}`; + } + + if (params.operation === 'divide' && params.b === 0) { + return 'Cannot divide by zero'; + } + + return null; + } + + async execute( + params: { operation: string; a: number; b: number }, + signal: AbortSignal, + updateOutput?: (output: string) => void + ): Promise { + updateOutput?.(`Calculating: ${params.a} ${params.operation} ${params.b}`); + + let result: number; + switch (params.operation) { + case 'add': + result = params.a + params.b; + break; + case 'subtract': + result = params.a - params.b; + break; + case 'multiply': + result = params.a * params.b; + break; + case 'divide': + result = params.a / params.b; + break; + default: + throw new Error(`Unknown operation: ${params.operation}`); + } + + return this.createResult( + `Result: ${result}`, + `๐Ÿงฎ ${params.a} ${params.operation} ${params.b} = ${result}`, + `Calculator: ${params.operation} operation completed` + ); + } +} + +/** + * Main example function + */ +async function runMCPExample(): Promise { + console.log('๐Ÿš€ Starting MCP Integration Example'); + + try { + // Create MCP configuration + const mcpConfig: MCPAgentConfig = { + // Standard agent configuration + agentConfig: { + model: 'gemini-2.0-flash', + workingDirectory: process.cwd(), + apiKey: process.env.GEMINI_API_KEY, + sessionId: 'mcp-example-session', + maxHistoryTokens: 100000, + logLevel: LogLevel.DEBUG, + }, + + // Chat configuration + chatConfig: { + apiKey: process.env.GEMINI_API_KEY!, + modelName: 'gemini-2.0-flash', + tokenLimit: 100000, + systemPrompt: 'You are a helpful assistant with access to filesystem tools, git operations, and calculation capabilities. Use the available tools to help users with their requests.', + }, + + // Tool scheduler configuration + toolSchedulerConfig: { + approvalMode: 'yolo', // Auto-approve for demo + outputUpdateHandler: (callId, output) => { + console.log(`๐Ÿ”ง Tool Output [${callId}]: ${output}`); + }, + onAllToolCallsComplete: (completedCalls) => { + console.log(`โœ… Completed ${completedCalls.length} tool calls`); + }, + }, + + // MCP configuration + mcpConfig: { + servers: [ + // Filesystem server - allows file operations in current directory + MCPConfigHelpers.createFilesystemServer( + 'filesystem', + process.cwd(), + false // enabled + ), + + // Git server - allows git operations + MCPConfigHelpers.createGitServer( + 'git', + process.cwd(), + false // enabled + ), + + // Example of custom server configuration + { + name: 'custom-server', + command: 'echo', // This is just a placeholder - won't actually work + args: ['Hello from custom server'], + disabled: true, // Disabled for demo + }, + ], + timeout: 30000, + retryAttempts: 3, + logLevel: LogLevel.INFO, + autoRestart: true, + maxConcurrentTools: 5, + }, + }; + + console.log('๐Ÿ“ Configuration created'); + + // Create regular tools + const regularTools = [ + new CalculatorTool(), + ]; + + // Create MCP-enabled agent + console.log('๐Ÿ”ง Creating MCP Agent...'); + const agent = await createMCPAgent(regularTools, mcpConfig); + + console.log('โœ… MCP Agent created successfully'); + + // Get status to show available tools + const status = agent.getMCPStatus(); + console.log('\n๐Ÿ“Š MCP Status:'); + console.log(` - MCP Enabled: ${status.mcpEnabled}`); + console.log(` - Tools Loaded: ${status.toolsLoaded}`); + console.log(` - Total Servers: ${status.serverStatus.totalServers}`); + console.log(` - Running Servers: ${status.serverStatus.runningServers}`); + console.log(` - Failed Servers: ${status.serverStatus.failedServers}`); + console.log(` - Total Tools: ${status.serverStatus.totalTools}`); + console.log(` - MCP Tools: ${status.mcpTools.length}`); + + // List all available tools + const allTools = agent.getToolList(); + console.log(`\n๐Ÿ› ๏ธ Available Tools (${allTools.length}):`); + allTools.forEach((tool, index) => { + const isMCP = 'serverName' in tool; + const prefix = isMCP ? '๐Ÿ“ก MCP' : '๐Ÿ”ง Regular'; + console.log(` ${index + 1}. ${prefix}: ${tool.name} - ${tool.description}`); + }); + + // Example interactions + const examples = [ + 'Calculate 15 + 27', + 'List the files in the current directory', + 'Show the git status of this repository', + 'Create a simple text file called "test.txt" with the content "Hello MCP!"', + ]; + + for (const [index, userInput] of examples.entries()) { + console.log(`\n${'='.repeat(60)}`); + console.log(`๐Ÿ“ Example ${index + 1}: ${userInput}`); + console.log(`${'='.repeat(60)}`); + + // Process the user input + const abortController = new AbortController(); + + // Set a timeout for each example + setTimeout(() => { + console.log('โฐ Timeout reached, aborting...'); + abortController.abort(); + }, 60000); // 60 second timeout + + console.log(`๐Ÿ‘ค User: ${userInput}`); + console.log('๐Ÿค– Assistant: '); + + try { + for await (const event of agent.process( + [{ + role: 'user', + content: { type: 'text', text: userInput }, + metadata: { sessionId: 'mcp-example-session' } + }], + 'mcp-example-session', + abortController.signal + )) { + switch (event.type) { + case AgentEventType.ResponseChunkTextDelta: + // Stream assistant response + process.stdout.write((event.data as any).content.text); + break; + + case AgentEventType.ResponseChunkTextDone: + // Complete text response + console.log('\n'); + break; + + case AgentEventType.ToolExecutionStart: + console.log(`๐Ÿ”ง Executing tool: ${(event.data as any).toolName}`); + break; + + case AgentEventType.ToolExecutionDone: + const toolData = event.data as any; + if (toolData.error) { + console.log(`โŒ Tool failed: ${toolData.error}`); + } else { + console.log(`โœ… Tool completed: ${toolData.toolName}`); + } + break; + + case AgentEventType.TurnComplete: + console.log('๐Ÿ”„ Turn completed\n'); + break; + + case AgentEventType.Error: + console.error(`โŒ Error: ${(event.data as any).message}`); + break; + } + } + } catch (error) { + if (abortController.signal.aborted) { + console.log('โฐ Example timed out'); + } else { + console.error(`โŒ Error in example: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Brief pause between examples + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // Demonstrate MCP server management + console.log(`\n${'='.repeat(60)}`); + console.log('๐Ÿ”„ Demonstrating Server Management'); + console.log(`${'='.repeat(60)}`); + + // Show current server status + const servers = agent.getMCPManager().getServers(); + console.log('\n๐Ÿ–ฅ๏ธ Server Status:'); + servers.forEach((server, name) => { + const info = server.getInfo(); + console.log(` - ${name}: ${info.status} (${info.tools.length} tools)`); + }); + + // Cleanup + console.log('\n๐Ÿงน Shutting down agent...'); + await agent.shutdown(); + console.log('โœ… Agent shut down successfully'); + + } catch (error) { + console.error('โŒ Example failed:', error instanceof Error ? error.message : String(error)); + if (error instanceof Error && error.stack) { + console.error('Stack trace:', error.stack); + } + process.exit(1); + } +} + +/** + * Environment validation + */ +function validateEnvironment(): boolean { + if (!process.env.GEMINI_API_KEY) { + console.error('โŒ GEMINI_API_KEY environment variable is required'); + console.error(' Please set it in your .env file or environment'); + return false; + } + + return true; +} + +// Run the example if this file is executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + if (!validateEnvironment()) { + process.exit(1); + } + + runMCPExample() + .then(() => { + console.log('๐ŸŽ‰ MCP Example completed successfully!'); + process.exit(0); + }) + .catch((error) => { + console.error('๐Ÿ’ฅ MCP Example failed:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/package.json b/package.json index f21a5a9..0dbbc6c 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@google/genai": "^1.8.0", + "@modelcontextprotocol/sdk": "^1.17.0", "dotenv": "^16.4.5", "openai": "^5.10.1" }, diff --git a/src/index.ts b/src/index.ts index f6061ce..32804ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,3 +148,28 @@ export { export { Type } from '@google/genai'; export type { Schema } from '@google/genai'; +// ============================================================================ +// MCP INTEGRATION +// ============================================================================ + +// MCP exports - optional integration for Model Context Protocol +export type { + MCPConfig, + MCPServerConfig, + MCPToolDefinition, + MCPTool, + MCPAgentConfig, + IMCPServerManager, +} from './mcp/index.js'; + +export { + MCPAgent, + createMCPAgent, + MCPServerManager, + MCPConfigLoader, + MCPConfigHelpers, + MCPServerStatus, + MCPErrorType, + MCPError, +} from './mcp/index.js'; + diff --git a/src/mcp/config.ts b/src/mcp/config.ts new file mode 100644 index 0000000..70e5dae --- /dev/null +++ b/src/mcp/config.ts @@ -0,0 +1,442 @@ +/** + * @fileoverview MCP Configuration Utilities + * + * This file provides utilities for loading, validating, and managing + * MCP configuration from various sources (files, environment variables, etc.). + */ + +import { readFileSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { + MCPConfig, + MCPServerConfig, + ConfigValidationResult, + isMCPServerConfig, + MCPError, + MCPErrorType, +} from './interfaces.js'; +import { LogLevel } from '../logger.js'; + +/** + * Default MCP configuration values + */ +export const DEFAULT_MCP_CONFIG: Partial = { + timeout: 30000, // 30 seconds + retryAttempts: 3, + logLevel: LogLevel.INFO, + autoRestart: true, + maxConcurrentTools: 10, +}; + +/** + * Environment variable prefixes for MCP configuration + */ +export const MCP_ENV_PREFIXES = { + CONFIG: 'MCP_', + SERVER: 'MCP_SERVER_', +} as const; + +/** + * Configuration loader class + */ +export class MCPConfigLoader { + /** + * Load configuration from file + */ + static loadFromFile(filePath: string): MCPConfig { + const resolvedPath = resolve(filePath); + + if (!existsSync(resolvedPath)) { + throw new MCPError( + MCPErrorType.ConfigurationError, + `Configuration file not found: ${resolvedPath}` + ); + } + + try { + const fileContent = readFileSync(resolvedPath, 'utf-8'); + const config = JSON.parse(fileContent); + + return this.mergeWithDefaults(config); + } catch (error) { + throw new MCPError( + MCPErrorType.ConfigurationError, + `Failed to load configuration from ${resolvedPath}: ${error instanceof Error ? error.message : String(error)}`, + undefined, + undefined, + error instanceof Error ? error : undefined + ); + } + } + + /** + * Load configuration from environment variables + */ + static loadFromEnv(): Partial { + const config: Partial = {}; + const servers: MCPServerConfig[] = []; + + // Load global MCP settings + const timeout = process.env.MCP_TIMEOUT; + if (timeout) { + config.timeout = parseInt(timeout, 10); + } + + const retryAttempts = process.env.MCP_RETRY_ATTEMPTS; + if (retryAttempts) { + config.retryAttempts = parseInt(retryAttempts, 10); + } + + const logLevel = process.env.MCP_LOG_LEVEL; + if (logLevel) { + config.logLevel = this.parseLogLevel(logLevel); + } + + const autoRestart = process.env.MCP_AUTO_RESTART; + if (autoRestart) { + config.autoRestart = autoRestart.toLowerCase() === 'true'; + } + + const maxConcurrentTools = process.env.MCP_MAX_CONCURRENT_TOOLS; + if (maxConcurrentTools) { + config.maxConcurrentTools = parseInt(maxConcurrentTools, 10); + } + + // Load server configurations + const serverConfigs = this.loadServerConfigsFromEnv(); + if (serverConfigs.length > 0) { + servers.push(...serverConfigs); + } + + if (servers.length > 0) { + config.servers = servers; + } + + return config; + } + + /** + * Create configuration with defaults + */ + static createWithDefaults(partialConfig: Partial = {}): MCPConfig { + return this.mergeWithDefaults(partialConfig); + } + + /** + * Validate configuration + */ + static validate(config: MCPConfig): ConfigValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate basic structure + if (!config.servers || !Array.isArray(config.servers)) { + errors.push('servers must be an array'); + return { isValid: false, errors, warnings }; + } + + // Validate global settings + if (config.timeout !== undefined && (config.timeout <= 0 || !Number.isFinite(config.timeout))) { + errors.push('timeout must be a positive number'); + } + + if (config.retryAttempts !== undefined && (config.retryAttempts < 0 || !Number.isInteger(config.retryAttempts))) { + errors.push('retryAttempts must be a non-negative integer'); + } + + if (config.maxConcurrentTools !== undefined && (config.maxConcurrentTools <= 0 || !Number.isInteger(config.maxConcurrentTools))) { + errors.push('maxConcurrentTools must be a positive integer'); + } + + // Validate servers + const serverNames = new Set(); + config.servers.forEach((server, index) => { + const serverErrors = this.validateServerConfig(server, index); + errors.push(...serverErrors); + + // Check for duplicate names + if (server.name && serverNames.has(server.name)) { + errors.push(`Duplicate server name: ${server.name}`); + } + if (server.name) { + serverNames.add(server.name); + } + }); + + // Warnings + if (config.servers.length === 0) { + warnings.push('No servers configured'); + } + + const enabledServers = config.servers.filter(s => !s.disabled); + if (enabledServers.length === 0 && config.servers.length > 0) { + warnings.push('All servers are disabled'); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * Merge configuration with defaults + */ + private static mergeWithDefaults(partialConfig: Partial): MCPConfig { + return { + servers: [], + ...DEFAULT_MCP_CONFIG, + ...partialConfig, + } as MCPConfig; + } + + /** + * Parse log level from string + */ + private static parseLogLevel(logLevelStr: string): LogLevel { + const level = logLevelStr.toUpperCase(); + switch (level) { + case 'DEBUG': + return LogLevel.DEBUG; + case 'INFO': + return LogLevel.INFO; + case 'WARN': + return LogLevel.WARN; + case 'ERROR': + return LogLevel.ERROR; + default: + return LogLevel.INFO; + } + } + + /** + * Load server configurations from environment variables + */ + private static loadServerConfigsFromEnv(): MCPServerConfig[] { + const servers: MCPServerConfig[] = []; + const serverMap = new Map>(); + + // Parse all MCP_SERVER_* environment variables + Object.keys(process.env).forEach(key => { + if (!key.startsWith(MCP_ENV_PREFIXES.SERVER)) { + return; + } + + const value = process.env[key]; + if (!value) { + return; + } + + // Extract server name and property + // Format: MCP_SERVER__ + const parts = key.substring(MCP_ENV_PREFIXES.SERVER.length).split('_'); + if (parts.length < 2) { + return; + } + + const serverName = parts[0].toLowerCase(); + const property = parts.slice(1).join('_').toLowerCase(); + + if (!serverMap.has(serverName)) { + serverMap.set(serverName, { name: serverName }); + } + + const serverConfig = serverMap.get(serverName)!; + + // Map properties + switch (property) { + case 'command': + serverConfig.command = value; + break; + case 'args': + serverConfig.args = value.split(',').map(arg => arg.trim()); + break; + case 'cwd': + serverConfig.cwd = value; + break; + case 'disabled': + serverConfig.disabled = value.toLowerCase() === 'true'; + break; + case 'timeout': + serverConfig.timeout = parseInt(value, 10); + break; + case 'retryattempts': + serverConfig.retryAttempts = parseInt(value, 10); + break; + default: + // Environment variables for the server process + if (!serverConfig.env) { + serverConfig.env = {}; + } + serverConfig.env[property.toUpperCase()] = value; + break; + } + }); + + // Convert to server configs + serverMap.forEach(serverConfig => { + if (serverConfig.name && serverConfig.command) { + servers.push(serverConfig as MCPServerConfig); + } + }); + + return servers; + } + + /** + * Validate a single server configuration + */ + private static validateServerConfig(server: any, index: number): string[] { + const errors: string[] = []; + + if (!isMCPServerConfig(server)) { + errors.push(`Server at index ${index} is not a valid server configuration`); + return errors; + } + + // Validate name + if (!server.name || typeof server.name !== 'string' || !server.name.trim()) { + errors.push(`Server at index ${index} must have a non-empty name`); + } + + // Validate command + if (!server.command || typeof server.command !== 'string' || !server.command.trim()) { + errors.push(`Server at index ${index} must have a non-empty command`); + } + + // Validate args + if (server.args !== undefined && !Array.isArray(server.args)) { + errors.push(`Server ${server.name} args must be an array`); + } + + // Validate env + if (server.env !== undefined && (typeof server.env !== 'object' || Array.isArray(server.env))) { + errors.push(`Server ${server.name} env must be an object`); + } + + // Validate cwd + if (server.cwd !== undefined && typeof server.cwd !== 'string') { + errors.push(`Server ${server.name} cwd must be a string`); + } + + // Validate timeout + if (server.timeout !== undefined && (typeof server.timeout !== 'number' || server.timeout <= 0)) { + errors.push(`Server ${server.name} timeout must be a positive number`); + } + + // Validate retryAttempts + if (server.retryAttempts !== undefined && (typeof server.retryAttempts !== 'number' || server.retryAttempts < 0 || !Number.isInteger(server.retryAttempts))) { + errors.push(`Server ${server.name} retryAttempts must be a non-negative integer`); + } + + return errors; + } +} + +/** + * Helper functions for common configuration patterns + */ +export class MCPConfigHelpers { + /** + * Create a filesystem server configuration + */ + static createFilesystemServer( + name: string = 'filesystem', + allowedPath: string, + disabled: boolean = false + ): MCPServerConfig { + return { + name, + command: 'npx', + args: ['@modelcontextprotocol/server-filesystem', allowedPath], + disabled, + }; + } + + /** + * Create a git server configuration + */ + static createGitServer( + name: string = 'git', + repoPath?: string, + disabled: boolean = false + ): MCPServerConfig { + const args = repoPath ? [repoPath] : []; + const config: MCPServerConfig = { + name, + command: 'mcp-server-git', + args, + disabled, + }; + if (repoPath !== undefined) { + config.cwd = repoPath; + } + return config; + } + + /** + * Create a web search server configuration + */ + static createWebSearchServer( + name: string = 'web-search', + apiKey?: string, + disabled: boolean = false + ): MCPServerConfig { + const config: MCPServerConfig = { + name, + command: 'mcp-server-web-search', + args: [], + disabled, + }; + if (apiKey !== undefined) { + config.env = { SEARCH_API_KEY: apiKey }; + } + return config; + } + + /** + * Create a database server configuration + */ + static createDatabaseServer( + name: string = 'database', + connectionString: string, + disabled: boolean = false + ): MCPServerConfig { + return { + name, + command: 'mcp-server-database', + args: [connectionString], + disabled, + }; + } + + /** + * Merge multiple configurations + */ + static mergeConfigs(...configs: Partial[]): MCPConfig { + const merged: MCPConfig = { + servers: [], + ...DEFAULT_MCP_CONFIG, + }; + + configs.forEach(config => { + // Merge other properties (later configs override earlier ones) + const { servers: configServers, ...otherProps } = config; + Object.assign(merged, otherProps); + }); + + // Handle servers array separately to properly merge + const serverMap = new Map(); + configs.forEach(config => { + if (config.servers) { + config.servers.forEach(server => { + serverMap.set(server.name, server); + }); + } + }); + merged.servers = Array.from(serverMap.values()); + + return merged; + } +} \ No newline at end of file diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..24b2a1c --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,55 @@ +/** + * @fileoverview MCP Module Entry Point + * + * This file exports all public interfaces and classes for the MCP integration. + * It provides a clean API for users to import MCP functionality. + */ + +// Core interfaces +export type { + MCPConfig, + MCPServerConfig, + MCPToolDefinition, + MCPToolRequest, + MCPToolResponse, + MCPTool, + MCPServerInfo, + IMCPServer, + IMCPServerManager, + MCPToolExecutionContext, + ConfigValidationResult, +} from './interfaces.js'; + +// Enums and error types +export { + MCPServerStatus, + MCPErrorType, + MCPError, + isMCPTool, + isMCPServerConfig, +} from './interfaces.js'; + +// Core implementations +export { MCPServer } from './mcpServer.js'; +export { MCPServerManager } from './mcpServerManager.js'; +export { MCPToolAdapter } from './mcpToolAdapter.js'; + +// Agent integration +export type { MCPAgentConfig } from './mcpAgent.js'; +export { MCPAgent, createMCPAgent } from './mcpAgent.js'; + +// Configuration utilities +export { + MCPConfigLoader, + MCPConfigHelpers, + DEFAULT_MCP_CONFIG, + MCP_ENV_PREFIXES, +} from './config.js'; + +// Re-export useful types from base interfaces +export type { + ITool, + ToolResult, + ToolCallConfirmationDetails, + AllConfig, +} from '../interfaces.js'; \ No newline at end of file diff --git a/src/mcp/interfaces.ts b/src/mcp/interfaces.ts new file mode 100644 index 0000000..1a44a94 --- /dev/null +++ b/src/mcp/interfaces.ts @@ -0,0 +1,371 @@ +/** + * @fileoverview MCP (Model Context Protocol) Integration Interfaces + * + * This file defines type-safe interfaces for integrating MCP servers and tools + * into the MiniAgent framework. It provides abstractions for server management, + * tool execution, and configuration. + */ + +import { ITool } from '../interfaces.js'; +import { LogLevel } from '../logger.js'; + +// ============================================================================ +// MCP SERVER CONFIGURATION +// ============================================================================ + +/** + * Configuration for a single MCP server + */ +export interface MCPServerConfig { + /** Unique server name identifier */ + name: string; + /** Command to start the server */ + command: string; + /** Command arguments */ + args?: string[]; + /** Environment variables for the server process */ + env?: Record; + /** Working directory for the server process */ + cwd?: string; + /** Whether this server is disabled */ + disabled?: boolean; + /** Custom timeout for this server (ms) */ + timeout?: number; + /** Custom retry attempts for this server */ + retryAttempts?: number; +} + +/** + * Global MCP configuration + */ +export interface MCPConfig { + /** Array of MCP server configurations */ + servers: MCPServerConfig[]; + /** Default timeout for MCP operations (ms) */ + timeout?: number; + /** Default number of retry attempts */ + retryAttempts?: number; + /** Log level for MCP operations */ + logLevel?: LogLevel; + /** Whether to auto-restart failed servers */ + autoRestart?: boolean; + /** Maximum concurrent tool executions per server */ + maxConcurrentTools?: number; +} + +// ============================================================================ +// MCP TOOL INTERFACES +// ============================================================================ + +/** + * MCP tool definition from server + */ +export interface MCPToolDefinition { + /** Tool name */ + name: string; + /** Tool description */ + description: string; + /** JSON schema for tool parameters */ + inputSchema: any; // Use any for flexibility with MCP schema types +} + +/** + * MCP tool execution request + */ +export interface MCPToolRequest { + /** Tool name */ + name: string; + /** Tool arguments */ + arguments: Record; +} + +/** + * MCP tool execution response + */ +export interface MCPToolResponse { + /** Tool execution content */ + content: Array<{ + type: 'text' | 'image' | 'resource'; + text?: string; + data?: string; + mimeType?: string; + }>; + /** Whether the tool execution failed */ + isError?: boolean; +} + +/** + * MCP Tool adapter that implements ITool interface + */ +export interface MCPTool extends ITool { + /** Name of the MCP server this tool belongs to */ + readonly serverName: string; + /** Original MCP tool name */ + readonly mcpToolName: string; + /** MCP tool definition */ + readonly mcpDefinition: MCPToolDefinition; +} + +// ============================================================================ +// MCP SERVER INTERFACES +// ============================================================================ + +/** + * MCP server status + */ +export enum MCPServerStatus { + /** Server is not started */ + Stopped = 'stopped', + /** Server is starting */ + Starting = 'starting', + /** Server is running and ready */ + Running = 'running', + /** Server has failed */ + Failed = 'failed', + /** Server is being stopped */ + Stopping = 'stopping', +} + +/** + * MCP server information + */ +export interface MCPServerInfo { + /** Server name */ + name: string; + /** Current status */ + status: MCPServerStatus; + /** Configuration */ + config: MCPServerConfig; + /** Available tools */ + tools: MCPToolDefinition[]; + /** Last error if any */ + lastError?: string; + /** Start time */ + startTime?: number; + /** Last activity time */ + lastActivity?: number; + /** Process ID if running */ + pid?: number; +} + +/** + * MCP server interface + */ +export interface IMCPServer { + /** Server name */ + readonly name: string; + /** Server configuration */ + readonly config: MCPServerConfig; + /** Current status */ + readonly status: MCPServerStatus; + + /** + * Start the server + */ + start(): Promise; + + /** + * Stop the server + */ + stop(): Promise; + + /** + * Get available tools + */ + getTools(): Promise; + + /** + * Execute a tool + */ + executeTool(request: MCPToolRequest, signal?: AbortSignal): Promise; + + /** + * Check if server is healthy + */ + ping(): Promise; + + /** + * Get server information + */ + getInfo(): MCPServerInfo; +} + +// ============================================================================ +// MCP SERVER MANAGER INTERFACES +// ============================================================================ + +/** + * MCP server manager interface + */ +export interface IMCPServerManager { + /** + * Initialize the manager with configuration + */ + initialize(config: MCPConfig): Promise; + + /** + * Start a specific server + */ + startServer(serverName: string): Promise; + + /** + * Stop a specific server + */ + stopServer(serverName: string): Promise; + + /** + * Restart a server + */ + restartServer(serverName: string): Promise; + + /** + * Get all servers + */ + getServers(): Map; + + /** + * Get a specific server + */ + getServer(serverName: string): IMCPServer | undefined; + + /** + * Get all available tools from all servers + */ + getAllTools(): Promise; + + /** + * Get tools from a specific server + */ + getServerTools(serverName: string): Promise; + + /** + * Execute a tool on a specific server + */ + executeServerTool( + serverName: string, + toolName: string, + params: Record, + signal?: AbortSignal + ): Promise; + + /** + * Shutdown all servers + */ + shutdown(): Promise; + + /** + * Get manager status + */ + getStatus(): { + totalServers: number; + runningServers: number; + failedServers: number; + totalTools: number; + }; +} + +// ============================================================================ +// ERROR INTERFACES +// ============================================================================ + +/** + * MCP error types + */ +export enum MCPErrorType { + /** Server connection failed */ + ServerConnectionFailed = 'server_connection_failed', + /** Server startup failed */ + ServerStartupFailed = 'server_startup_failed', + /** Tool execution failed */ + ToolExecutionFailed = 'tool_execution_failed', + /** Tool not found */ + ToolNotFound = 'tool_not_found', + /** Invalid tool parameters */ + InvalidToolParameters = 'invalid_tool_parameters', + /** Timeout occurred */ + Timeout = 'timeout', + /** Configuration error */ + ConfigurationError = 'configuration_error', + /** Protocol error */ + ProtocolError = 'protocol_error', +} + +/** + * MCP specific error class + */ +export class MCPError extends Error { + override readonly message: string; + + constructor( + public readonly type: MCPErrorType, + message: string, + public readonly serverName?: string, + public readonly toolName?: string, + public override readonly cause?: Error + ) { + super(message); + this.message = message; + this.name = 'MCPError'; + } +} + +// ============================================================================ +// UTILITY TYPES +// ============================================================================ + +/** + * Configuration validation result + */ +export interface ConfigValidationResult { + /** Whether configuration is valid */ + isValid: boolean; + /** Validation errors if any */ + errors: string[]; + /** Validation warnings if any */ + warnings: string[]; +} + +/** + * Tool execution context + */ +export interface MCPToolExecutionContext { + /** Server name */ + serverName: string; + /** Tool name */ + toolName: string; + /** Execution start time */ + startTime: number; + /** Abort signal */ + signal?: AbortSignal; + /** Request ID for tracking */ + requestId: string; +} + +/** + * Type guard for MCPTool + */ +export function isMCPTool(obj: unknown): obj is MCPTool { + return ( + typeof obj === 'object' && + obj !== null && + 'serverName' in obj && + 'mcpToolName' in obj && + 'mcpDefinition' in obj && + typeof (obj as any).serverName === 'string' && + typeof (obj as any).mcpToolName === 'string' + ); +} + +/** + * Type guard for MCPServerConfig + */ +export function isMCPServerConfig(obj: unknown): obj is MCPServerConfig { + return ( + typeof obj === 'object' && + obj !== null && + 'name' in obj && + 'command' in obj && + typeof (obj as any).name === 'string' && + typeof (obj as any).command === 'string' + ); +} \ No newline at end of file diff --git a/src/mcp/mcpAgent.ts b/src/mcp/mcpAgent.ts new file mode 100644 index 0000000..005e02d --- /dev/null +++ b/src/mcp/mcpAgent.ts @@ -0,0 +1,334 @@ +/** + * @fileoverview MCP Agent Implementation + * + * This file extends BaseAgent to provide MCP (Model Context Protocol) integration, + * allowing the agent to use tools from MCP servers alongside regular tools. + */ + +import { BaseAgent } from '../baseAgent.js'; +import { + IAgentStatus, + ITool, + AllConfig, +} from '../interfaces.js'; +import { + MCPConfig, + IMCPServerManager, + MCPTool, +} from './interfaces.js'; +import { MCPServerManager } from './mcpServerManager.js'; +import { ILogger, createLogger, LogLevel } from '../logger.js'; + +/** + * Extended configuration for MCP-enabled agents + */ +export interface MCPAgentConfig extends AllConfig { + /** MCP-specific configuration */ + mcpConfig: MCPConfig; +} + +/** + * MCP-enabled agent that extends BaseAgent with MCP server management + * + * This class provides seamless integration between MCP servers and the + * MiniAgent framework, allowing agents to use tools from multiple MCP + * servers alongside regular tools. + */ +export class MCPAgent extends BaseAgent { + /** MCP server manager */ + private mcpManager: IMCPServerManager; + + /** MCP-specific logger */ + private mcpLogger: ILogger; + + /** MCP configuration */ + private mcpConfig: MCPConfig; + + /** Whether MCP tools are loaded */ + private mcpToolsLoaded = false; + + constructor( + tools: ITool[], + config: MCPAgentConfig + ) { + super(config.agentConfig, config.chatConfig as any, config.toolSchedulerConfig as any); + + this.mcpConfig = config.mcpConfig; + this.mcpLogger = createLogger('MCPAgent', { + level: this.mcpConfig.logLevel || LogLevel.INFO, + }); + + // Create MCP server manager + this.mcpManager = new MCPServerManager(this.mcpConfig.logLevel); + + // Register initial tools + tools.forEach(tool => this.registerTool(tool)); + + this.mcpLogger.info('MCP Agent created', 'MCPAgent.constructor()'); + } + + /** + * Initialize MCP integration + * This should be called after creating the agent to set up MCP servers + */ + async initialize(): Promise { + this.mcpLogger.info('Initializing MCP integration', 'MCPAgent.initialize()'); + + try { + // Initialize MCP server manager + await this.mcpManager.initialize(this.mcpConfig); + + // Start enabled servers + await this.startEnabledServers(); + + // Load MCP tools + await this.loadMCPTools(); + + this.mcpLogger.info('MCP integration initialized successfully', 'MCPAgent.initialize()'); + } catch (error) { + this.mcpLogger.error( + `Failed to initialize MCP integration: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.initialize()' + ); + throw error; + } + } + + /** + * Load MCP tools from all running servers + */ + async loadMCPTools(): Promise { + this.mcpLogger.info('Loading MCP tools', 'MCPAgent.loadMCPTools()'); + + try { + const mcpTools = await this.mcpManager.getAllTools(); + + // Register MCP tools with the tool scheduler + mcpTools.forEach(tool => { + try { + this.registerTool(tool); + this.mcpLogger.debug(`Registered MCP tool: ${tool.name}`, 'MCPAgent.loadMCPTools()'); + } catch (error) { + this.mcpLogger.warn( + `Failed to register MCP tool ${tool.name}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.loadMCPTools()' + ); + } + }); + + this.mcpToolsLoaded = true; + this.mcpLogger.info(`Loaded ${mcpTools.length} MCP tools`, 'MCPAgent.loadMCPTools()'); + } catch (error) { + this.mcpLogger.error( + `Failed to load MCP tools: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.loadMCPTools()' + ); + throw error; + } + } + + /** + * Refresh MCP tools (reload from servers) + */ + async refreshMCPTools(): Promise { + this.mcpLogger.info('Refreshing MCP tools', 'MCPAgent.refreshMCPTools()'); + + try { + // Remove existing MCP tools + const currentTools = this.getToolList(); + const mcpToolsToRemove = currentTools.filter(tool => this.isMCPTool(tool)); + + mcpToolsToRemove.forEach(tool => { + this.removeTool(tool.name); + this.mcpLogger.debug(`Removed MCP tool: ${tool.name}`, 'MCPAgent.refreshMCPTools()'); + }); + + // Reload MCP tools + await this.loadMCPTools(); + + this.mcpLogger.info('MCP tools refreshed successfully', 'MCPAgent.refreshMCPTools()'); + } catch (error) { + this.mcpLogger.error( + `Failed to refresh MCP tools: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.refreshMCPTools()' + ); + throw error; + } + } + + /** + * Start a specific MCP server + */ + async startMCPServer(serverName: string): Promise { + this.mcpLogger.info(`Starting MCP server: ${serverName}`, 'MCPAgent.startMCPServer()'); + + try { + await this.mcpManager.startServer(serverName); + + // Load tools from the newly started server + const serverTools = await this.mcpManager.getServerTools(serverName); + serverTools.forEach(tool => { + this.registerTool(tool); + this.mcpLogger.debug(`Registered tool from server ${serverName}: ${tool.name}`, 'MCPAgent.startMCPServer()'); + }); + + this.mcpLogger.info(`Started MCP server and loaded ${serverTools.length} tools: ${serverName}`, 'MCPAgent.startMCPServer()'); + } catch (error) { + this.mcpLogger.error( + `Failed to start MCP server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.startMCPServer()' + ); + throw error; + } + } + + /** + * Stop a specific MCP server + */ + async stopMCPServer(serverName: string): Promise { + this.mcpLogger.info(`Stopping MCP server: ${serverName}`, 'MCPAgent.stopMCPServer()'); + + try { + // Remove tools from this server + const currentTools = this.getToolList(); + const serverTools = currentTools.filter(tool => + this.isMCPTool(tool) && (tool as MCPTool).serverName === serverName + ); + + serverTools.forEach(tool => { + this.removeTool(tool.name); + this.mcpLogger.debug(`Removed tool from server ${serverName}: ${tool.name}`, 'MCPAgent.stopMCPServer()'); + }); + + // Stop the server + await this.mcpManager.stopServer(serverName); + + this.mcpLogger.info(`Stopped MCP server and removed ${serverTools.length} tools: ${serverName}`, 'MCPAgent.stopMCPServer()'); + } catch (error) { + this.mcpLogger.error( + `Failed to stop MCP server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.stopMCPServer()' + ); + throw error; + } + } + + /** + * Restart a specific MCP server + */ + async restartMCPServer(serverName: string): Promise { + this.mcpLogger.info(`Restarting MCP server: ${serverName}`, 'MCPAgent.restartMCPServer()'); + + try { + await this.stopMCPServer(serverName); + await this.startMCPServer(serverName); + this.mcpLogger.info(`Restarted MCP server: ${serverName}`, 'MCPAgent.restartMCPServer()'); + } catch (error) { + this.mcpLogger.error( + `Failed to restart MCP server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.restartMCPServer()' + ); + throw error; + } + } + + /** + * Get MCP server manager + */ + getMCPManager(): IMCPServerManager { + return this.mcpManager; + } + + /** + * Get MCP-specific status information + */ + getMCPStatus(): { + mcpEnabled: boolean; + toolsLoaded: boolean; + serverStatus: ReturnType; + mcpTools: MCPTool[]; + } { + const mcpTools = this.getToolList().filter(tool => this.isMCPTool(tool)) as MCPTool[]; + + return { + mcpEnabled: true, + toolsLoaded: this.mcpToolsLoaded, + serverStatus: this.mcpManager.getStatus(), + mcpTools, + }; + } + + /** + * Override getStatus to include MCP information + */ + override getStatus(): IAgentStatus & { mcpStatus?: ReturnType } { + const baseStatus = super.getStatus(); + const mcpStatus = this.getMCPStatus(); + + return { + ...baseStatus, + mcpStatus, + }; + } + + /** + * Shutdown agent and clean up MCP resources + */ + async shutdown(): Promise { + this.mcpLogger.info('Shutting down MCP Agent', 'MCPAgent.shutdown()'); + + try { + await this.mcpManager.shutdown(); + this.mcpLogger.info('MCP Agent shut down successfully', 'MCPAgent.shutdown()'); + } catch (error) { + this.mcpLogger.error( + `Error during MCP Agent shutdown: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.shutdown()' + ); + throw error; + } + } + + /** + * Start all enabled servers from configuration + */ + private async startEnabledServers(): Promise { + const enabledServers = this.mcpConfig.servers.filter(server => !server.disabled); + + this.mcpLogger.info(`Starting ${enabledServers.length} enabled servers`, 'MCPAgent.startEnabledServers()'); + + const startPromises = enabledServers.map(async (serverConfig) => { + try { + await this.mcpManager.startServer(serverConfig.name); + this.mcpLogger.info(`Started server: ${serverConfig.name}`, 'MCPAgent.startEnabledServers()'); + } catch (error) { + this.mcpLogger.error( + `Failed to start server ${serverConfig.name}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPAgent.startEnabledServers()' + ); + // Continue with other servers + } + }); + + await Promise.all(startPromises); + } + + /** + * Check if a tool is an MCP tool + */ + private isMCPTool(tool: ITool): tool is MCPTool { + return 'serverName' in tool && 'mcpToolName' in tool && 'mcpDefinition' in tool; + } +} + +/** + * Factory function to create MCP-enabled agents + */ +export async function createMCPAgent( + tools: ITool[], + config: MCPAgentConfig +): Promise { + const agent = new MCPAgent(tools, config); + await agent.initialize(); + return agent; +} \ No newline at end of file diff --git a/src/mcp/mcpServer.ts b/src/mcp/mcpServer.ts new file mode 100644 index 0000000..d640296 --- /dev/null +++ b/src/mcp/mcpServer.ts @@ -0,0 +1,529 @@ +/** + * @fileoverview MCP Server Implementation + * + * This file implements the IMCPServer interface, providing a complete + * MCP server wrapper that handles process management, tool execution, + * and error handling. + */ + +import { spawn, ChildProcess } from 'child_process'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { + IMCPServer, + MCPServerConfig, + MCPServerStatus, + MCPServerInfo, + MCPToolDefinition, + MCPToolRequest, + MCPToolResponse, + MCPError, + MCPErrorType, +} from './interfaces.js'; +import { ILogger, createLogger, LogLevel } from '../logger.js'; + +/** + * Implementation of IMCPServer interface + * + * This class manages a single MCP server process and provides + * type-safe methods for tool execution and server management. + */ +export class MCPServer implements IMCPServer { + /** Server name */ + public readonly name: string; + + /** Server configuration */ + public readonly config: MCPServerConfig; + + /** Current server status */ + private _status: MCPServerStatus = MCPServerStatus.Stopped; + + /** Child process reference */ + private process: ChildProcess | null = null; + + /** MCP client instance */ + private client: Client | null = null; + + /** Transport for client communication */ + private transport: StdioClientTransport | null = null; + + /** Available tools cache */ + private toolsCache: MCPToolDefinition[] = []; + + /** Last error message */ + private lastError?: string; + + /** Start time */ + private startTime?: number | undefined; + + /** Last activity time */ + private lastActivity?: number; + + /** Logger instance */ + private logger: ILogger; + + /** Timeout for operations */ + private readonly timeout: number; + + /** Number of retry attempts */ + private readonly retryAttempts: number; + + constructor( + config: MCPServerConfig, + globalTimeout: number = 30000, + globalRetryAttempts: number = 3, + logLevel: LogLevel = LogLevel.INFO + ) { + this.name = config.name; + this.config = config; + this.timeout = config.timeout ?? globalTimeout; + this.retryAttempts = config.retryAttempts ?? globalRetryAttempts; + + this.logger = createLogger(`MCPServer:${this.name}`, { + level: logLevel, + }); + + this.logger.debug(`MCP Server initialized: ${this.name}`, 'MCPServer.constructor()'); + } + + /** Get current status */ + get status(): MCPServerStatus { + return this._status; + } + + /** + * Start the MCP server + */ + async start(): Promise { + if (this._status === MCPServerStatus.Running) { + this.logger.debug('Server already running', 'MCPServer.start()'); + return; + } + + if (this._status === MCPServerStatus.Starting) { + throw new MCPError( + MCPErrorType.ServerStartupFailed, + 'Server is already starting', + this.name + ); + } + + this._status = MCPServerStatus.Starting; + this.logger.info('Starting MCP server', 'MCPServer.start()'); + + try { + await this.startWithRetry(); + this._status = MCPServerStatus.Running; + this.startTime = Date.now(); + this.lastActivity = Date.now(); + this.logger.info('MCP server started successfully', 'MCPServer.start()'); + } catch (error) { + this._status = MCPServerStatus.Failed; + this.lastError = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to start MCP server: ${this.lastError}`, 'MCPServer.start()'); + throw error; + } + } + + /** + * Start server with retry logic + */ + private async startWithRetry(): Promise { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= this.retryAttempts; attempt++) { + try { + this.logger.debug(`Start attempt ${attempt}/${this.retryAttempts}`, 'MCPServer.startWithRetry()'); + await this.startServer(); + return; // Success + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + this.logger.warn(`Start attempt ${attempt} failed: ${lastError.message}`, 'MCPServer.startWithRetry()'); + + if (attempt < this.retryAttempts) { + // Wait before retry (exponential backoff) + const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw new MCPError( + MCPErrorType.ServerStartupFailed, + `Failed to start server after ${this.retryAttempts} attempts: ${lastError?.message}`, + this.name, + undefined, + lastError || undefined + ); + } + + /** + * Internal server startup logic + */ + private async startServer(): Promise { + // Spawn the server process + const env = { + ...process.env, + ...this.config.env, + }; + + this.process = spawn(this.config.command, this.config.args || [], { + env, + cwd: this.config.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + if (!this.process.stdin || !this.process.stdout) { + throw new MCPError( + MCPErrorType.ServerStartupFailed, + 'Failed to create stdio streams', + this.name + ); + } + + // Set up error handling + this.process.on('error', (error) => { + this.logger.error(`Process error: ${error.message}`, 'MCPServer.startServer()'); + this.handleProcessError(error); + }); + + this.process.on('exit', (code, signal) => { + this.logger.warn(`Process exited with code ${code}, signal ${signal}`, 'MCPServer.startServer()'); + this.handleProcessExit(code, signal); + }); + + // Create transport and client + this.transport = new StdioClientTransport({ + reader: this.process.stdout, + writer: this.process.stdin, + } as any); // SDK type issue - reader/writer are correct parameters + + this.client = new Client( + { + name: `mini-agent-${this.name}`, + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + try { + // Connect with timeout + await Promise.race([ + this.client.connect(this.transport), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timeout')), this.timeout) + ), + ]); + + this.logger.debug('Client connected successfully', 'MCPServer.startServer()'); + + // Load tools + await this.loadTools(); + } catch (error) { + await this.cleanup(); + throw new MCPError( + MCPErrorType.ServerConnectionFailed, + `Failed to connect to server: ${error instanceof Error ? error.message : String(error)}`, + this.name, + undefined, + error instanceof Error ? error : undefined + ); + } + } + + /** + * Stop the MCP server + */ + async stop(): Promise { + if (this._status === MCPServerStatus.Stopped) { + this.logger.debug('Server already stopped', 'MCPServer.stop()'); + return; + } + + if (this._status === MCPServerStatus.Stopping) { + this.logger.debug('Server already stopping', 'MCPServer.stop()'); + return; + } + + this._status = MCPServerStatus.Stopping; + this.logger.info('Stopping MCP server', 'MCPServer.stop()'); + + try { + await this.cleanup(); + this._status = MCPServerStatus.Stopped; + this.startTime = undefined; + this.logger.info('MCP server stopped successfully', 'MCPServer.stop()'); + } catch (error) { + this.logger.error(`Error stopping server: ${error instanceof Error ? error.message : String(error)}`, 'MCPServer.stop()'); + this._status = MCPServerStatus.Failed; + throw error; + } + } + + /** + * Get available tools from the server + */ + async getTools(): Promise { + if (this._status !== MCPServerStatus.Running) { + throw new MCPError( + MCPErrorType.ServerConnectionFailed, + 'Server is not running', + this.name + ); + } + + if (this.toolsCache.length > 0) { + return this.toolsCache; + } + + return await this.loadTools(); + } + + /** + * Execute a tool on the server + */ + async executeTool(request: MCPToolRequest, signal?: AbortSignal): Promise { + if (this._status !== MCPServerStatus.Running) { + throw new MCPError( + MCPErrorType.ServerConnectionFailed, + 'Server is not running', + this.name, + request.name + ); + } + + if (!this.client) { + throw new MCPError( + MCPErrorType.ServerConnectionFailed, + 'Client not initialized', + this.name, + request.name + ); + } + + this.logger.debug(`Executing tool: ${request.name}`, 'MCPServer.executeTool()'); + this.lastActivity = Date.now(); + + try { + const timeoutPromise = new Promise((_, reject) => { + const timeout = setTimeout(() => { + reject(new MCPError( + MCPErrorType.Timeout, + `Tool execution timeout after ${this.timeout}ms`, + this.name, + request.name + )); + }, this.timeout); + + signal?.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new MCPError( + MCPErrorType.ToolExecutionFailed, + 'Tool execution aborted', + this.name, + request.name + )); + }); + }); + + const executionPromise = this.client.callTool({ + name: request.name, + arguments: request.arguments, + }); + + const result = await Promise.race([executionPromise, timeoutPromise]); + + this.logger.debug(`Tool execution completed: ${request.name}`, 'MCPServer.executeTool()'); + + return { + content: result.content as Array<{ + type: 'text' | 'image' | 'resource'; + text?: string; + data?: string; + mimeType?: string; + }>, + isError: result.isError as boolean, + }; + } catch (error) { + this.logger.error(`Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, 'MCPServer.executeTool()'); + + if (error instanceof MCPError) { + throw error; + } + + throw new MCPError( + MCPErrorType.ToolExecutionFailed, + `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, + this.name, + request.name, + error instanceof Error ? error : undefined + ); + } + } + + /** + * Check if server is healthy + */ + async ping(): Promise { + if (this._status !== MCPServerStatus.Running) { + return false; + } + + if (!this.client) { + return false; + } + + try { + // Simple ping operation - just try to list tools + await Promise.race([ + this.client.listTools(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Ping timeout')), 5000) + ), + ]); + + this.lastActivity = Date.now(); + return true; + } catch (error) { + this.logger.warn(`Ping failed: ${error instanceof Error ? error.message : String(error)}`, 'MCPServer.ping()'); + return false; + } + } + + /** + * Get server information + */ + getInfo(): MCPServerInfo { + const info: MCPServerInfo = { + name: this.name, + status: this._status, + config: this.config, + tools: this.toolsCache, + }; + + if (this.lastError !== undefined) { + info.lastError = this.lastError; + } + if (this.startTime !== undefined) { + info.startTime = this.startTime; + } + if (this.lastActivity !== undefined) { + info.lastActivity = this.lastActivity; + } + if (this.process?.pid !== undefined) { + info.pid = this.process.pid; + } + + return info; + } + + /** + * Load tools from the server + */ + private async loadTools(): Promise { + if (!this.client) { + throw new MCPError( + MCPErrorType.ServerConnectionFailed, + 'Client not initialized', + this.name + ); + } + + try { + this.logger.debug('Loading tools from server', 'MCPServer.loadTools()'); + const result = await this.client.listTools(); + + this.toolsCache = result.tools.map(tool => ({ + name: tool.name, + description: tool.description || '', + inputSchema: tool.inputSchema as any, + })); + + this.logger.info(`Loaded ${this.toolsCache.length} tools`, 'MCPServer.loadTools()'); + return this.toolsCache; + } catch (error) { + this.logger.error(`Failed to load tools: ${error instanceof Error ? error.message : String(error)}`, 'MCPServer.loadTools()'); + throw new MCPError( + MCPErrorType.ProtocolError, + `Failed to load tools: ${error instanceof Error ? error.message : String(error)}`, + this.name, + undefined, + error instanceof Error ? error : undefined + ); + } + } + + /** + * Handle process errors + */ + private handleProcessError(error: Error): void { + this.logger.error(`Process error: ${error.message}`, 'MCPServer.handleProcessError()'); + this._status = MCPServerStatus.Failed; + this.lastError = error.message; + } + + /** + * Handle process exit + */ + private handleProcessExit(code: number | null, signal: string | null): void { + this.logger.info(`Process exited: code=${code}, signal=${signal}`, 'MCPServer.handleProcessExit()'); + + if (this._status === MCPServerStatus.Stopping) { + this._status = MCPServerStatus.Stopped; + } else { + this._status = MCPServerStatus.Failed; + this.lastError = `Process exited unexpectedly: code=${code}, signal=${signal}`; + } + + this.cleanup().catch(error => { + this.logger.error(`Cleanup error: ${error instanceof Error ? error.message : String(error)}`, 'MCPServer.handleProcessExit()'); + }); + } + + /** + * Clean up resources + */ + private async cleanup(): Promise { + this.logger.debug('Cleaning up server resources', 'MCPServer.cleanup()'); + + // Close client connection + if (this.client) { + try { + await this.client.close(); + } catch (error) { + this.logger.warn(`Error closing client: ${error instanceof Error ? error.message : String(error)}`, 'MCPServer.cleanup()'); + } + this.client = null; + } + + // Close transport + if (this.transport) { + try { + await this.transport.close(); + } catch (error) { + this.logger.warn(`Error closing transport: ${error instanceof Error ? error.message : String(error)}`, 'MCPServer.cleanup()'); + } + this.transport = null; + } + + // Kill process if still running + if (this.process && !this.process.killed) { + this.process.kill('SIGTERM'); + + // Wait for graceful shutdown, then force kill + setTimeout(() => { + if (this.process && !this.process.killed) { + this.logger.warn('Force killing process', 'MCPServer.cleanup()'); + this.process.kill('SIGKILL'); + } + }, 5000); + } + + this.process = null; + this.toolsCache = []; + } +} \ No newline at end of file diff --git a/src/mcp/mcpServerManager.ts b/src/mcp/mcpServerManager.ts new file mode 100644 index 0000000..1d7293e --- /dev/null +++ b/src/mcp/mcpServerManager.ts @@ -0,0 +1,511 @@ +/** + * @fileoverview MCP Server Manager Implementation + * + * This file implements the IMCPServerManager interface, providing centralized + * management of multiple MCP servers, tool aggregation, and lifecycle management. + */ + +import { + IMCPServerManager, + IMCPServer, + MCPConfig, + MCPServerConfig, + MCPTool, + MCPToolResponse, + MCPError, + MCPErrorType, + MCPServerStatus, + ConfigValidationResult, + isMCPServerConfig, +} from './interfaces.js'; +import { MCPServer } from './mcpServer.js'; +import { MCPToolAdapter } from './mcpToolAdapter.js'; +import { ILogger, createLogger, LogLevel } from '../logger.js'; + +/** + * Implementation of IMCPServerManager interface + * + * This class manages multiple MCP servers, handles their lifecycle, + * aggregates tools from all servers, and provides a unified interface + * for tool execution across all managed servers. + */ +export class MCPServerManager implements IMCPServerManager { + /** Map of server name to server instance */ + private servers: Map = new Map(); + + /** Manager configuration */ + private config: MCPConfig | null = null; + + /** Logger instance */ + private logger: ILogger; + + /** Whether manager is initialized */ + private initialized = false; + + /** Auto-restart enabled servers */ + private autoRestartServers = new Set(); + + /** Server restart timers */ + private restartTimers = new Map(); + + constructor(logLevel: LogLevel = LogLevel.INFO) { + this.logger = createLogger('MCPServerManager', { + level: logLevel, + }); + + this.logger.debug('MCP Server Manager created', 'MCPServerManager.constructor()'); + } + + /** + * Initialize the manager with configuration + */ + async initialize(config: MCPConfig): Promise { + if (this.initialized) { + this.logger.warn('Manager already initialized', 'MCPServerManager.initialize()'); + return; + } + + this.logger.info('Initializing MCP Server Manager', 'MCPServerManager.initialize()'); + + // Validate configuration + const validation = this.validateConfig(config); + if (!validation.isValid) { + throw new MCPError( + MCPErrorType.ConfigurationError, + `Invalid configuration: ${validation.errors.join(', ')}` + ); + } + + // Log warnings if any + validation.warnings.forEach(warning => { + this.logger.warn(warning, 'MCPServerManager.initialize()'); + }); + + this.config = config; + + // Create and initialize servers + const serverPromises = config.servers + .filter(serverConfig => !serverConfig.disabled) + .map(async (serverConfig) => { + try { + await this.createServer(serverConfig); + this.logger.info(`Server created: ${serverConfig.name}`, 'MCPServerManager.initialize()'); + } catch (error) { + this.logger.error( + `Failed to create server ${serverConfig.name}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.initialize()' + ); + // Continue with other servers + } + }); + + await Promise.all(serverPromises); + + this.initialized = true; + this.logger.info(`Manager initialized with ${this.servers.size} servers`, 'MCPServerManager.initialize()'); + } + + /** + * Start a specific server + */ + async startServer(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new MCPError( + MCPErrorType.ConfigurationError, + `Server not found: ${serverName}`, + serverName + ); + } + + this.logger.info(`Starting server: ${serverName}`, 'MCPServerManager.startServer()'); + + try { + await server.start(); + + if (this.config?.autoRestart) { + this.autoRestartServers.add(serverName); + this.setupAutoRestart(serverName); + } + + this.logger.info(`Server started: ${serverName}`, 'MCPServerManager.startServer()'); + } catch (error) { + this.logger.error( + `Failed to start server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.startServer()' + ); + throw error; + } + } + + /** + * Stop a specific server + */ + async stopServer(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new MCPError( + MCPErrorType.ConfigurationError, + `Server not found: ${serverName}`, + serverName + ); + } + + this.logger.info(`Stopping server: ${serverName}`, 'MCPServerManager.stopServer()'); + + try { + // Remove from auto-restart + this.autoRestartServers.delete(serverName); + const timer = this.restartTimers.get(serverName); + if (timer) { + clearTimeout(timer); + this.restartTimers.delete(serverName); + } + + await server.stop(); + this.logger.info(`Server stopped: ${serverName}`, 'MCPServerManager.stopServer()'); + } catch (error) { + this.logger.error( + `Failed to stop server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.stopServer()' + ); + throw error; + } + } + + /** + * Restart a server + */ + async restartServer(serverName: string): Promise { + this.logger.info(`Restarting server: ${serverName}`, 'MCPServerManager.restartServer()'); + + try { + await this.stopServer(serverName); + // Brief delay before restart + await new Promise(resolve => setTimeout(resolve, 1000)); + await this.startServer(serverName); + this.logger.info(`Server restarted: ${serverName}`, 'MCPServerManager.restartServer()'); + } catch (error) { + this.logger.error( + `Failed to restart server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.restartServer()' + ); + throw error; + } + } + + /** + * Get all servers + */ + getServers(): Map { + return new Map(this.servers); + } + + /** + * Get a specific server + */ + getServer(serverName: string): IMCPServer | undefined { + return this.servers.get(serverName); + } + + /** + * Get all available tools from all servers + */ + async getAllTools(): Promise { + const allTools: MCPTool[] = []; + + const toolPromises = Array.from(this.servers.entries()).map(async ([serverName, server]) => { + try { + if (server.status === MCPServerStatus.Running) { + const tools = await this.getServerTools(serverName); + allTools.push(...tools); + } + } catch (error) { + this.logger.warn( + `Failed to get tools from server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.getAllTools()' + ); + } + }); + + await Promise.all(toolPromises); + + this.logger.debug(`Retrieved ${allTools.length} tools from all servers`, 'MCPServerManager.getAllTools()'); + return allTools; + } + + /** + * Get tools from a specific server + */ + async getServerTools(serverName: string): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new MCPError( + MCPErrorType.ConfigurationError, + `Server not found: ${serverName}`, + serverName + ); + } + + if (server.status !== MCPServerStatus.Running) { + this.logger.warn(`Server ${serverName} is not running`, 'MCPServerManager.getServerTools()'); + return []; + } + + try { + const mcpTools = await server.getTools(); + + const tools: MCPTool[] = mcpTools.map(mcpTool => + new MCPToolAdapter(this, serverName, mcpTool) + ); + + this.logger.debug(`Retrieved ${tools.length} tools from server ${serverName}`, 'MCPServerManager.getServerTools()'); + return tools; + } catch (error) { + this.logger.error( + `Failed to get tools from server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.getServerTools()' + ); + throw error; + } + } + + /** + * Execute a tool on a specific server + */ + async executeServerTool( + serverName: string, + toolName: string, + params: Record, + signal?: AbortSignal + ): Promise { + const server = this.servers.get(serverName); + if (!server) { + throw new MCPError( + MCPErrorType.ConfigurationError, + `Server not found: ${serverName}`, + serverName, + toolName + ); + } + + if (server.status !== MCPServerStatus.Running) { + throw new MCPError( + MCPErrorType.ServerConnectionFailed, + `Server ${serverName} is not running`, + serverName, + toolName + ); + } + + this.logger.debug(`Executing tool ${toolName} on server ${serverName}`, 'MCPServerManager.executeServerTool()'); + + try { + const response = await server.executeTool({ + name: toolName, + arguments: params, + }, signal); + + this.logger.debug(`Tool execution completed: ${toolName}`, 'MCPServerManager.executeServerTool()'); + return response; + } catch (error) { + this.logger.error( + `Tool execution failed: ${toolName} on ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.executeServerTool()' + ); + throw error; + } + } + + /** + * Shutdown all servers + */ + async shutdown(): Promise { + this.logger.info('Shutting down all servers', 'MCPServerManager.shutdown()'); + + // Clear all restart timers + this.restartTimers.forEach(timer => clearTimeout(timer)); + this.restartTimers.clear(); + this.autoRestartServers.clear(); + + // Stop all servers + const shutdownPromises = Array.from(this.servers.entries()).map(async ([serverName, server]) => { + try { + await server.stop(); + this.logger.debug(`Server stopped: ${serverName}`, 'MCPServerManager.shutdown()'); + } catch (error) { + this.logger.error( + `Error stopping server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.shutdown()' + ); + } + }); + + await Promise.all(shutdownPromises); + + this.servers.clear(); + this.initialized = false; + this.logger.info('All servers shut down', 'MCPServerManager.shutdown()'); + } + + /** + * Get manager status + */ + getStatus(): { + totalServers: number; + runningServers: number; + failedServers: number; + totalTools: number; + } { + const servers = Array.from(this.servers.values()); + const runningServers = servers.filter(s => s.status === MCPServerStatus.Running).length; + const failedServers = servers.filter(s => s.status === MCPServerStatus.Failed).length; + + // Count total tools from all running servers + let totalTools = 0; + servers.forEach(server => { + if (server.status === MCPServerStatus.Running) { + totalTools += server.getInfo().tools.length; + } + }); + + return { + totalServers: this.servers.size, + runningServers, + failedServers, + totalTools, + }; + } + + /** + * Create a server instance + */ + private async createServer(serverConfig: MCPServerConfig): Promise { + if (!this.config) { + throw new MCPError( + MCPErrorType.ConfigurationError, + 'Manager not initialized' + ); + } + + const server = new MCPServer( + serverConfig, + this.config.timeout, + this.config.retryAttempts, + this.config.logLevel + ); + + this.servers.set(serverConfig.name, server); + } + + /** + * Set up auto-restart for a server + */ + private setupAutoRestart(serverName: string): void { + const server = this.servers.get(serverName); + if (!server) return; + + // Monitor server status and restart if it fails + const checkServer = async () => { + if (!this.autoRestartServers.has(serverName)) { + return; // Auto-restart disabled for this server + } + + const serverInfo = server.getInfo(); + if (serverInfo.status === MCPServerStatus.Failed) { + this.logger.warn(`Server ${serverName} failed, attempting auto-restart`, 'MCPServerManager.setupAutoRestart()'); + + try { + await this.restartServer(serverName); + } catch (error) { + this.logger.error( + `Auto-restart failed for server ${serverName}: ${error instanceof Error ? error.message : String(error)}`, + 'MCPServerManager.setupAutoRestart()' + ); + } + } + + // Schedule next check + if (this.autoRestartServers.has(serverName)) { + const timer = setTimeout(checkServer, 10000); // Check every 10 seconds + this.restartTimers.set(serverName, timer); + } + }; + + // Start monitoring + const timer = setTimeout(checkServer, 10000); + this.restartTimers.set(serverName, timer); + } + + /** + * Validate configuration + */ + private validateConfig(config: MCPConfig): ConfigValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check basic structure + if (!config.servers || !Array.isArray(config.servers)) { + errors.push('servers must be an array'); + return { isValid: false, errors, warnings }; + } + + if (config.servers.length === 0) { + warnings.push('No servers configured'); + } + + // Validate each server config + const serverNames = new Set(); + config.servers.forEach((serverConfig, index) => { + if (!isMCPServerConfig(serverConfig)) { + errors.push(`Server at index ${index} is invalid`); + return; + } + + // Check for duplicate names + if (serverNames.has(serverConfig.name)) { + errors.push(`Duplicate server name: ${serverConfig.name}`); + } + serverNames.add(serverConfig.name); + + // Validate name + if (!serverConfig.name.trim()) { + errors.push(`Server at index ${index} has empty name`); + } + + // Validate command + if (!serverConfig.command.trim()) { + errors.push(`Server ${serverConfig.name} has empty command`); + } + + // Check timeout values + if (serverConfig.timeout !== undefined && serverConfig.timeout <= 0) { + errors.push(`Server ${serverConfig.name} has invalid timeout`); + } + + // Check retry attempts + if (serverConfig.retryAttempts !== undefined && serverConfig.retryAttempts < 0) { + errors.push(`Server ${serverConfig.name} has invalid retry attempts`); + } + }); + + // Validate global settings + if (config.timeout !== undefined && config.timeout <= 0) { + errors.push('Global timeout must be positive'); + } + + if (config.retryAttempts !== undefined && config.retryAttempts < 0) { + errors.push('Global retry attempts must be non-negative'); + } + + if (config.maxConcurrentTools !== undefined && config.maxConcurrentTools <= 0) { + errors.push('Max concurrent tools must be positive'); + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } +} \ No newline at end of file diff --git a/src/mcp/mcpToolAdapter.ts b/src/mcp/mcpToolAdapter.ts new file mode 100644 index 0000000..1de2e52 --- /dev/null +++ b/src/mcp/mcpToolAdapter.ts @@ -0,0 +1,388 @@ +/** + * @fileoverview MCP Tool Adapter Implementation + * + * This file implements the MCPTool interface, providing an adapter that wraps + * MCP tools to conform to the ITool interface used by the MiniAgent framework. + */ + +import { + MCPTool, + MCPToolDefinition, + MCPToolResponse, + IMCPServerManager, + MCPError, + MCPErrorType, +} from './interfaces.js'; +import { + ToolResult, + ToolCallConfirmationDetails, + ToolMcpConfirmationDetails, + ToolConfirmationOutcome, + ToolDeclaration, +} from '../interfaces.js'; +import { ILogger, createLogger, LogLevel } from '../logger.js'; + +/** + * Adapter class that wraps MCP tools to implement the ITool interface + * + * This class provides seamless integration between MCP servers and the + * MiniAgent tool system, handling parameter validation, execution, + * and confirmation workflows. + */ +export class MCPToolAdapter implements MCPTool { + /** Tool name (prefixed with server name for uniqueness) */ + public readonly name: string; + + /** Tool description */ + public readonly description: string; + + /** Tool schema in MiniAgent format */ + public readonly schema: ToolDeclaration; + + /** Whether output is markdown */ + public readonly isOutputMarkdown: boolean = false; + + /** Whether tool supports streaming output */ + public readonly canUpdateOutput: boolean = false; + + /** MCP server name */ + public readonly serverName: string; + + /** Original MCP tool name */ + public readonly mcpToolName: string; + + /** MCP tool definition */ + public readonly mcpDefinition: MCPToolDefinition; + + /** Logger instance */ + private logger: ILogger; + + constructor( + private serverManager: IMCPServerManager, + serverName: string, + mcpDefinition: MCPToolDefinition, + logLevel: LogLevel = LogLevel.INFO + ) { + this.serverName = serverName; + this.mcpToolName = mcpDefinition.name; + this.mcpDefinition = mcpDefinition; + + // Create unique tool name by prefixing with server name + this.name = `${serverName}_${mcpDefinition.name}`; + this.description = mcpDefinition.description || `MCP tool ${mcpDefinition.name} from ${serverName}`; + + // Convert MCP schema to MiniAgent schema + this.schema = this.convertMCPSchemaToToolDeclaration(mcpDefinition); + + this.logger = createLogger(`MCPToolAdapter:${this.name}`, { + level: logLevel, + }); + + this.logger.debug(`MCP Tool Adapter created: ${this.name}`, 'MCPToolAdapter.constructor()'); + } + + /** + * Validate tool parameters + */ + validateToolParams(params: Record): string | null { + try { + const schema = this.mcpDefinition.inputSchema; + + // Check required parameters + if (schema.required) { + for (const requiredParam of schema.required) { + if (!(requiredParam in params)) { + return `Missing required parameter: ${requiredParam}`; + } + } + } + + // Basic type validation + if (schema.properties) { + for (const [paramName, paramValue] of Object.entries(params)) { + const paramSchema = schema.properties[paramName]; + if (!paramSchema) { + // Allow additional properties for flexibility + continue; + } + + const validationError = this.validateParameterValue(paramName, paramValue, paramSchema); + if (validationError) { + return validationError; + } + } + } + + return null; // Valid + } catch (error) { + this.logger.error(`Parameter validation error: ${error instanceof Error ? error.message : String(error)}`, 'MCPToolAdapter.validateToolParams()'); + return `Parameter validation failed: ${error instanceof Error ? error.message : String(error)}`; + } + } + + /** + * Get tool description for given parameters + */ + getDescription(params: Record): string { + const baseDescription = this.description; + + // Add parameter information if available + const paramInfo = Object.keys(params).length > 0 + ? ` with parameters: ${JSON.stringify(params, null, 2)}` + : ''; + + return `${baseDescription}${paramInfo}`; + } + + /** + * Check if tool requires confirmation before execution + */ + async shouldConfirmExecute( + params: Record + ): Promise { + // First validate parameters + const validationError = this.validateToolParams(params); + if (validationError) { + return false; // Don't confirm if parameters are invalid + } + + // Check if this is a potentially destructive operation + const isDestructive = this.isDestructiveOperation(params); + + if (!isDestructive) { + return false; // No confirmation needed for safe operations + } + + // Return MCP confirmation details + return { + type: 'mcp', + title: `Execute MCP Tool: ${this.mcpToolName}`, + serverName: this.serverName, + toolName: this.mcpToolName, + toolDisplayName: this.name, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + // The confirmation outcome will be handled by the tool scheduler + this.logger.debug(`Tool confirmation: ${outcome}`, 'MCPToolAdapter.shouldConfirmExecute()'); + }, + } as ToolMcpConfirmationDetails; + } + + /** + * Execute the tool + */ + async execute( + params: Record, + signal: AbortSignal, + updateOutput?: (output: string) => void + ): Promise { + this.logger.info(`Executing MCP tool: ${this.mcpToolName}`, 'MCPToolAdapter.execute()'); + + // Validate parameters first + const validationError = this.validateToolParams(params); + if (validationError) { + throw new MCPError( + MCPErrorType.InvalidToolParameters, + validationError, + this.serverName, + this.mcpToolName + ); + } + + try { + // Optional: Provide execution feedback + updateOutput?.(`Executing ${this.mcpToolName} on ${this.serverName}...`); + + // Execute the tool via server manager + const response: MCPToolResponse = await this.serverManager.executeServerTool( + this.serverName, + this.mcpToolName, + params, + signal + ); + + // Handle error responses + if (response.isError) { + const errorMessage = this.extractErrorMessage(response); + throw new MCPError( + MCPErrorType.ToolExecutionFailed, + errorMessage, + this.serverName, + this.mcpToolName + ); + } + + // Convert MCP response to tool result + const result = this.convertMCPResponseToToolResult(response); + + this.logger.info(`Tool execution completed: ${this.mcpToolName}`, 'MCPToolAdapter.execute()'); + updateOutput?.(`โœ“ ${this.mcpToolName} completed successfully`); + + return result; + } catch (error) { + this.logger.error( + `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, + 'MCPToolAdapter.execute()' + ); + + updateOutput?.(`โœ— ${this.mcpToolName} failed: ${error instanceof Error ? error.message : String(error)}`); + + if (error instanceof MCPError) { + throw error; + } + + throw new MCPError( + MCPErrorType.ToolExecutionFailed, + `Tool execution failed: ${error instanceof Error ? error.message : String(error)}`, + this.serverName, + this.mcpToolName, + error instanceof Error ? error : undefined + ); + } + } + + /** + * Convert MCP schema to MiniAgent ToolDeclaration + */ + private convertMCPSchemaToToolDeclaration(mcpTool: MCPToolDefinition): ToolDeclaration { + // Convert MCP JSON schema to MiniAgent tool declaration format + // This is a simplified conversion - more sophisticated mapping may be needed + return { + name: this.name, + description: mcpTool.description, + parameters: mcpTool.inputSchema, + }; + } + + /** + * Validate a single parameter value + */ + private validateParameterValue( + paramName: string, + value: unknown, + schema: any + ): string | null { + if (value === null || value === undefined) { + return `Parameter ${paramName} cannot be null or undefined`; + } + + // Basic type checking based on JSON schema + if (schema.type) { + switch (schema.type) { + case 'string': + if (typeof value !== 'string') { + return `Parameter ${paramName} must be a string`; + } + break; + case 'number': + case 'integer': + if (typeof value !== 'number') { + return `Parameter ${paramName} must be a number`; + } + if (schema.type === 'integer' && !Number.isInteger(value)) { + return `Parameter ${paramName} must be an integer`; + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + return `Parameter ${paramName} must be a boolean`; + } + break; + case 'array': + if (!Array.isArray(value)) { + return `Parameter ${paramName} must be an array`; + } + break; + case 'object': + if (typeof value !== 'object' || Array.isArray(value)) { + return `Parameter ${paramName} must be an object`; + } + break; + } + } + + // Additional validation rules can be added here + // e.g., min/max values, string patterns, etc. + + return null; // Valid + } + + /** + * Check if operation is potentially destructive + */ + private isDestructiveOperation(params: Record): boolean { + const toolName = this.mcpToolName.toLowerCase(); + + // List of operations that typically require confirmation + const destructiveKeywords = [ + 'delete', 'remove', 'drop', 'destroy', 'kill', 'terminate', + 'clear', 'reset', 'format', 'wipe', 'purge', 'erase', + 'modify', 'update', 'edit', 'change', 'write', 'create', + 'move', 'rename', 'copy', 'clone', 'install', 'uninstall' + ]; + + // Check tool name for destructive keywords + const hasDestructiveKeyword = destructiveKeywords.some(keyword => + toolName.includes(keyword) + ); + + // Check parameters for destructive patterns + const paramString = JSON.stringify(params).toLowerCase(); + const hasDestructiveParams = destructiveKeywords.some(keyword => + paramString.includes(keyword) + ); + + return hasDestructiveKeyword || hasDestructiveParams; + } + + /** + * Extract error message from MCP response + */ + private extractErrorMessage(response: MCPToolResponse): string { + if (response.content && response.content.length > 0) { + const errorContent = response.content.find(c => c.type === 'text' && c.text); + if (errorContent && errorContent.text) { + return errorContent.text; + } + } + + return `Tool execution failed on server ${this.serverName}`; + } + + /** + * Convert MCP response to MiniAgent ToolResult + */ + private convertMCPResponseToToolResult(response: MCPToolResponse): ToolResult { + if (!response.content || response.content.length === 0) { + return { + result: `Tool ${this.mcpToolName} executed successfully (no output)` + }; + } + + // Combine all text content + const textContents = response.content + .filter(c => c.type === 'text' && c.text) + .map(c => c.text!) + .join('\n'); + + // Handle image content (basic support) + const imageContents = response.content + .filter(c => c.type === 'image') + .map(c => `[Image: ${c.mimeType || 'unknown'}]`) + .join('\n'); + + // Handle resource content + const resourceContents = response.content + .filter(c => c.type === 'resource') + .map(c => `[Resource: ${c.mimeType || 'unknown'}]`) + .join('\n'); + + // Combine all content + const allContent = [textContents, imageContents, resourceContents] + .filter(content => content.length > 0) + .join('\n\n'); + + return { + result: allContent || `Tool ${this.mcpToolName} executed successfully` + }; + } +} \ No newline at end of file diff --git a/src/test/mcp/mcpConfig.test.ts b/src/test/mcp/mcpConfig.test.ts new file mode 100644 index 0000000..961d6a0 --- /dev/null +++ b/src/test/mcp/mcpConfig.test.ts @@ -0,0 +1,289 @@ +/** + * @fileoverview MCP Configuration Tests + * + * Tests for MCP configuration loading, validation, and helper functions. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, unlinkSync, existsSync } from 'fs'; +import { resolve } from 'path'; +import { + MCPConfigLoader, + MCPConfigHelpers, + DEFAULT_MCP_CONFIG, + MCPConfig, + MCPServerConfig, + MCPError, + MCPErrorType, +} from '../../mcp/index.js'; +import { LogLevel } from '../../logger.js'; + +describe('MCPConfigLoader', () => { + const testConfigPath = resolve('./test-mcp-config.json'); + + afterEach(() => { + // Clean up test files + if (existsSync(testConfigPath)) { + unlinkSync(testConfigPath); + } + + // Clean up environment variables + delete process.env.MCP_TIMEOUT; + delete process.env.MCP_RETRY_ATTEMPTS; + delete process.env.MCP_LOG_LEVEL; + delete process.env.MCP_AUTO_RESTART; + delete process.env.MCP_SERVER_TEST_COMMAND; + delete process.env.MCP_SERVER_TEST_ARGS; + }); + + describe('loadFromFile', () => { + it('should load valid configuration from file', () => { + const config: MCPConfig = { + servers: [ + { + name: 'test-server', + command: 'echo', + args: ['hello'], + }, + ], + timeout: 5000, + retryAttempts: 2, + }; + + writeFileSync(testConfigPath, JSON.stringify(config, null, 2)); + + const loaded = MCPConfigLoader.loadFromFile(testConfigPath); + expect(loaded.servers).toHaveLength(1); + expect(loaded.servers[0].name).toBe('test-server'); + expect(loaded.timeout).toBe(5000); + expect(loaded.retryAttempts).toBe(2); + }); + + it('should throw error for non-existent file', () => { + expect(() => { + MCPConfigLoader.loadFromFile('./non-existent-config.json'); + }).toThrow(MCPError); + }); + + it('should throw error for invalid JSON', () => { + writeFileSync(testConfigPath, 'invalid json'); + + expect(() => { + MCPConfigLoader.loadFromFile(testConfigPath); + }).toThrow(MCPError); + }); + }); + + describe('loadFromEnv', () => { + it('should load configuration from environment variables', () => { + process.env.MCP_TIMEOUT = '15000'; + process.env.MCP_RETRY_ATTEMPTS = '5'; + process.env.MCP_LOG_LEVEL = 'DEBUG'; + process.env.MCP_AUTO_RESTART = 'true'; + process.env.MCP_SERVER_TEST_COMMAND = 'npm'; + process.env.MCP_SERVER_TEST_ARGS = 'start,--verbose'; + + const config = MCPConfigLoader.loadFromEnv(); + + expect(config.timeout).toBe(15000); + expect(config.retryAttempts).toBe(5); + expect(config.logLevel).toBe(LogLevel.DEBUG); + expect(config.autoRestart).toBe(true); + expect(config.servers).toHaveLength(1); + expect(config.servers![0].name).toBe('test'); + expect(config.servers![0].command).toBe('npm'); + expect(config.servers![0].args).toEqual(['start', '--verbose']); + }); + + it('should return empty config when no env vars set', () => { + const config = MCPConfigLoader.loadFromEnv(); + expect(config).toEqual({}); + }); + }); + + describe('createWithDefaults', () => { + it('should create config with defaults', () => { + const config = MCPConfigLoader.createWithDefaults(); + expect(config.timeout).toBe(DEFAULT_MCP_CONFIG.timeout); + expect(config.retryAttempts).toBe(DEFAULT_MCP_CONFIG.retryAttempts); + expect(config.servers).toEqual([]); + }); + + it('should merge partial config with defaults', () => { + const partial = { + servers: [{ name: 'test', command: 'echo' }], + timeout: 5000, + }; + + const config = MCPConfigLoader.createWithDefaults(partial); + expect(config.timeout).toBe(5000); + expect(config.retryAttempts).toBe(DEFAULT_MCP_CONFIG.retryAttempts); + expect(config.servers).toHaveLength(1); + }); + }); + + describe('validate', () => { + it('should validate valid configuration', () => { + const config: MCPConfig = { + servers: [ + { + name: 'valid-server', + command: 'echo', + args: ['test'], + }, + ], + timeout: 30000, + retryAttempts: 3, + }; + + const result = MCPConfigLoader.validate(config); + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should detect invalid servers array', () => { + const config = { + servers: 'not-an-array', + } as any; + + const result = MCPConfigLoader.validate(config); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('servers must be an array'); + }); + + it('should detect duplicate server names', () => { + const config: MCPConfig = { + servers: [ + { name: 'duplicate', command: 'echo' }, + { name: 'duplicate', command: 'echo' }, + ], + }; + + const result = MCPConfigLoader.validate(config); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Duplicate server name: duplicate'); + }); + + it('should detect invalid timeout', () => { + const config: MCPConfig = { + servers: [], + timeout: -1000, + }; + + const result = MCPConfigLoader.validate(config); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('timeout must be a positive number'); + }); + + it('should generate warnings for empty servers', () => { + const config: MCPConfig = { + servers: [], + }; + + const result = MCPConfigLoader.validate(config); + expect(result.isValid).toBe(true); + expect(result.warnings).toContain('No servers configured'); + }); + }); +}); + +describe('MCPConfigHelpers', () => { + describe('createFilesystemServer', () => { + it('should create filesystem server configuration', () => { + const config = MCPConfigHelpers.createFilesystemServer('fs', '/path/to/dir'); + + expect(config.name).toBe('fs'); + expect(config.command).toBe('npx'); + expect(config.args).toEqual(['@modelcontextprotocol/server-filesystem', '/path/to/dir']); + expect(config.disabled).toBe(false); + }); + + it('should create disabled filesystem server', () => { + const config = MCPConfigHelpers.createFilesystemServer('fs', '/path', true); + expect(config.disabled).toBe(true); + }); + }); + + describe('createGitServer', () => { + it('should create git server configuration', () => { + const config = MCPConfigHelpers.createGitServer('git', '/repo/path'); + + expect(config.name).toBe('git'); + expect(config.command).toBe('mcp-server-git'); + expect(config.args).toEqual(['/repo/path']); + expect(config.cwd).toBe('/repo/path'); + }); + + it('should create git server without repo path', () => { + const config = MCPConfigHelpers.createGitServer(); + + expect(config.name).toBe('git'); + expect(config.args).toEqual([]); + expect(config.cwd).toBeUndefined(); + }); + }); + + describe('createWebSearchServer', () => { + it('should create web search server configuration', () => { + const config = MCPConfigHelpers.createWebSearchServer('search', 'api-key-123'); + + expect(config.name).toBe('search'); + expect(config.command).toBe('mcp-server-web-search'); + expect(config.env).toEqual({ SEARCH_API_KEY: 'api-key-123' }); + }); + + it('should create web search server without API key', () => { + const config = MCPConfigHelpers.createWebSearchServer(); + + expect(config.env).toBeUndefined(); + }); + }); + + describe('createDatabaseServer', () => { + it('should create database server configuration', () => { + const connectionString = 'postgresql://user:pass@localhost/db'; + const config = MCPConfigHelpers.createDatabaseServer('db', connectionString); + + expect(config.name).toBe('db'); + expect(config.command).toBe('mcp-server-database'); + expect(config.args).toEqual([connectionString]); + }); + }); + + describe('mergeConfigs', () => { + it('should merge multiple configurations', () => { + const config1: Partial = { + timeout: 5000, + servers: [{ name: 'server1', command: 'echo' }], + }; + + const config2: Partial = { + retryAttempts: 5, + servers: [{ name: 'server2', command: 'cat' }], + }; + + const merged = MCPConfigHelpers.mergeConfigs(config1, config2); + + expect(merged.timeout).toBe(5000); + expect(merged.retryAttempts).toBe(5); + expect(merged.servers).toHaveLength(2); + expect(merged.servers[0].name).toBe('server1'); + expect(merged.servers[1].name).toBe('server2'); + }); + + it('should handle duplicate server names', () => { + const config1: Partial = { + servers: [{ name: 'server1', command: 'echo' }], + }; + + const config2: Partial = { + servers: [{ name: 'server1', command: 'cat' }], // Same name, different command + }; + + const merged = MCPConfigHelpers.mergeConfigs(config1, config2); + + expect(merged.servers).toHaveLength(1); + expect(merged.servers[0].command).toBe('cat'); // Later config wins + }); + }); +}); \ No newline at end of file