Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de4d17d0b6 | ||
|
|
cddd555ad0 | ||
|
|
848b833bc1 | ||
|
|
c60b47fd57 | ||
|
|
5bbda8e348 | ||
|
|
f07e171c0b | ||
|
|
36d09597a2 | ||
|
|
c3619f51a1 | ||
|
|
141285b036 | ||
|
|
f429db89b4 |
@@ -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
|
||||
|
||||
@@ -11,6 +11,8 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
end
|
||||
|
||||
sections << "Contact Details: #{@record.contact.to_llm_text}" if config[:include_contact_details]
|
||||
sections << 'Conversation Attributes:'
|
||||
sections << build_attributes
|
||||
sections.join("\n")
|
||||
end
|
||||
|
||||
@@ -20,7 +22,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
return "No messages in this conversation\n" if @record.messages.empty?
|
||||
|
||||
message_text = ''
|
||||
@record.messages.chat.order(created_at: :asc).each do |message|
|
||||
@record.messages.where.not(message_type: :activity).order(created_at: :asc).each do |message|
|
||||
message_text << format_message(message)
|
||||
end
|
||||
message_text
|
||||
@@ -28,6 +30,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
||||
|
||||
def format_message(message)
|
||||
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
|
||||
sender = "[Private] #{sender}" if message.private?
|
||||
"#{sender}: #{message.content}\n"
|
||||
end
|
||||
|
||||
def build_attributes
|
||||
attributes = @record.account.custom_attribute_definitions.with_attribute_model('conversation_attribute').map do |attribute|
|
||||
"#{attribute.attribute_display_name}: #{@record.custom_attributes[attribute.attribute_key]}"
|
||||
end
|
||||
attributes.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +34,7 @@ RUN apk update && apk add --no-cache \
|
||||
git \
|
||||
curl \
|
||||
xz \
|
||||
expect \
|
||||
&& mkdir -p /var/app \
|
||||
&& gem install bundler
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
|
||||
def playground
|
||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
params[:message_content],
|
||||
message_history
|
||||
additional_message: params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
|
||||
render json: response
|
||||
|
||||
@@ -18,6 +18,18 @@ module Captain::ChatHelper
|
||||
raise e
|
||||
end
|
||||
|
||||
def extract_audio_transcriptions(attachments)
|
||||
audio_attachments = attachments.where(file_type: :audio)
|
||||
return '' if audio_attachments.blank?
|
||||
|
||||
transcriptions = ''
|
||||
audio_attachments.each do |attachment|
|
||||
result = Messages::AudioTranscriptionService.new(attachment).perform
|
||||
transcriptions += result[:transcriptions] if result[:success]
|
||||
end
|
||||
transcriptions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::ChatHelper
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3
|
||||
|
||||
@@ -13,7 +15,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
generate_and_process_response
|
||||
end
|
||||
rescue StandardError => e
|
||||
raise e if e.is_a?(ActiveJob::FileNotFoundError)
|
||||
raise e if e.is_a?(ActiveStorage::FileNotFoundError)
|
||||
|
||||
handle_error(e)
|
||||
ensure
|
||||
@@ -26,8 +28,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
@conversation.messages.incoming.last.content,
|
||||
collect_previous_messages
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
|
||||
return process_action('handoff') if handoff_requested?
|
||||
@@ -37,39 +38,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
account.increment_response_usage
|
||||
end
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
{
|
||||
content: message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
end
|
||||
end
|
||||
def collect_previous_messages(include_all: false)
|
||||
messages_query = @conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
|
||||
def message_content(message)
|
||||
return message.content if message.content.present?
|
||||
return 'User has shared a message without content' unless message.attachments.any?
|
||||
messages_query = messages_query.where(private: false) unless include_all
|
||||
|
||||
audio_transcriptions = extract_audio_transcriptions(message.attachments)
|
||||
return audio_transcriptions if audio_transcriptions.present?
|
||||
|
||||
'User has shared an attachment'
|
||||
end
|
||||
|
||||
def extract_audio_transcriptions(attachments)
|
||||
audio_attachments = attachments.where(file_type: :audio)
|
||||
return '' if audio_attachments.blank?
|
||||
|
||||
transcriptions = ''
|
||||
audio_attachments.each do |attachment|
|
||||
result = Messages::AudioTranscriptionService.new(attachment).perform
|
||||
transcriptions += result[:transcriptions] if result[:success]
|
||||
messages_query.map do |message|
|
||||
{
|
||||
content: message_content_multimodal(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
end
|
||||
transcriptions
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
@@ -78,6 +59,65 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
message.message_type == 'incoming' ? 'user' : 'system'
|
||||
end
|
||||
|
||||
def message_content_multimodal(message)
|
||||
parts = []
|
||||
|
||||
parts << text_part(message.content) if message.content.present?
|
||||
parts.concat(attachment_parts(message.attachments)) if message.attachments.any?
|
||||
|
||||
finalize_content_parts(parts)
|
||||
end
|
||||
|
||||
def text_part(text)
|
||||
{ type: 'text', text: text }
|
||||
end
|
||||
|
||||
def attachment_parts(attachments)
|
||||
[].tap do |parts|
|
||||
parts.concat(image_parts(attachments.where(file_type: :image)))
|
||||
|
||||
transcription = extract_audio_transcriptions(attachments)
|
||||
parts << text_part(transcription) if transcription.present?
|
||||
|
||||
parts << text_part('User has shared an attachment') if attachments.where.not(file_type: %i[image audio]).exists?
|
||||
end
|
||||
end
|
||||
|
||||
def image_parts(image_attachments)
|
||||
image_attachments.each_with_object([]) do |attachment, parts|
|
||||
url = get_attachment_url(attachment)
|
||||
next if url.blank?
|
||||
|
||||
parts << {
|
||||
type: 'image_url',
|
||||
image_url: { url: url }
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def finalize_content_parts(parts)
|
||||
return 'Message without content' if parts.blank?
|
||||
return parts.first[:text] if single_text_part?(parts)
|
||||
|
||||
parts
|
||||
end
|
||||
|
||||
def single_text_part?(parts)
|
||||
parts.one? && parts.first[:type] == 'text'
|
||||
end
|
||||
|
||||
def get_attachment_url(attachment)
|
||||
return attachment.external_url if attachment.external_url.present?
|
||||
|
||||
return unless attachment.file.attached?
|
||||
|
||||
begin
|
||||
attachment.file_url
|
||||
rescue ActiveStorage::FileNotFoundError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def handoff_requested?
|
||||
@response['response'] == 'conversation_handoff'
|
||||
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)
|
||||
|
||||
@@ -12,9 +12,16 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
register_tools
|
||||
end
|
||||
|
||||
def generate_response(input, previous_messages = [], role = 'user')
|
||||
@messages += previous_messages
|
||||
@messages << { role: role, content: input } if input.present?
|
||||
# additional_message: A single message (String) from the user that should be appended to the chat.
|
||||
# It can be an empty String or nil when you only want to supply historical messages.
|
||||
# message_history: An Array of already formatted messages that provide the previous context.
|
||||
# role: The role for the additional_message (defaults to `user`).
|
||||
#
|
||||
# NOTE: Parameters are provided as keyword arguments to improve clarity and avoid relying on
|
||||
# positional ordering.
|
||||
def generate_response(additional_message: nil, message_history: [], role: 'user')
|
||||
@messages += message_history
|
||||
@messages << { role: role, content: additional_message } if additional_message.present?
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
@@ -22,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
|
||||
@@ -8,9 +8,9 @@ class ChatGpt
|
||||
@messages = [system_message(context_sections)]
|
||||
end
|
||||
|
||||
def generate_response(input, previous_messages = [], role = 'user')
|
||||
@messages += previous_messages
|
||||
@messages << { 'role': role, 'content': input } if input.present?
|
||||
def generate_response(additional_message: nil, message_history: [], role: 'user')
|
||||
@messages += message_history
|
||||
@messages << { 'role': role, 'content': additional_message } if additional_message.present?
|
||||
|
||||
response = request_gpt
|
||||
JSON.parse(response['choices'][0]['message']['content'].strip)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,189 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'singleton'
|
||||
require 'timeout'
|
||||
require_relative 'mcp/clients/mintlify_client'
|
||||
require_relative 'mcp/clients/acme_mintlify_client'
|
||||
|
||||
# MCP Client Service
|
||||
# Main service that manages MCP client connections using a structured approach
|
||||
class McpClientService
|
||||
include Singleton
|
||||
|
||||
class ClientNotFoundError < StandardError; end
|
||||
|
||||
attr_reader :clients
|
||||
|
||||
def initialize
|
||||
@clients = {}
|
||||
# Don't auto-setup in initializer to avoid issues with Rails loading
|
||||
end
|
||||
|
||||
# Initialize both servers with default configurations
|
||||
def initialize_default_servers!
|
||||
Rails.logger.info('[McpClientService] Initializing default MCP servers...')
|
||||
start_time = Time.current
|
||||
|
||||
begin
|
||||
initialize_mintlify_server
|
||||
initialize_acme_server
|
||||
|
||||
duration = Time.current - start_time
|
||||
Rails.logger.info("[McpClientService] All default servers initialized successfully in #{duration.round(2)}s")
|
||||
|
||||
log_connection_status
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[McpClientService] Failed to initialize some servers: #{e.message}")
|
||||
Rails.logger.debug { "[McpClientService] Backtrace: #{e.backtrace.first(3).join(', ')}" }
|
||||
end
|
||||
end
|
||||
|
||||
# Get or create a client for a specific type
|
||||
def client_for(type, config = {})
|
||||
type = type.to_s.downcase
|
||||
|
||||
return @clients[type] if @clients[type]&.connected?
|
||||
|
||||
Rails.logger.debug { "[McpClientService] Creating client for #{type}" }
|
||||
connection_start = Time.current
|
||||
|
||||
begin
|
||||
# Create and connect the client with provided config
|
||||
# Each client has its own default configuration
|
||||
client = create_client(type, config)
|
||||
client.connect!
|
||||
|
||||
@clients[type] = client
|
||||
|
||||
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
|
||||
|
||||
# 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 initialize_mintlify_server
|
||||
connection_start = Time.current
|
||||
|
||||
begin
|
||||
config = {
|
||||
server_id: 'chatwoot-447c5a93',
|
||||
auto_setup: true,
|
||||
test_on_connect: true
|
||||
}
|
||||
|
||||
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 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'
|
||||
Mcp::Clients::MintlifyClient.new(config)
|
||||
when 'acme_mintlify'
|
||||
Mcp::Clients::AcmeMintlifyClient.new(config)
|
||||
else
|
||||
raise ClientNotFoundError, "Unknown client type: #{type}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -211,8 +211,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
valid_params[:message_content],
|
||||
valid_params[:message_history]
|
||||
additional_message: valid_params[:message_content],
|
||||
message_history: valid_params[:message_history]
|
||||
)
|
||||
expect(json_response[:content]).to eq('Assistant response')
|
||||
end
|
||||
@@ -232,8 +232,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(chat_service).to have_received(:generate_response).with(
|
||||
params_without_history[:message_content],
|
||||
[]
|
||||
additional_message: params_without_history[:message_content],
|
||||
message_history: []
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,5 +30,30 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
account.reload
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
context 'when message contains an image' do
|
||||
let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') }
|
||||
let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') }
|
||||
|
||||
before do
|
||||
image_attachment
|
||||
end
|
||||
|
||||
it 'includes image URL directly in the message content for OpenAI vision analysis' do
|
||||
# Expect the generate_response to receive multimodal content with image URL
|
||||
expect(mock_llm_chat_service).to receive(:generate_response) do |**kwargs|
|
||||
history = kwargs[:message_history]
|
||||
last_entry = history.last
|
||||
expect(last_entry[:content]).to be_an(Array)
|
||||
expect(last_entry[:content].any? { |part| part[:type] == 'text' && part[:text] == 'Can you help with this error?' }).to be true
|
||||
expect(last_entry[:content].any? do |part|
|
||||
part[:type] == 'image_url' && part[:image_url][:url] == 'https://example.com/error.jpg'
|
||||
end).to be true
|
||||
{ 'response' => 'I can see the error in your image. It appears to be a database connection issue.' }
|
||||
end
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end
|
||||
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
|
||||
@@ -12,7 +12,9 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'No messages in this conversation'
|
||||
'No messages in this conversation',
|
||||
'Conversation Attributes:',
|
||||
''
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
@@ -41,6 +43,8 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
||||
'Message History:',
|
||||
'User: Hello, I need help',
|
||||
'Support agent: How can I assist you today?',
|
||||
'',
|
||||
'Conversation Attributes:',
|
||||
''
|
||||
].join("\n")
|
||||
|
||||
@@ -55,11 +59,38 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'No messages in this conversation',
|
||||
"Contact Details: #{conversation.contact.to_llm_text}"
|
||||
"Contact Details: #{conversation.contact.to_llm_text}",
|
||||
'Conversation Attributes:',
|
||||
''
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format(include_contact_details: true)).to eq(expected_output)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has custom attributes' do
|
||||
it 'includes formatted custom attributes in the output' do
|
||||
create(
|
||||
:custom_attribute_definition,
|
||||
account: account,
|
||||
attribute_display_name: 'Order ID',
|
||||
attribute_key: 'order_id',
|
||||
attribute_model: :conversation_attribute
|
||||
)
|
||||
|
||||
conversation.update(custom_attributes: { 'order_id' => '12345' })
|
||||
|
||||
expected_output = [
|
||||
"Conversation ID: ##{conversation.display_id}",
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'No messages in this conversation',
|
||||
'Conversation Attributes:',
|
||||
'Order ID: 12345'
|
||||
].join("\n")
|
||||
|
||||
expect(formatter.format).to eq(expected_output)
|
||||
end
|
||||
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