From de4d17d0b60162f500ac6ddd5d899ddba0f77e8f Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 18 Jun 2025 17:33:53 +0530 Subject: [PATCH] feat: add support for multiple MCP client initialisation --- config/initializers/mcp_client.rb | 38 --- config/initializers/mcp_setup.rb | 31 ++ docker/Dockerfile | 1 + lib/mcp/base_client.rb | 266 ++++++++++++++++ lib/mcp/clients/acme_mintlify_client.rb | 224 +++++++++++++ lib/mcp/clients/mintlify_client.rb | 229 ++++++++++++++ lib/mcp_client_service.rb | 402 +++++++++--------------- 7 files changed, 896 insertions(+), 295 deletions(-) delete mode 100644 config/initializers/mcp_client.rb create mode 100644 config/initializers/mcp_setup.rb create mode 100644 lib/mcp/base_client.rb create mode 100644 lib/mcp/clients/acme_mintlify_client.rb create mode 100644 lib/mcp/clients/mintlify_client.rb diff --git a/config/initializers/mcp_client.rb b/config/initializers/mcp_client.rb deleted file mode 100644 index 86b1ff602..000000000 --- a/config/initializers/mcp_client.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# MCP Client Initializer -# This initializer sets up the Model Context Protocol client service -# for connecting to external MCP servers like Mintlify documentation. - -# Initialize the MCP client service -Rails.application.config.after_initialize do - # Initialize the MCP client service singleton - McpClientService.instance - Rails.logger.info('[MCP] MCP Client Service initialized successfully') - -rescue StandardError => e - Rails.logger.error("[MCP] Failed to initialize MCP service: #{e.message}") - - # Don't crash the application, just log the error - Rails.logger.warn('[MCP] MCP integration disabled due to initialization error') -end - -# Simple health check method for console debugging -if Rails.env.development? - Rails.application.config.after_initialize do - def mcp_health_check - service = McpClientService.instance - stats = service.stats - - Rails.logger.debug 'MCP Service Health Check' - Rails.logger.debug '=======================' - Rails.logger.debug { "Clients: #{stats[:clients].join(', ')}" } - Rails.logger.debug 'Connection Stats:' - stats[:connection_stats].each do |server, stat| - Rails.logger.debug " #{server}: #{stat[:total_calls]} calls, #{stat[:successful_calls]} successful" - end - rescue StandardError => e - Rails.logger.debug { "Health check failed: #{e.message}" } - end - end -end diff --git a/config/initializers/mcp_setup.rb b/config/initializers/mcp_setup.rb new file mode 100644 index 000000000..c89ff729f --- /dev/null +++ b/config/initializers/mcp_setup.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# MCP Server Auto-Setup Initializer +# Automatically sets up MCP servers when the application starts + +Rails.application.config.after_initialize do + # Only setup in development, production, or when explicitly enabled + if Rails.env.development? || Rails.env.production? || ENV['MCP_AUTO_SETUP'] == 'true' + begin + # Use a background job or thread to avoid blocking application startup + Thread.new do + require Rails.root.join('lib/mcp_client_service') + service = McpClientService.instance + service.initialize_default_servers! + + Rails.logger.info '[MCP Setup] ✅ MCP initialization thread completed' + end + + Rails.logger.info '[MCP Setup] ✅ MCP server initialization scheduled successfully' + + rescue StandardError => e + Rails.logger.error "[MCP Setup] ❌ Failed to schedule MCP setup: #{e.message}" + Rails.logger.error "[MCP Setup] Backtrace: #{e.backtrace.first(3).join(', ')}" + # Don't fail application startup if MCP setup fails + end + else + Rails.logger.info "[MCP Setup] ⏭️ Skipping MCP setup (environment: #{Rails.env})" + end +end + +# MCP initialization is now handled by the McpClientService \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 2d753337d..c2b13e36d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -34,6 +34,7 @@ RUN apk update && apk add --no-cache \ git \ curl \ xz \ + expect \ && mkdir -p /var/app \ && gem install bundler diff --git a/lib/mcp/base_client.rb b/lib/mcp/base_client.rb new file mode 100644 index 000000000..9fe588585 --- /dev/null +++ b/lib/mcp/base_client.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +require 'mcp_client' + +module Mcp + # Base class for all MCP clients + # Provides common functionality and interface for MCP connections + class BaseClient + class ConnectionError < StandardError; end + class ConfigurationError < StandardError; end + class ToolCallError < StandardError; end + + attr_reader :name, :config, :client, :connected + + def initialize(name, config = {}) + @name = name + @config = default_config.merge(config) + @client = nil + @connected = false + @wrapper_script = nil + + post_initialize_setup + end + + # Connect to the MCP server + def connect! + Rails.logger.info("[Mcp::#{self.class.name}] Starting connection to #{@name}...") + connection_start = Time.current + + validate_config! + @client = create_client + @connected = true + + duration = Time.current - connection_start + Rails.logger.info("[Mcp::#{self.class.name}] Connected successfully in #{duration.round(3)}s") + + if @config[:test_on_connect] + Rails.logger.debug { "[Mcp::#{self.class.name}] Testing connection..." } + test_connection + end + + rescue StandardError => e + @connected = false + Rails.logger.error("[Mcp::#{self.class.name}] Connection failed: #{e.message}") + Rails.logger.debug { "[Mcp::#{self.class.name}] Error details: #{e.backtrace.first(2).join(', ')}" } + raise ConnectionError, "Failed to connect: #{e.message}" + end + + # Disconnect from the MCP server + def disconnect! + @client = nil + @connected = false + + # Clean up wrapper script if it exists + cleanup_wrapper_script + + Rails.logger.debug { "[Mcp::#{self.class.name}] Disconnected" } + end + + # Check if connected + def connected? + @connected && @client.present? + end + + # Call a tool on the MCP server + def call_tool(tool_name, arguments = {}) + ensure_connected! + + Rails.logger.debug { "[Mcp::#{self.class.name}] Calling tool: #{tool_name}" } + result = @client.call_tool(tool_name, arguments) + Rails.logger.debug { "[Mcp::#{self.class.name}] Tool call successful" } + + result + rescue StandardError => e + Rails.logger.error("[Mcp::#{self.class.name}] Tool call failed: #{e.message}") + raise ToolCallError, "Failed to call tool #{tool_name}: #{e.message}" + end + + # List available tools + def list_tools + ensure_connected! + + tools = @client.list_tools || [] + Rails.logger.debug { "[Mcp::#{self.class.name}] Found #{tools.length} tools" } + + tools + rescue StandardError => e + Rails.logger.error("[Mcp::#{self.class.name}] Failed to list tools: #{e.message}") + [] + end + + # Get client statistics + def stats + { + name: @name, + connected: connected?, + config: sanitized_config, + tools_count: connected? ? list_tools.length : 0 + } + end + + # Get authentication configuration (for debugging) + def auth_info + return { enabled: false } unless @config[:auth][:enabled] + + { + enabled: true, + configured_fields: @config[:auth][:config].keys, + env_mapping: @config[:auth][:env_mapping] + } + end + + protected + + # Post-initialization setup - override in subclasses if needed + def post_initialize_setup + # Override in subclasses for additional setup after config is set + end + + # Default configuration - override in subclasses + def default_config + { + transport: 'stdio', + test_on_connect: false, + auto_setup: false, + auth: { + enabled: false, + config: {}, + env_mapping: {} + }, + environment: {} + } + end + + # Validate configuration - override in subclasses + def validate_config! + Rails.logger.debug { "[Mcp::#{self.class.name}] Validating configuration with command: #{@config[:command]}" } + raise ConfigurationError, 'Command is required' if @config[:command].blank? + end + + # Create the actual MCP client - override in subclasses + def create_client + Rails.logger.debug { "[Mcp::#{self.class.name}] Creating client with config: #{@config.inspect}" } + case @config[:transport] + when 'stdio' + create_stdio_client + else + raise ConfigurationError, "Unsupported transport: #{@config[:transport]}" + end + end + + # Create stdio client + def create_stdio_client + command = @config[:command] + raise ConfigurationError, 'Command required for stdio transport' if command.blank? + + Rails.logger.debug { "[Mcp::#{self.class.name}] Creating stdio client with command: #{command.join(' ')}" } + + # Prepare environment variables if specified + env_vars = @config[:environment] || {} + final_command = command + + if env_vars.any? + Rails.logger.debug { "[Mcp::#{self.class.name}] Setting environment variables: #{env_vars.keys.join(', ')}" } + Rails.logger.debug do + "[Mcp::#{self.class.name}] Environment values: #{env_vars.transform_values do |v| + v.to_s.length > 10 ? "#{v.to_s[0..8]}..." : v.to_s + end}" + end + + # Create a wrapper script to ensure environment variables are passed correctly + wrapper_script = create_env_wrapper_script(command, env_vars) + if wrapper_script + final_command = ['sh', wrapper_script] + Rails.logger.debug { "[Mcp::#{self.class.name}] Using wrapper script: #{wrapper_script}" } + else + # Fallback: Set in current process + env_vars.each do |key, value| + if value + ENV[key.to_s] = value.to_s + Rails.logger.debug { "[Mcp::#{self.class.name}] Set ENV['#{key}'] = '#{value.to_s[0..8]}...#{value.to_s[-3..]}'" } + end + end + end + else + Rails.logger.debug { "[Mcp::#{self.class.name}] No environment variables to set" } + end + + Rails.logger.debug { "[Mcp::#{self.class.name}] Initializing MCP client..." } + + MCPClient.create_client( + mcp_server_configs: [ + MCPClient.stdio_config( + command: final_command, + name: @name + ) + ] + ) + end + + # Create a temporary wrapper script to ensure environment variables are passed + def create_env_wrapper_script(command, env_vars) + return nil unless env_vars.any? + + begin + script_content = "#!/bin/bash\n" + env_vars.each { |key, value| script_content += "export #{key}='#{value}'\n" } + script_content += "exec #{command.join(' ')}\n" + + script_path = "/tmp/mcp_wrapper_#{@name}_#{Process.pid}.sh" + File.write(script_path, script_content) + File.chmod(0o755, script_path) + + @wrapper_script = script_path + Rails.logger.debug { "[Mcp::#{self.class.name}] Created wrapper script: #{script_path}" } + script_path + rescue StandardError => e + Rails.logger.warn("[Mcp::#{self.class.name}] Failed to create wrapper script: #{e.message}") + nil + end + end + + # Clean up wrapper script + def cleanup_wrapper_script + return unless @wrapper_script && File.exist?(@wrapper_script) + + begin + File.delete(@wrapper_script) + Rails.logger.debug { "[Mcp::#{self.class.name}] Cleaned up wrapper script: #{@wrapper_script}" } + rescue StandardError => e + Rails.logger.warn("[Mcp::#{self.class.name}] Failed to cleanup wrapper script: #{e.message}") + ensure + @wrapper_script = nil + end + end + + # Test connection after connecting + def test_connection + tools = list_tools + Rails.logger.debug { "[Mcp::#{self.class.name}] Connection test successful. Found #{tools.length} tools" } + rescue StandardError => e + Rails.logger.warn("[Mcp::#{self.class.name}] Connection test failed: #{e.message}") + end + + private + + def ensure_connected! + return if connected? + + raise ConnectionError, "Not connected to #{@name}. Call connect! first." + end + + # Sanitize configuration for logging (remove sensitive data) + def sanitized_config + config = @config.dup + + # Remove sensitive auth data + if config[:auth] && config[:auth][:config] + config[:auth] = config[:auth].dup + config[:auth][:config] = config[:auth][:config].transform_values { |_| '[REDACTED]' } + end + + config + end + end +end \ No newline at end of file diff --git a/lib/mcp/clients/acme_mintlify_client.rb b/lib/mcp/clients/acme_mintlify_client.rb new file mode 100644 index 000000000..41efa1d25 --- /dev/null +++ b/lib/mcp/clients/acme_mintlify_client.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require_relative '../base_client' + +module Mcp + module Clients + # Acme Mintlify MCP Client + # Handles connection to specific Acme Mintlify server (acme-d0cb791b) + # Requires api_access_token for authentication + class AcmeMintlifyClient < BaseClient + DEFAULT_SERVER_ID = 'acme-d0cb791b' + + def initialize(config = {}) + super('acme_mintlify', build_config(config)) + end + + def self.connect_with_setup(config = {}) + Rails.logger.info('[Mcp::Clients::AcmeMintlifyClient] Connecting to Acme Mintlify MCP server...') + client = new(config) + client.setup_if_needed! if client.config[:auto_setup] + client.connect! + client + end + + def self.connect + connect_with_setup({ auto_setup: true }) + end + + # Setup Acme Mintlify MCP server if needed + def setup_if_needed! + return if server_installed? + + Rails.logger.info('[Mcp::Clients::AcmeMintlifyClient] Setting up Acme Mintlify MCP server...') + + api_token = @config[:auth][:config]['api_access_token'] + + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] API token: #{api_token}" } + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Config: #{@config.inspect}" } + + raise ConfigurationError, 'api_access_token is required for automatic setup' if api_token.blank? + + install_server_with_token(api_token) + + raise ConfigurationError, 'Failed to setup Acme Mintlify MCP server automatically' unless server_installed? + + Rails.logger.info('[Mcp::Clients::AcmeMintlifyClient] Setup completed successfully') + end + + # Check if server is properly installed + def server_installed? + !server_path.nil? && File.exist?(server_path) + end + + # Get available capabilities + def capabilities + %w[documentation_search api_access content_retrieval] + end + + protected + + def default_config + super.merge({ + server_id: DEFAULT_SERVER_ID, + command: nil, # Will be built after config is set + auto_setup: true, + test_on_connect: true, + auth: { + enabled: false, # No runtime auth needed - API key only used during mcp add + config: {}, + env_mapping: {} + }, + environment: {} + }) + end + + def post_initialize_setup + # Find the server and build command + actual_server_path = find_server_path + if actual_server_path + @config[:command] = ['node', actual_server_path] + @server_path = actual_server_path + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Using server at: #{actual_server_path}" } + else + @config[:command] = build_fallback_command + end + + super + end + + def validate_config! + # Check if the exact server is installed + unless server_installed? + if @config[:auto_setup] + # For auto-setup, API token can be provided in config for installation + api_token = @config.dig(:auth, :config, 'api_access_token') + if api_token.blank? + raise ConfigurationError, + 'api_access_token is required for automatic setup of Acme server. ' \ + 'Use AcmeMintlifyClient.connect_with_setup for installation' + end + setup_if_needed! + else + raise ConfigurationError, + "Acme Mintlify MCP server '#{@config[:server_id]}' not found. " \ + 'Enable auto_setup for automatic installation' + end + end + + super + end + + private + + def build_config(user_config) + # Build auth config first + auth_config = build_auth_config(user_config[:auth] || {}) + + base_config = { + server_id: user_config[:server_id] || DEFAULT_SERVER_ID, + auto_setup: user_config.fetch(:auto_setup, true), + test_on_connect: user_config.fetch(:test_on_connect, true), + auth: auth_config, + environment: build_environment_config(user_config[:environment] || {}, auth_config) + } + base_config.merge(user_config.except(:auth, :environment)) + end + + def build_auth_config(user_auth) + # Auth config only used for installation (mcp add step) + # Make sure to preserve any auth config passed in + auth_config = {} + + # Copy auth config from user input + auth_config = user_auth[:config].dup if user_auth && user_auth[:config] + + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Building auth config from: #{user_auth.inspect}" } + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Extracted auth_config: #{auth_config.inspect}" } + + result = { + enabled: false, # No runtime auth needed + config: auth_config, # Store for installation if needed + env_mapping: {} + } + + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Final auth config: #{result.inspect}" } + result + end + + def build_environment_config(user_env, _auth_config = nil) + # No runtime environment variables needed - server is configured during mcp add + user_env + end + + def server_path + @server_path ||= find_server_path + end + + def find_server_path + home_dir = Dir.home || '/root' + mcp_dir = "#{home_dir}/.mcp" + + return nil unless Dir.exist?(mcp_dir) + + # Only use the exact server ID specified + expected_path = "#{mcp_dir}/#{@config[:server_id]}/src/index.js" + + if File.exist?(expected_path) + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Found exact server at: #{expected_path}" } + return expected_path + else + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Server #{@config[:server_id]} not found at #{expected_path}" } + return nil + end + end + + def build_fallback_command + ['node', "#{Dir.home || '/root'}/.mcp/#{@config[:server_id]}/src/index.js"] + end + + def install_mcp_cli + Rails.logger.debug('[Mcp::Clients::AcmeMintlifyClient] Installing MCP CLI...') + system('npm install -g @mintlify/mcp 2>/dev/null') + end + + def add_mcp_server + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Adding server #{@config[:server_id]}..." } + + api_token = @config[:auth][:config]['api_access_token'] + if api_token.blank? + Rails.logger.error('[Mcp::Clients::AcmeMintlifyClient] No API token provided for server installation') + return false + end + + # Use expect script to handle interactive installation + install_with_expect(api_token) + end + + def install_server_with_token(_api_token) + Rails.logger.debug('[Mcp::Clients::AcmeMintlifyClient] Installing Acme Mintlify MCP server...') + + install_mcp_cli && add_mcp_server + end + + def install_with_expect(api_token) + Rails.logger.debug('[Mcp::Clients::AcmeMintlifyClient] Installing Acme Mintlify MCP server with expect...') + # Use expect to handle the interactive prompt + expect_script = <<~SCRIPT + #!/usr/bin/expect -f + spawn mcp add #{@config[:server_id]} + expect "What is the API Key for \\"Chatwoot\\"?" + send "#{api_token}\\r" + expect eof + SCRIPT + script_path = "/tmp/mcp_install_#{@config[:server_id]}.exp" + Rails.logger.debug { "[Mcp::Clients::AcmeMintlifyClient] Script path: #{script_path}" } + File.write(script_path, expect_script) + File.chmod(0o755, script_path) + + system(script_path) + # File.delete(script_path) if File.exist?(script_path) + end + end + end +end \ No newline at end of file diff --git a/lib/mcp/clients/mintlify_client.rb b/lib/mcp/clients/mintlify_client.rb new file mode 100644 index 000000000..8702a724d --- /dev/null +++ b/lib/mcp/clients/mintlify_client.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require_relative '../base_client' + +module Mcp + module Clients + # Mintlify MCP Client + # Handles connection to Mintlify documentation server + class MintlifyClient < BaseClient + DEFAULT_SERVER_ID = 'chatwoot-447c5a93' + + def initialize(config = {}) + super('mintlify', build_config(config)) + end + + # Quick connect method with auto-setup + def self.connect_with_setup(config = {}) + client = new(config) + client.setup_if_needed! if client.config[:auto_setup] + client.connect! + client + end + + # Setup Mintlify MCP server if needed + def setup_if_needed! + return if server_installed? + + Rails.logger.info('[Mcp::Clients::MintlifyClient] Setting up Mintlify MCP server...') + + install_mcp_cli && add_mcp_server + + raise ConfigurationError, 'Failed to setup Mintlify MCP server automatically' unless server_installed? + + Rails.logger.info('[Mcp::Clients::MintlifyClient] Setup completed successfully') + end + + # Check if server is properly installed + def server_installed? + !server_path.nil? && File.exist?(server_path) + end + + # Get available capabilities + def capabilities + %w[documentation_search code_examples api_reference] + end + + protected + + def default_config + super.merge({ + server_id: DEFAULT_SERVER_ID, + command: nil, # Will be built after config is set + auto_setup: true, + test_on_connect: true, + auth: { + enabled: false, + config: {}, + env_mapping: {} + }, + environment: {} + }) + end + + def post_initialize_setup + # Find the actual server path and update config + actual_server_path = find_server_path + if actual_server_path + @config[:command] = ['node', actual_server_path] + @server_path = actual_server_path + + # Extract the actual server ID from the path for logging + actual_server_id = File.dirname(actual_server_path).split('/').last + Rails.logger.debug { "[Mcp::Clients::MintlifyClient] Using server at: #{actual_server_path} (ID: #{actual_server_id})" } + else + @config[:command] = build_command + end + super + end + + def validate_config! + # Check if the exact server is installed + unless server_installed? + if @config[:auto_setup] + setup_if_needed! + else + available_servers = list_available_servers + error_msg = "Mintlify MCP server '#{@config[:server_id]}' not found.\n" + + if available_servers.any? + error_msg += "Available servers: #{available_servers.join(', ')}\n" + error_msg += "Please use one of these server IDs or install '#{@config[:server_id]}'" + else + error_msg += "No MCP servers found. Run 'rake mcp:setup' to install servers." + end + + raise ConfigurationError, error_msg + end + end + + super + end + + private + + def build_config(user_config) + # Build auth config first + auth_config = build_auth_config(user_config[:auth] || {}) + + base_config = { + server_id: user_config[:server_id] || DEFAULT_SERVER_ID, + auto_setup: user_config.fetch(:auto_setup, true), + test_on_connect: user_config.fetch(:test_on_connect, true), + auth: auth_config, + environment: build_environment_config(user_config[:environment] || {}, auth_config) + } + base_config.merge(user_config.except(:auth, :environment)) + end + + def build_command + ['node', server_path] + end + + def server_path + @server_path ||= find_server_path + end + + def find_server_path + home_dir = Dir.home || '/root' + mcp_dir = "#{home_dir}/.mcp" + + return nil unless Dir.exist?(mcp_dir) + + # Only use the exact server ID specified - no fallback + expected_path = "#{mcp_dir}/#{@config[:server_id]}/src/index.js" + + if File.exist?(expected_path) + Rails.logger.debug { "[Mcp::Clients::MintlifyClient] Found exact server at: #{expected_path}" } + return expected_path + else + Rails.logger.debug { "[Mcp::Clients::MintlifyClient] Server #{@config[:server_id]} not found at #{expected_path}" } + return nil + end + end + + def install_mcp_cli + Rails.logger.debug('[Mcp::Clients::MintlifyClient] Installing MCP CLI...') + system('npm install -g @mintlify/mcp 2>/dev/null') + end + + def add_mcp_server + Rails.logger.debug { "[Mcp::Clients::MintlifyClient] Adding server #{@config[:server_id]}..." } + + # Run mcp add command for the server + success = system("mcp add #{@config[:server_id]} 2>/dev/null") + + if success + Rails.logger.debug { "[Mcp::Clients::MintlifyClient] Successfully added server #{@config[:server_id]}" } + else + Rails.logger.warn("[Mcp::Clients::MintlifyClient] Failed to add server #{@config[:server_id]}") + end + + success + end + + def list_available_servers + home_dir = Dir.home || '/root' + mcp_dir = "#{home_dir}/.mcp" + + return [] unless Dir.exist?(mcp_dir) + + # Find all directories with src/index.js + available = [] + Dir.glob("#{mcp_dir}/*/src/index.js").each do |path| + # Skip node_modules directories + next if path.include?('/node_modules/') + + # Extract server directory name + path_parts = path.split('/') + mcp_index = path_parts.index('.mcp') + if mcp_index && mcp_index < path_parts.length - 1 + server_id = path_parts[mcp_index + 1] + available << server_id + end + end + + available + end + + def build_auth_config(user_auth) + # No default auth fields - completely configurable based on MCP server requirements + # Users define whatever auth fields their specific MCP server needs + + # Get environment mapping from user config (no defaults) + env_mapping = user_auth[:env_mapping] || {} + + # Build auth config from user config and environment variables + auth_config = user_auth[:config] || {} + + # Auto-populate from environment variables if mapping is provided + env_mapping.each do |config_key, env_var| + auth_config[config_key] = ENV[env_var] if auth_config[config_key].nil? && ENV[env_var] + end + + # Determine if auth is enabled + enabled = user_auth.fetch(:enabled, auth_config.any? { |_, value| value.present? }) + + { + enabled: enabled, + config: auth_config, + env_mapping: env_mapping + } + end + + def build_environment_config(user_env, auth_config = nil) + env_config = {} + + # Add authentication environment variables if configured + if auth_config && auth_config[:enabled] + auth_config[:env_mapping].each do |config_key, env_var| + value = auth_config[:config][config_key] + env_config[env_var] = value if value.present? + end + end + + env_config.merge(user_env) + end + end + end +end \ No newline at end of file diff --git a/lib/mcp_client_service.rb b/lib/mcp_client_service.rb index 558ff5a96..dd4e352e0 100644 --- a/lib/mcp_client_service.rb +++ b/lib/mcp_client_service.rb @@ -1,301 +1,189 @@ # frozen_string_literal: true -require 'mcp_client' +require 'singleton' +require 'timeout' +require_relative 'mcp/clients/mintlify_client' +require_relative 'mcp/clients/acme_mintlify_client' -# MCP Client Service - manages connections to MCP servers -# This service provides a centralized way to connect to and interact with -# Model Context Protocol servers, including the Mintlify documentation server. +# MCP Client Service +# Main service that manages MCP client connections using a structured approach class McpClientService include Singleton - class ConfigurationError < StandardError; end - class ConnectionError < StandardError; end - class TimeoutError < StandardError; end + class ClientNotFoundError < StandardError; end - attr_reader :clients, :connection_stats + attr_reader :clients def initialize @clients = {} - @connection_stats = {} - @mutex = Mutex.new - setup_clients + # Don't auto-setup in initializer to avoid issues with Rails loading end - # Get or create a client for a specific server - def client_for(server_name) - @mutex.synchronize do - return @clients[server_name] if @clients[server_name] - - Rails.logger.info("[McpClientService] Creating new client for #{server_name}") - @clients[server_name] = create_client_for(server_name) - end - end - - # Execute a tool on a specific server with retry logic - def call_tool(server_name, tool_name, arguments = {}) - validate_call_tool_params(server_name, tool_name, arguments) - + # Initialize both servers with default configurations + def initialize_default_servers! + Rails.logger.info('[McpClientService] Initializing default MCP servers...') start_time = Time.current - attempt = 0 - max_retries = 3 begin - attempt += 1 - client = client_for(server_name) - raise ConnectionError, "No client available for #{server_name}" unless client + initialize_mintlify_server + initialize_acme_server - Rails.logger.debug { "[McpClientService] Calling tool #{tool_name} on #{server_name} (attempt #{attempt})" } + duration = Time.current - start_time + Rails.logger.info("[McpClientService] All default servers initialized successfully in #{duration.round(2)}s") - result = Timeout.timeout(60) do # 60 second timeout - client.call_tool(tool_name, arguments) - end - - record_successful_call(server_name, tool_name, Time.current - start_time) - Rails.logger.debug { "[McpClientService] Tool call successful in #{(Time.current - start_time).round(2)}s" } - result + log_connection_status rescue StandardError => e - record_failed_call(server_name, tool_name, e) - - if attempt < max_retries && should_retry?(e) - Rails.logger.warn("[McpClientService] Retrying tool call #{tool_name} on #{server_name} (attempt #{attempt}/#{max_retries})") - sleep(2 * attempt) # Simple backoff - retry - end - - Rails.logger.error("[McpClientService] Tool call failed after #{attempt} attempts: #{e.message}") - handle_tool_call_error(e, server_name, tool_name) + Rails.logger.error("[McpClientService] Failed to initialize some servers: #{e.message}") + Rails.logger.debug { "[McpClientService] Backtrace: #{e.backtrace.first(3).join(', ')}" } end end - # List available tools for a server - def list_tools(server_name) - client = client_for(server_name) - return [] unless client + # Get or create a client for a specific type + def client_for(type, config = {}) + type = type.to_s.downcase - Rails.logger.debug { "[McpClientService] Listing tools for server: #{server_name}" } - tools = client.list_tools || [] + return @clients[type] if @clients[type]&.connected? - Rails.logger.debug { "[McpClientService] Found #{tools.length} tools for #{server_name}" } - tools + Rails.logger.debug { "[McpClientService] Creating client for #{type}" } + connection_start = Time.current - rescue StandardError => e - Rails.logger.error("[McpClientService] Error listing tools for #{server_name}: #{e.message}") - record_failed_call(server_name, 'list_tools', e) - [] - end + begin + # Create and connect the client with provided config + # Each client has its own default configuration + client = create_client(type, config) + client.connect! - # Get all available tools across all servers - def all_tools - tools = {} - @clients.each_key do |server_name| - tools[server_name] = list_tools(server_name) - end - tools - end + @clients[type] = client - # Reconnect all clients (useful for development/testing) - def reconnect_all! - @mutex.synchronize do - Rails.logger.info('[McpClientService] Reconnecting all MCP clients') - close_all_connections - @clients.clear - @connection_stats.clear - setup_clients + duration = Time.current - connection_start + Rails.logger.info("[McpClientService] #{type} client ready in #{duration.round(2)}s") + + client + rescue StandardError => e + Rails.logger.error("[McpClientService] Failed to create #{type} client: #{e.message}") + raise end end - # Get connection statistics - def stats - { - clients: @clients.keys, - connection_stats: @connection_stats.dup - } + # Call a tool on a specific client + def call_tool(type, tool_name, arguments = {}) + client = client_for(type) + client.call_tool(tool_name, arguments) + end + + # List available tools for a client + def list_tools(type) + client = client_for(type) + client.list_tools + end + + # Check if a client is connected + def connected?(type) + client = @clients[type.to_s.downcase] + client&.connected? || false + end + + # Get available client types + def available_types + %w[mintlify acme_mintlify] + end + + # Quick setup for a client type + def setup_client(type) + case type.to_s.downcase + when 'mintlify' + client = Mcp::Clients::MintlifyClient.new + client.setup_if_needed! + true + when 'acme_mintlify' + client = Mcp::Clients::AcmeMintlifyClient.new + client.setup_if_needed! + true + else + false + end end private - def setup_clients - # Set up Mintlify MCP server if enabled - setup_mintlify_client if mintlify_enabled? - end + def initialize_mintlify_server + connection_start = Time.current - def setup_mintlify_client - Rails.logger.info('[McpClientService] Setting up Mintlify MCP client') - - validate_mintlify_configuration! - - mcp_client = setup_mintlify_stdio_client - - raise ConnectionError, 'Failed to setup Mintlify client' unless mcp_client - - @clients['mintlify'] = mcp_client - initialize_connection_stats('mintlify') - Rails.logger.info('[McpClientService] Mintlify MCP client setup complete') - - # Test connection by listing tools (but don't fail if empty) begin - tools = mcp_client.list_tools - tool_names = extract_tool_names(tools) - Rails.logger.info("[McpClientService] Found #{tools.length} tools: #{tool_names.join(', ')}") - rescue StandardError => e - Rails.logger.warn("[McpClientService] Could not list tools (but client may still work): #{e.message}") - end + config = { + server_id: 'chatwoot-447c5a93', + auto_setup: true, + test_on_connect: true + } - rescue StandardError => e - Rails.logger.error("[McpClientService] Failed to setup Mintlify client: #{e.message}") - record_failed_call('mintlify', 'setup', e) - raise e unless Rails.env.development? + client = create_client('mintlify', config) + client.connect! + @clients['mintlify'] = client + duration = Time.current - connection_start + + Rails.logger.info("[McpClientService] Mintlify server connected successfully in #{duration.round(2)}s") + tools = client.list_tools + Rails.logger.info("[McpClientService] Mintlify server tools: #{tools.length} tools available") + + rescue StandardError => e + Rails.logger.warn("[McpClientService] Mintlify server initialization failed: #{e.message}") + Rails.logger.debug { "[McpClientService] Mintlify error backtrace: #{e.backtrace.first(3).join(', ')}" } + end end - def create_client_for(server_name) - case server_name + def initialize_acme_server + connection_start = Time.current + + begin + config = { + server_id: 'acme-d0cb791b', + auto_setup: true, + test_on_connect: true, + auth: { + config: { 'api_access_token' => get_acme_api_token } + } + } + + client = create_client('acme_mintlify', config) + client.connect! + @clients['acme_mintlify'] = client + duration = Time.current - connection_start + + Rails.logger.info("[McpClientService] Acme server connected successfully in #{duration.round(2)}s") + tools = client.list_tools + Rails.logger.info("[McpClientService] Acme server tools: #{tools.length} tools available") + + rescue StandardError => e + Rails.logger.warn("[McpClientService] Acme server initialization failed: #{e.message}") + Rails.logger.debug { "[McpClientService] Acme error backtrace: #{e.backtrace.first(3).join(', ')}" } + end + end + + def get_acme_api_token + # TODO: Update this to use the actual API token, or figure out a better way to get the token + ENV['ACME_MINTLIFY_API_TOKEN'] || 'demo_token' + end + + def log_connection_status + Rails.logger.info('[McpClientService] Connection Status Summary:') + + available_types.each do |type| + status = connected?(type) ? 'Connected' : 'Disconnected' + Rails.logger.info("[McpClientService] #{type}: #{status}") + end + + Rails.logger.info("[McpClientService] MCP setup complete - #{@clients.keys.size} clients ready") + end + + def create_client(type, config) + case type.to_s.downcase when 'mintlify' - setup_mintlify_client - @clients['mintlify'] + Mcp::Clients::MintlifyClient.new(config) + when 'acme_mintlify' + Mcp::Clients::AcmeMintlifyClient.new(config) else - raise ConfigurationError, "Unknown server: #{server_name}" - end - end - - def mintlify_enabled? - # Always enabled - no configuration needed - true - end - - def validate_mintlify_configuration! - home_dir = Dir.home || '/root' - mcp_dir = "#{home_dir}/.mcp/#{mcp_server_id}" - - return if File.exist?(mcp_dir) - - Rails.logger.warn('[McpClientService] MCP server directory not found, attempting setup...') - setup_mcp_server_if_needed - - return if File.exist?(mcp_dir) - - raise ConfigurationError, "MCP server not found at #{mcp_dir}. Run 'mcp add #{mcp_server_id}'" - end - - def mintlify_command_array - home_dir = Dir.home || '/root' - command = "node #{home_dir}/.mcp/#{mcp_server_id}/src/index.js" - command.split - end - - def setup_mcp_server_if_needed - home_dir = Dir.home || '/root' - mcp_dir = "#{home_dir}/.mcp/#{mcp_server_id}" - - return if File.exist?(mcp_dir) - - Rails.logger.info('[McpClientService] Setting up Mintlify MCP server for the first time...') - - # Install MCP CLI and add the server with proper error handling - install_success = system('npm install -g @mintlify/mcp 2>/dev/null') - add_success = system("mcp add #{mcp_server_id} 2>/dev/null") - - return if install_success && add_success - - Rails.logger.error('[McpClientService] Failed to install or add MCP server') - raise ConfigurationError, 'Failed to setup MCP server automatically' - end - - def setup_mintlify_stdio_client - command_array = mintlify_command_array - Rails.logger.info("[McpClientService] Creating stdio client with command: #{command_array.inspect}") - - Timeout.timeout(30) do # 30 second connection timeout - mcp_client = MCPClient.create_client( - mcp_server_configs: [ - MCPClient.stdio_config( - command: command_array, - name: 'mintlify' - ) - ] - ) - - Rails.logger.info('[McpClientService] Stdio transport successful') - mcp_client - end - rescue StandardError => e - Rails.logger.error("[McpClientService] Stdio transport failed: #{e.message}") - raise ConnectionError, "Failed to connect to Mintlify MCP server: #{e.message}" - end - - def should_retry?(error) - return false if error.is_a?(ConfigurationError) - return false if error.is_a?(ArgumentError) - - true - end - - def validate_call_tool_params(server_name, tool_name, arguments) - raise ArgumentError, 'server_name cannot be blank' if server_name.blank? - raise ArgumentError, 'tool_name cannot be blank' if tool_name.blank? - raise ArgumentError, 'arguments must be a Hash' unless arguments.is_a?(Hash) - end - - def extract_tool_names(tools) - tools.filter_map do |tool| - case tool - when Hash - tool['name'] - else - tool.respond_to?(:name) ? tool.name : tool.to_s - end - end - end - - def initialize_connection_stats(server_name) - @connection_stats[server_name] = { - total_calls: 0, - successful_calls: 0, - failed_calls: 0, - average_response_time: 0.0, - last_error: nil, - created_at: Time.current - } - end - - def record_successful_call(server_name, _tool_name, response_time) - stats = @connection_stats[server_name] ||= initialize_connection_stats(server_name) - stats[:total_calls] += 1 - stats[:successful_calls] += 1 - stats[:average_response_time] = ((stats[:average_response_time] * (stats[:total_calls] - 1)) + response_time) / stats[:total_calls] - end - - def record_failed_call(server_name, _tool_name, error) - stats = @connection_stats[server_name] ||= initialize_connection_stats(server_name) - stats[:total_calls] += 1 - stats[:failed_calls] += 1 - stats[:last_error] = error.message - end - - def handle_tool_call_error(error, server_name, tool_name) - case error - when Timeout::Error - "Tool call timed out: could not #{tool_name} on #{server_name}" - when ConnectionError - "Connection failed: could not #{tool_name} on #{server_name}" - when ConfigurationError - "Configuration error: could not #{tool_name} on #{server_name}" - else - "Error occurred: could not #{tool_name} on #{server_name} (#{error.message})" - end - end - - def close_all_connections - @clients.each_value do |client| - client.close if client.respond_to?(:close) - rescue StandardError => e - Rails.logger.warn("[McpClientService] Error closing client connection: #{e.message}") - end - end - - def mcp_server_id - ENV.fetch('MCP_SERVER_ID') do - raise ConfigurationError, 'MCP_SERVER_ID environment variable is required. Please set it to your MCP server identifier.' + raise ClientNotFoundError, "Unknown client type: #{type}" end end end