Compare commits

..
Author SHA1 Message Date
Muhsin KelothandGitHub 7f9e94af2e Merge branch 'develop' into feat/github-oauth 2025-06-12 12:00:51 +05:30
Muhsin Keloth cf73ba195e feat: add github auth 2025-06-12 11:55:57 +05:30
39 changed files with 353 additions and 1851 deletions
-3
View File
@@ -151,9 +151,6 @@ 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'
+1 -5
View File
@@ -702,9 +702,6 @@ 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)
@@ -986,7 +983,6 @@ DEPENDENCIES
rubocop-performance
rubocop-rails
rubocop-rspec
ruby-mcp-client
ruby-openai
scout_apm
scss_lint
@@ -1024,4 +1020,4 @@ RUBY VERSION
ruby 3.4.4p34
BUNDLED WITH
2.6.7
2.5.16
@@ -0,0 +1,13 @@
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
before_action :check_authorization
def show
@github_integration = Current.account.hooks.find_by(app_id: 'github')
end
private
def check_authorization
authorize ::Webhook, :show?
end
end
@@ -0,0 +1,70 @@
class Github::CallbacksController < ApplicationController
include Github::IntegrationHelper
before_action :authenticate_user!
before_action :set_account
def show
if params[:error].present?
redirect_to app_account_integrations_path(account_id: @account.id), alert: params[:error_description]
return
end
if params[:code].present?
response = exchange_code_for_token(params[:code])
if response[:access_token].present?
hook = @account.hooks.find_or_initialize_by(app_id: 'github')
hook.access_token = response[:access_token]
hook.status = 'enabled'
if hook.save
redirect_to app_account_integrations_path(account_id: @account.id), notice: 'GitHub integration connected successfully!'
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'Failed to save GitHub integration'
end
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'Failed to get access token from GitHub'
end
else
redirect_to app_account_integrations_path(account_id: @account.id), alert: 'GitHub authorization failed'
end
end
private
def set_account
@account = Current.user.accounts.find(params[:account_id]) if params[:account_id].present?
@account ||= Current.user.accounts.first
return if @account
redirect_to root_path, alert: 'Account not found'
end
def exchange_code_for_token(code)
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
client_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
return {} unless client_id && client_secret
response = HTTParty.post('https://github.com/login/oauth/access_token', {
body: {
client_id: client_id,
client_secret: client_secret,
code: code
},
headers: {
'Accept' => 'application/json'
}
})
if response.success?
JSON.parse(response.body).with_indifferent_access
else
{}
end
rescue StandardError => e
Rails.logger.error "GitHub OAuth error: #{e.message}"
{}
end
end
@@ -39,7 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
+71
View File
@@ -0,0 +1,71 @@
module Github::IntegrationHelper
def github_integration_url(account_id)
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback?account_id=#{account_id}"
end
def github_oauth_url(account_id)
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
return nil unless client_id
params = {
client_id: client_id,
redirect_uri: github_integration_url(account_id),
scope: 'repo',
state: generate_state_token(account_id)
}
"https://github.com/login/oauth/authorize?#{params.to_query}"
end
def github_configured?
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? &&
GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
end
def github_integration_enabled?(account)
return false unless github_configured?
account.hooks.exists?(app_id: 'github', status: 'enabled')
end
def github_repositories(access_token)
return [] unless access_token
response = HTTParty.get('https://api.github.com/user/repos', {
headers: {
'Authorization' => "token #{access_token}",
'Accept' => 'application/vnd.github.v3+json'
},
query: {
per_page: 100,
sort: 'updated'
}
})
if response.success?
JSON.parse(response.body)
else
[]
end
rescue StandardError => e
Rails.logger.error "GitHub API error: #{e.message}"
[]
end
private
def generate_state_token(account_id)
payload = {
account_id: account_id,
timestamp: Time.current.to_i
}
JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
end
def verify_state_token(state)
JWT.decode(state, Rails.application.secret_key_base, true, { algorithm: 'HS256' })
rescue JWT::DecodeError
nil
end
end
@@ -109,7 +109,6 @@ const advancedFilterTypes = ref(
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
}))
);
const isInitialLoad = ref(false);
const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getFilteredConversations');
@@ -377,7 +376,6 @@ function setFiltersFromUISettings() {
function emitConversationLoaded() {
emit('conversationLoad');
isInitialLoad.value = false;
// [VITE] removing this since the library has changed
// nextTick(() => {
// // Addressing a known issue in the virtual list library where dynamically added items
@@ -422,7 +420,6 @@ function onApplyFilter(payload) {
foldersQuery.value = filterQueryGenerator(payload);
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
isInitialLoad.value = true;
fetchFilteredConversations(payload);
}
@@ -577,7 +574,6 @@ function resetAndFetchData() {
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
store.dispatch('clearConversationFilters');
isInitialLoad.value = true;
if (hasActiveFolders.value) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
@@ -858,7 +854,7 @@ watch(conversationFilters, (newVal, oldVal) => {
:active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="isInitialLoad"
:is-list-loading="chatListLoading"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@@ -328,6 +328,28 @@
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
},
"GITHUB": {
"TITLE": "GitHub Integration",
"DESCRIPTION": "Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.",
"NOT_CONFIGURED": {
"TITLE": "GitHub Integration Not Configured",
"DESCRIPTION": "Contact your administrator to configure GitHub OAuth settings."
},
"CONNECT": {
"TITLE": "Connect GitHub",
"DESCRIPTION": "Connect your GitHub account to start creating issues from conversations.",
"BUTTON": "Connect GitHub"
},
"CONNECTED": {
"TITLE": "GitHub Connected",
"DESCRIPTION": "Your GitHub account is successfully connected. You can now create issues from conversations."
},
"DISCONNECT": {
"BUTTON": "Disconnect",
"SUCCESS": "GitHub integration disconnected successfully",
"ERROR": "Failed to disconnect GitHub integration"
}
}
},
"CAPTAIN": {
@@ -0,0 +1,57 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
const store = useStore();
const integrationLoaded = ref(false);
const integration = useFunctionGetter('integrations/getIntegration', 'github');
const uiFlags = useMapGetter('integrations/getUIFlags');
const integrationAction = computed(() => {
if (integration.value.enabled) {
return 'disconnect';
}
return integration.value.action;
});
const initializeGithubIntegration = async () => {
await store.dispatch('integrations/get', 'github');
integrationLoaded.value = true;
};
onMounted(() => {
initializeGithubIntegration();
});
</script>
<template>
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
@@ -9,6 +9,7 @@ import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Shopify from './Shopify.vue';
import Github from './Github.vue';
export default {
routes: [
@@ -100,6 +101,14 @@ export default {
},
props: route => ({ error: route.query.error }),
},
{
path: 'github',
name: 'settings_integrations_github',
component: Github,
meta: {
permissions: ['administrator'],
},
},
{
path: ':integration_id',
name: 'settings_applications_integration',
+18
View File
@@ -43,6 +43,8 @@ class Integrations::App
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
when 'linear'
build_linear_action
when 'github'
build_github_action
else
params[:action]
end
@@ -58,6 +60,8 @@ class Integrations::App
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
when 'leadsquared'
account.feature_enabled?('crm_integration')
when 'github'
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present?
else
true
end
@@ -75,6 +79,16 @@ class Integrations::App
].join('&')
end
def build_github_action
client_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
[
'https://github.com/login/oauth/authorize?response_type=code',
"client_id=#{client_id}",
"redirect_uri=#{self.class.github_integration_url}",
'scope=repo'
].join('&')
end
def enabled?(account)
case params[:id]
when 'webhook'
@@ -98,6 +112,10 @@ class Integrations::App
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
end
def self.github_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
end
class << self
def apps
Hashie::Mash.new(APPS_CONFIG)
@@ -11,8 +11,6 @@ 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
@@ -22,7 +20,7 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
@record.messages.where.not(message_type: :activity).order(created_at: :asc).each do |message|
@record.messages.chat.order(created_at: :asc).each do |message|
message_text << format_message(message)
end
message_text
@@ -30,14 +28,6 @@ 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
@@ -159,6 +159,9 @@
<symbol id="icon-shopify" viewBox="0 0 32 32">
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
</symbol>
<symbol id="icon-github" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</symbol>
<symbol id="icon-slack" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
</symbol>

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+3
View File
@@ -168,4 +168,7 @@
enabled: true
- name: crm_integration
display_name: CRM Integration
enabled: false
- name: github_integration
display_name: Github Integration
enabled: false
-31
View File
@@ -1,31 +0,0 @@
# 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
+14
View File
@@ -341,3 +341,17 @@
value: 'v22.0'
locked: true
# ------- End of Instagram Channel Related Config ------- #
## ------ Configs added for GitHub ------ ##
- name: GITHUB_CLIENT_ID
display_title: 'GitHub Client ID'
value:
locked: false
description: 'GitHub OAuth app client ID'
- name: GITHUB_CLIENT_SECRET
display_title: 'GitHub Client Secret'
value:
locked: false
description: 'GitHub OAuth app client secret'
type: secret
## ------ End of Configs added for GitHub ------ ##
+7
View File
@@ -278,3 +278,10 @@ leadsquared:
'conversation_activity_score',
'transcript_activity_score',
]
github:
id: github
logo: github.png
i18n_key: github
hook_type: account
allow_multiple_hooks: false
+4
View File
@@ -257,6 +257,10 @@ en:
name: 'LeadSquared'
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
github:
name: 'GitHub'
short_description: 'Create and manage GitHub issues directly from conversations.'
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
captain:
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
+4
View File
@@ -265,6 +265,9 @@ Rails.application.routes.draw do
get :linked_issues
end
end
namespace :integrations do
resource :github, only: [:show]
end
end
resources :working_hours, only: [:update]
@@ -493,6 +496,7 @@ Rails.application.routes.draw do
get 'microsoft/callback', to: 'microsoft/callbacks#show'
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
get 'github/callback', to: 'github/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications
get '.well-known/assetlinks.json' => 'android_app#assetlinks'
-1
View File
@@ -34,7 +34,6 @@ 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(
additional_message: params[:message_content],
message_history: message_history
params[:message_content],
message_history
)
render json: response
@@ -18,18 +18,6 @@ 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)
@@ -103,3 +103,9 @@ shopify:
enabled: true
icon: 'icon-shopify'
config_key: 'shopify'
github:
name: 'GitHub'
description: 'Configuration for setting up GitHub Integration'
enabled: true
icon: 'icon-github'
config_key: 'github'
@@ -1,6 +1,4 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
include Captain::ChatHelper
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3
@@ -15,7 +13,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
generate_and_process_response
end
rescue StandardError => e
raise e if e.is_a?(ActiveStorage::FileNotFoundError)
raise e if e.is_a?(ActiveJob::FileNotFoundError)
handle_error(e)
ensure
@@ -28,7 +26,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def generate_and_process_response
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
message_history: collect_previous_messages
@conversation.messages.incoming.last.content,
collect_previous_messages
)
return process_action('handoff') if handoff_requested?
@@ -38,19 +37,39 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
account.increment_response_usage
end
def collect_previous_messages(include_all: false)
messages_query = @conversation
.messages
.where(message_type: [:incoming, :outgoing])
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
messages_query = messages_query.where(private: false) unless include_all
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.map do |message|
{
content: message_content_multimodal(message),
role: determine_role(message)
}
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]
end
transcriptions
end
def determine_role(message)
@@ -59,65 +78,6 @@ 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,7 +61,6 @@ 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,16 +12,9 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
register_tools
end
# 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?
def generate_response(input, previous_messages = [], role = 'user')
@messages += previous_messages
@messages << { role: role, content: input } if input.present?
request_chat_completion
end
@@ -29,7 +22,6 @@ 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
@@ -1,230 +0,0 @@
# 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
+3 -3
View File
@@ -8,9 +8,9 @@ class ChatGpt
@messages = [system_message(context_sections)]
end
def generate_response(additional_message: nil, message_history: [], role: 'user')
@messages += message_history
@messages << { 'role': role, 'content': additional_message } if additional_message.present?
def generate_response(input, previous_messages = [], role = 'user')
@messages += previous_messages
@messages << { 'role': role, 'content': input } if input.present?
response = request_gpt
JSON.parse(response['choices'][0]['message']['content'].strip)
-266
View File
@@ -1,266 +0,0 @@
# 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
-224
View File
@@ -1,224 +0,0 @@
# 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
-229
View File
@@ -1,229 +0,0 @@
# 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
-189
View File
@@ -1,189 +0,0 @@
# 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -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(
additional_message: valid_params[:message_content],
message_history: valid_params[:message_history]
valid_params[:message_content],
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(
additional_message: params_without_history[:message_content],
message_history: []
params_without_history[:message_content],
[]
)
end
end
@@ -30,30 +30,5 @@ 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
@@ -1,285 +0,0 @@
# 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,9 +12,7 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
"Conversation ID: ##{conversation.display_id}",
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
'No messages in this conversation',
'Conversation Attributes:',
''
'No messages in this conversation'
].join("\n")
expect(formatter.format).to eq(expected_output)
@@ -43,8 +41,6 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
'Message History:',
'User: Hello, I need help',
'Support agent: How can I assist you today?',
'',
'Conversation Attributes:',
''
].join("\n")
@@ -59,38 +55,11 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
'No messages in this conversation',
"Contact Details: #{conversation.contact.to_llm_text}",
'Conversation Attributes:',
''
"Contact Details: #{conversation.contact.to_llm_text}"
].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
-207
View File
@@ -1,207 +0,0 @@
# 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