From cddd555ad0855c082b394b7c962b29b601279b27 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Tue, 17 Jun 2025 09:29:19 +0530 Subject: [PATCH] feat: implement mintlify search service --- Gemfile | 3 + Gemfile.lock | 6 +- config/initializers/mcp_client.rb | 38 +++ .../services/captain/copilot/chat_service.rb | 1 + .../captain/llm/assistant_chat_service.rb | 1 + .../captain/tools/mintlify_search_service.rb | 230 +++++++++++++ lib/mcp_client_service.rb | 301 ++++++++++++++++++ .../tools/mintlify_search_service_spec.rb | 285 +++++++++++++++++ spec/services/mcp_client_service_spec.rb | 207 ++++++++++++ 9 files changed, 1071 insertions(+), 1 deletion(-) create mode 100644 config/initializers/mcp_client.rb create mode 100644 enterprise/app/services/captain/tools/mintlify_search_service.rb create mode 100644 lib/mcp_client_service.rb create mode 100644 spec/services/captain/tools/mintlify_search_service_spec.rb create mode 100644 spec/services/mcp_client_service_spec.rb diff --git a/Gemfile b/Gemfile index b4752c745..a1460bf75 100644 --- a/Gemfile +++ b/Gemfile @@ -151,6 +151,9 @@ gem 'pg_search' # Subscriptions, Billing gem 'stripe' +# MCP Client for integrating with Model Context Protocol servers +gem 'ruby-mcp-client' + ## - helper gems --## ## to populate db with sample data gem 'faker' diff --git a/Gemfile.lock b/Gemfile.lock index d9908f5e1..29835e2a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -702,6 +702,9 @@ GEM rubocop-rspec (3.6.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) + ruby-mcp-client (0.6.2) + faraday (~> 2.0) + faraday-retry (~> 2.0) ruby-openai (7.3.1) event_stream_parser (>= 0.3.0, < 2.0.0) faraday (>= 1) @@ -983,6 +986,7 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec + ruby-mcp-client ruby-openai scout_apm scss_lint @@ -1020,4 +1024,4 @@ RUBY VERSION ruby 3.4.4p34 BUNDLED WITH - 2.5.16 + 2.6.7 diff --git a/config/initializers/mcp_client.rb b/config/initializers/mcp_client.rb new file mode 100644 index 000000000..86b1ff602 --- /dev/null +++ b/config/initializers/mcp_client.rb @@ -0,0 +1,38 @@ +# 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/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index f6e3a1105..e6d334a1e 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -61,6 +61,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService def register_tools @tool_registry = Captain::ToolRegistryService.new(@assistant, user: @user) + @tool_registry.register_tool(Captain::Tools::MintlifySearchService) @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) @tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService) @tool_registry.register_tool(Captain::Tools::Copilot::GetContactService) diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb index ca8fafaa0..e5a2d9b1f 100644 --- a/enterprise/app/services/captain/llm/assistant_chat_service.rb +++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb @@ -29,6 +29,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService def register_tools @tool_registry = Captain::ToolRegistryService.new(@assistant, user: nil) + @tool_registry.register_tool(Captain::Tools::MintlifySearchService) @tool_registry.register_tool(Captain::Tools::SearchDocumentationService) end diff --git a/enterprise/app/services/captain/tools/mintlify_search_service.rb b/enterprise/app/services/captain/tools/mintlify_search_service.rb new file mode 100644 index 000000000..d77c678fe --- /dev/null +++ b/enterprise/app/services/captain/tools/mintlify_search_service.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +class Captain::Tools::MintlifySearchService < Captain::Tools::BaseService + # A Copilot/Assistant tool that allows the LLM to run semantic searches over + # Chatwoot's public Mintlify documentation. Results are fetched via the + # ruby-mcp-client and streamed back as plain text. + + BASE_URL = 'https://developers.chatwoot.com' + MAX_ITERATIONS = 3 + + def name + 'mintlify_docs_search' + end + + def description + 'Search Chatwoot documentation hosted on Mintlify and return the most relevant answer with comprehensive details.' + end + + def parameters + { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The natural-language query to run against the documentation.' + } + }, + required: %w[query] + } + end + + # Executes the tool with iterative search capability. + # @param arguments [Hash] – expects a single key `query`. + # @return [String] – formatted block of Markdown text ready for the LLM. + def execute(arguments) + query = arguments['query'] + raise ArgumentError, 'query is required' if query.blank? + + Rails.logger.info("[MintlifySearchTool] Starting search for: #{query}") + + perform_search_with_error_handling(query) + end + + def active? + # Only activate if MCP client service is available and Mintlify is enabled + mcp_client_service.client_for('mintlify').present? + rescue StandardError => e + Rails.logger.debug { "[MintlifySearchTool] Service inactive: #{e.message}" } + false + end + + # Health check method for monitoring + def health_check + { + status: active? ? 'healthy' : 'unhealthy', + last_check: Time.current, + client_available: mcp_client_service.client_for('mintlify').present? + } + rescue StandardError => e + { + status: 'error', + error: e.message, + last_check: Time.current + } + end + + private + + def perform_search_with_error_handling(query) + start_time = Time.current + all_results = perform_iterative_search(query) + + return handle_search_results(all_results, start_time) if all_results.any? + + Rails.logger.warn("[MintlifySearchTool] No content found for query: #{query}") + no_results_message + rescue McpClientService::TimeoutError => e + handle_timeout_error(e) + rescue McpClientService::ConnectionError => e + handle_connection_error(e) + rescue McpClientService::ConfigurationError => e + handle_configuration_error(e) + rescue StandardError => e + handle_unexpected_error(e) + end + + def handle_search_results(results, start_time) + combined_content = combine_search_results(results) + result = format_response(combined_content, results.length) + log_search_completion(start_time, results.length) + result + end + + def handle_timeout_error(error) + Rails.logger.warn("[MintlifySearchTool] Search timeout: #{error.message}") + 'The documentation search timed out. Please try again with a more specific query.' + end + + def handle_connection_error(error) + Rails.logger.error("[MintlifySearchTool] Connection error: #{error.message}") + 'The documentation service is temporarily unavailable. Please try again in a few minutes.' + end + + def handle_configuration_error(error) + Rails.logger.error("[MintlifySearchTool] Configuration error: #{error.message}") + 'The documentation search service is not properly configured. Please contact support.' + end + + def handle_unexpected_error(error) + log_unexpected_error(error) + 'I encountered an error while searching the documentation. Please try again or contact support if the issue persists.' + end + + def log_search_completion(start_time, iterations_count) + duration = (Time.current - start_time).round(2) + Rails.logger.debug { "[MintlifySearchTool] Iterative search completed in #{duration}s with #{iterations_count} iterations" } + end + + def log_unexpected_error(error) + Rails.logger.error("[MintlifySearchTool] Unexpected error: #{error.class}: #{error.message}") + Rails.logger.error("[MintlifySearchTool] Backtrace: #{error.backtrace.join("\n")}") + end + + def perform_iterative_search(original_query) + results = [] + queries = generate_search_queries(original_query) + + queries.first(MAX_ITERATIONS).each_with_index do |query, index| + result = execute_single_search(query, index) + results << result if result + + # Short delay between iterations to be respectful + sleep(0.5) if index < queries.length - 1 + end + + results + end + + def execute_single_search(query, index) + Rails.logger.debug { "[MintlifySearchTool] Iteration #{index + 1}: #{query}" } + + response = mcp_client_service.call_tool('mintlify', 'search', { query: query }) + + return nil unless response && content?(response) + + content_text = extract_content_text(response['content']) + return nil if content_text.blank? + + { + query: query, + content: content_text, + iteration: index + 1 + } + end + + def generate_search_queries(original_query) + queries = [original_query] + additional_queries = generate_additional_queries(original_query) + queries.concat(additional_queries).uniq + end + + def generate_additional_queries(original_query) + case original_query.downcase + when /install|setup|deploy/ + ["#{original_query} configuration", "#{original_query} requirements dependencies"] + when /api|integration/ + ["#{original_query} authentication", "#{original_query} examples endpoints"] + when /webhook|notification/ + ["#{original_query} configuration setup", "#{original_query} payload format"] + when /troubleshoot|error|problem/ + ["#{original_query} common issues", "#{original_query} debugging guide"] + else + ["#{original_query} guide tutorial", "#{original_query} configuration examples"] + end + end + + def combine_search_results(results) + return '' if results.empty? + return results.first[:content] if results.length == 1 + + results.map.with_index do |result, index| + header = index.zero? ? "## Main Search Results\n\n" : "\n## Additional Information (Search #{index + 1})\n\n" + "#{header}#{result[:content]}" + end.join("\n\n") + end + + def content?(response) + response&.dig('content') && !response['content'].empty? + end + + def extract_content_text(content) + case content + when Array + content.map { |block| extract_text_from_block(block) }.join("\n") + when Hash + extract_text_from_block(content) + when String + content + else + content.to_s + end + end + + def extract_text_from_block(block) + return block.to_s unless block.is_a?(Hash) + + text_content = block['text'] + return text_content.to_s if text_content + + block.to_s + end + + def format_response(content_text, iteration_count) + return no_results_message if content_text.blank? + + iteration_note = iteration_count > 1 ? " (#{iteration_count} searches combined)" : '' + source_line = "*Source: [Chatwoot Developer Documentation](#{BASE_URL})*" + + "**Chatwoot Documentation Search Results#{iteration_note}:**\n\n#{content_text}\n\n" \ + "---\n*Base URL for relative links: #{BASE_URL}*\n#{source_line}" + end + + def no_results_message + "I couldn't find relevant information in the documentation for your query. Try rephrasing your question or asking about a different topic." + end + + def mcp_client_service + @mcp_client_service ||= McpClientService.instance + end +end \ No newline at end of file diff --git a/lib/mcp_client_service.rb b/lib/mcp_client_service.rb new file mode 100644 index 000000000..558ff5a96 --- /dev/null +++ b/lib/mcp_client_service.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require 'mcp_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. +class McpClientService + include Singleton + + class ConfigurationError < StandardError; end + class ConnectionError < StandardError; end + class TimeoutError < StandardError; end + + attr_reader :clients, :connection_stats + + def initialize + @clients = {} + @connection_stats = {} + @mutex = Mutex.new + setup_clients + 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) + + 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 + + Rails.logger.debug { "[McpClientService] Calling tool #{tool_name} on #{server_name} (attempt #{attempt})" } + + 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 + + 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) + end + end + + # List available tools for a server + def list_tools(server_name) + client = client_for(server_name) + return [] unless client + + Rails.logger.debug { "[McpClientService] Listing tools for server: #{server_name}" } + tools = client.list_tools || [] + + Rails.logger.debug { "[McpClientService] Found #{tools.length} tools for #{server_name}" } + tools + + rescue StandardError => e + Rails.logger.error("[McpClientService] Error listing tools for #{server_name}: #{e.message}") + record_failed_call(server_name, 'list_tools', e) + [] + end + + # 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 + + # 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 + end + end + + # Get connection statistics + def stats + { + clients: @clients.keys, + connection_stats: @connection_stats.dup + } + end + + private + + def setup_clients + # Set up Mintlify MCP server if enabled + setup_mintlify_client if mintlify_enabled? + end + + 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 + + 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? + end + + def create_client_for(server_name) + case server_name + when 'mintlify' + setup_mintlify_client + @clients['mintlify'] + 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.' + end + end +end diff --git a/spec/services/captain/tools/mintlify_search_service_spec.rb b/spec/services/captain/tools/mintlify_search_service_spec.rb new file mode 100644 index 000000000..0397eadf6 --- /dev/null +++ b/spec/services/captain/tools/mintlify_search_service_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Captain::Tools::MintlifySearchService, type: :service do + let(:assistant) { create(:captain_assistant) } + let(:service) { described_class.new(assistant) } + let(:mock_mcp_service) { instance_double(McpClientService) } + + before do + allow(McpClientService).to receive(:instance).and_return(mock_mcp_service) + # Skip sleep in tests + allow(service).to receive(:sleep) + end + + describe '#name' do + it 'returns the correct tool name' do + expect(service.name).to eq('mintlify_docs_search') + end + end + + describe '#description' do + it 'returns the correct description' do + expect(service.description).to include('Search Chatwoot documentation') + end + end + + describe '#parameters' do + it 'returns correct parameter schema' do + params = service.parameters + + expect(params[:type]).to eq('object') + expect(params[:properties][:query]).to be_present + expect(params[:required]).to include('query') + end + end + + describe '#execute' do + let(:valid_arguments) { { 'query' => 'How to setup webhooks?' } } + let(:mock_response) do + { + 'content' => [ + { 'type' => 'text', 'text' => 'Webhook setup documentation content here' } + ] + } + end + + context 'with valid arguments' do + it 'returns formatted documentation results' do + # Mock iterative search - expects multiple calls + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(mock_response, nil, nil) + + result = service.execute(valid_arguments) + + expect(result).to include('Documentation Search Results') + expect(result).to include('Webhook setup documentation content here') + expect(result).to include('Source: [Chatwoot Developer Documentation]') + end + + it 'handles different content structures' do + string_response = { 'content' => 'Simple string content' } + # Mock all 3 calls that the iterative search makes + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(string_response, nil, nil) + + result = service.execute(valid_arguments) + expect(result).to include('Simple string content') + end + end + + context 'with invalid arguments' do + it 'raises ArgumentError for missing query' do + expect { service.execute({}) }.to raise_error(ArgumentError, 'query is required') + end + + it 'raises ArgumentError for blank query' do + expect { service.execute({ 'query' => '' }) }.to raise_error(ArgumentError, 'query is required') + end + end + + context 'when MCP service fails' do + it 'handles timeout errors gracefully' do + allow(mock_mcp_service).to receive(:call_tool) + .and_raise(McpClientService::TimeoutError.new('Request timed out')) + + result = service.execute(valid_arguments) + expect(result).to include('search timed out') + end + + it 'handles connection errors gracefully' do + allow(mock_mcp_service).to receive(:call_tool) + .and_raise(McpClientService::ConnectionError.new('Connection failed')) + + result = service.execute(valid_arguments) + expect(result).to include('temporarily unavailable') + end + + it 'handles configuration errors gracefully' do + allow(mock_mcp_service).to receive(:call_tool) + .and_raise(McpClientService::ConfigurationError.new('Bad config')) + + result = service.execute(valid_arguments) + expect(result).to include('not properly configured') + end + + it 'handles unexpected errors gracefully' do + allow(mock_mcp_service).to receive(:call_tool) + .and_raise(StandardError.new('Unexpected error')) + + result = service.execute(valid_arguments) + expect(result).to include('encountered an error') + end + end + + context 'when no results found' do + it 'returns no results message for empty response' do + allow(mock_mcp_service).to receive(:call_tool).and_return(nil) + + result = service.execute(valid_arguments) + expect(result).to include("couldn't find relevant information") + end + + it 'returns no results message for empty content' do + empty_response = { 'content' => [] } + allow(mock_mcp_service).to receive(:call_tool).and_return(empty_response) + + result = service.execute(valid_arguments) + expect(result).to include("couldn't find relevant information") + end + end + end + + describe '#active?' do + context 'when MCP client is available' do + before do + allow(mock_mcp_service).to receive(:client_for).with('mintlify').and_return(instance_double(MCPClient)) + end + + it 'returns true' do + expect(service.active?).to be true + end + end + + context 'when MCP client is not available' do + before do + allow(mock_mcp_service).to receive(:client_for).with('mintlify').and_return(nil) + end + + it 'returns false' do + expect(service.active?).to be false + end + end + + context 'when MCP service raises an error' do + before do + allow(mock_mcp_service).to receive(:client_for).and_raise(StandardError.new('Service error')) + end + + it 'returns false and logs debug message' do + expect(Rails.logger).to receive(:debug) + expect(service.active?).to be false + end + end + end + + describe '#health_check' do + context 'when service is healthy' do + before do + allow(service).to receive(:active?).and_return(true) + allow(mock_mcp_service).to receive(:client_for).with('mintlify').and_return(instance_double(MCPClient)) + end + + it 'returns healthy status with details' do + health = service.health_check + + expect(health[:status]).to eq('healthy') + expect(health[:client_available]).to be true + expect(health[:last_check]).to be_present + end + end + + context 'when service has errors' do + before do + allow(service).to receive(:active?).and_raise(StandardError.new('Service error')) + end + + it 'returns error status' do + health = service.health_check + + expect(health[:status]).to eq('error') + expect(health[:error]).to eq('Service error') + expect(health[:last_check]).to be_present + end + end + end + + describe 'content extraction' do + let(:valid_arguments) { { 'query' => 'test query' } } + + it 'extracts text from array of content blocks' do + array_response = { + 'content' => [ + { 'type' => 'text', 'text' => 'First block' }, + { 'type' => 'text', 'text' => 'Second block' } + ] + } + + # Mock all 3 calls from iterative search + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(array_response, nil, nil) + + result = service.execute(valid_arguments) + expect(result).to include('First block') + expect(result).to include('Second block') + end + + it 'extracts text from hash content block' do + hash_response = { + 'content' => { 'type' => 'text', 'text' => 'Single block content' } + } + + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(hash_response, nil, nil) + + result = service.execute(valid_arguments) + expect(result).to include('Single block content') + end + + it 'handles string content directly' do + string_response = { 'content' => 'Direct string content' } + + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(string_response, nil, nil) + + result = service.execute(valid_arguments) + expect(result).to include('Direct string content') + end + + it 'handles blocks with different structures' do + mixed_response = { + 'content' => [ + { 'text' => 'Text without type' }, + { 'type' => 'other', 'value' => 'Other content' } + ] + } + + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(mixed_response, nil, nil) + + result = service.execute(valid_arguments) + expect(result).to include('Text without type') + end + end + + describe 'iterative search behavior' do + let(:valid_arguments) { { 'query' => 'API integration' } } + + it 'performs multiple searches for comprehensive results' do + responses = [ + { 'content' => 'Main API information' }, + { 'content' => 'Authentication details' }, + { 'content' => 'Examples and endpoints' } + ] + + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(*responses) + + result = service.execute(valid_arguments) + + expect(result).to include('Main API information') + expect(result).to include('Authentication details') + expect(result).to include('Examples and endpoints') + expect(result).to include('3 searches combined') + end + + it 'handles failed searches gracefully' do + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times + .and_return(nil, { 'content' => 'Some content' }, nil) + + result = service.execute(valid_arguments) + expect(result).to include('Some content') + end + + it 'stops early if no results found' do + expect(mock_mcp_service).to receive(:call_tool).exactly(3).times.and_return(nil, nil, nil) + + result = service.execute(valid_arguments) + expect(result).to include("couldn't find relevant information") + end + end +end \ No newline at end of file diff --git a/spec/services/mcp_client_service_spec.rb b/spec/services/mcp_client_service_spec.rb new file mode 100644 index 000000000..440de86a5 --- /dev/null +++ b/spec/services/mcp_client_service_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe McpClientService, type: :service do + let(:service) { described_class.instance } + + before do + # Reset singleton state between tests + described_class.instance_variable_set(:@singleton__instance__, nil) + + # Mock environment variables + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('HOME').and_return('/tmp') + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('MCP_SERVER_ID').and_return('test-mcp-server') + + # Mock file existence checks + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with('/tmp/.mcp/test-mcp-server').and_return(true) + + # Prevent actual MCP client creation during tests + allow(service).to receive(:setup_clients) + allow(service).to receive(:setup_mcp_server_if_needed) + end + + describe '#initialize' do + it 'initializes with empty clients and stats' do + expect(service.clients).to be_a(Hash) + expect(service.connection_stats).to be_a(Hash) + end + end + + describe '#client_for' do + context 'when requesting mintlify client' do + let(:mock_client) { instance_double(MCPClient) } + + before do + # Override the stubbed setup_clients for this test + allow(service).to receive(:setup_clients) + allow(MCPClient).to receive(:create_client).and_return(mock_client) + allow(mock_client).to receive(:list_tools).and_return([]) + end + + it 'creates and returns a mintlify client' do + client = service.client_for('mintlify') + expect(client).to eq(mock_client) + end + end + + context 'when requesting unknown server' do + it 'raises ConfigurationError' do + expect { service.client_for('unknown') }.to raise_error(McpClientService::ConfigurationError) + end + end + end + + describe '#call_tool' do + let(:mock_client) { instance_double(MCPClient) } + + before do + allow(service).to receive(:client_for).with('mintlify').and_return(mock_client) + end + + context 'with valid parameters' do + it 'calls tool successfully and records metrics' do + expect(mock_client).to receive(:call_tool).with('search', { query: 'test' }).and_return('result') + + result = service.call_tool('mintlify', 'search', { query: 'test' }) + + expect(result).to eq('result') + expect(service.connection_stats['mintlify'][:successful_calls]).to eq(1) + end + end + + context 'with invalid parameters' do + it 'raises ArgumentError for blank server_name' do + expect { service.call_tool('', 'search', {}) }.to raise_error(ArgumentError, 'server_name cannot be blank') + end + + it 'raises ArgumentError for blank tool_name' do + expect { service.call_tool('mintlify', '', {}) }.to raise_error(ArgumentError, 'tool_name cannot be blank') + end + + it 'raises ArgumentError for non-hash arguments' do + expect { service.call_tool('mintlify', 'search', 'not_hash') }.to raise_error(ArgumentError, 'arguments must be a Hash') + end + end + + context 'when tool call fails' do + it 'handles errors gracefully' do + allow(mock_client).to receive(:call_tool).and_raise(StandardError.new('Connection failed')) + + result = service.call_tool('mintlify', 'search', { query: 'test' }) + expect(result).to be_a(String) + expect(result).to include('could not search') + end + end + end + + describe '#list_tools' do + let(:mock_client) { instance_double(MCPClient) } + let(:mock_tools) { [{ 'name' => 'search', 'description' => 'Search docs' }] } + + before do + allow(service).to receive(:client_for).with('mintlify').and_return(mock_client) + end + + it 'returns tools from client' do + expect(mock_client).to receive(:list_tools).and_return(mock_tools) + + tools = service.list_tools('mintlify') + expect(tools).to eq(mock_tools) + end + + it 'returns empty array on error' do + expect(mock_client).to receive(:list_tools).and_raise(StandardError) + + tools = service.list_tools('mintlify') + expect(tools).to eq([]) + end + end + + describe '#all_tools' do + it 'returns tools from all servers' do + allow(service).to receive(:list_tools).with('mintlify').and_return([{ 'name' => 'search' }]) + service.instance_variable_set(:@clients, { 'mintlify' => instance_double(MCPClient) }) + + tools = service.all_tools + expect(tools).to have_key('mintlify') + expect(tools['mintlify']).to eq([{ 'name' => 'search' }]) + end + end + + describe '#stats' do + it 'returns service statistics' do + stats = service.stats + + expect(stats).to have_key(:clients) + expect(stats).to have_key(:connection_stats) + end + end + + describe '#reconnect_all!' do + let(:mock_client) { instance_double(MCPClient, close: nil) } + + before do + service.instance_variable_set(:@clients, { 'mintlify' => mock_client }) + end + + it 'clears clients and reconnects' do + service.reconnect_all! + + expect(service.clients).to be_empty + end + end + + describe 'error handling' do + context 'when MCP server directory does not exist' do + before do + allow(File).to receive(:exist?).with('/tmp/.mcp/test-mcp-server').and_return(false) + # Don't stub setup methods for this test to test the actual error handling + allow(service).to receive(:setup_clients).and_call_original + allow(service).to receive(:setup_mcp_server_if_needed).and_call_original + end + + it 'raises ConfigurationError in production' do + mock_env = instance_double(Rails.env, production?: true, development?: false) + allow(Rails).to receive(:env).and_return(mock_env) + + expect { described_class.instance }.to raise_error(McpClientService::ConfigurationError) + end + end + + context 'when MCP_SERVER_ID environment variable is not set' do + before do + # Remove the MCP_SERVER_ID mock to test the error handling + allow(ENV).to receive(:fetch).with('MCP_SERVER_ID').and_raise(KeyError) + allow(service).to receive(:setup_clients).and_call_original + end + + it 'raises ConfigurationError with helpful message' do + expect { described_class.instance }.to raise_error( + McpClientService::ConfigurationError, + /MCP_SERVER_ID environment variable is required/ + ) + end + end + + context 'when using custom MCP_SERVER_ID' do + before do + # Override the environment variable for this test + allow(ENV).to receive(:fetch).with('MCP_SERVER_ID').and_return('custom-server-id') + allow(File).to receive(:exist?).with('/tmp/.mcp/custom-server-id').and_return(false) + allow(service).to receive(:setup_clients).and_call_original + allow(service).to receive(:setup_mcp_server_if_needed).and_call_original + end + + it 'uses the custom server id from environment variable' do + mock_env = instance_double(Rails.env, production?: true, development?: false) + allow(Rails).to receive(:env).and_return(mock_env) + + expect { described_class.instance }.to raise_error(McpClientService::ConfigurationError, /custom-server-id/) + end + end + end +end \ No newline at end of file