feat: implement mintlify search service
This commit is contained in:
@@ -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'
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user