Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de4d17d0b6 | ||
|
|
cddd555ad0 | ||
|
|
848b833bc1 | ||
|
|
c60b47fd57 | ||
|
|
5bbda8e348 | ||
|
|
f07e171c0b | ||
|
|
36d09597a2 | ||
|
|
c3619f51a1 | ||
|
|
141285b036 | ||
|
|
f28bb70d67 | ||
|
|
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
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,70 +0,0 @@
|
||||
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,8 +39,7 @@ 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],
|
||||
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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,6 +109,7 @@ const advancedFilterTypes = ref(
|
||||
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
|
||||
}))
|
||||
);
|
||||
const isInitialLoad = ref(false);
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const chatLists = useMapGetter('getFilteredConversations');
|
||||
@@ -376,6 +377,7 @@ 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
|
||||
@@ -420,6 +422,7 @@ function onApplyFilter(payload) {
|
||||
foldersQuery.value = filterQueryGenerator(payload);
|
||||
store.dispatch('conversationPage/reset');
|
||||
store.dispatch('emptyAllConversations');
|
||||
isInitialLoad.value = true;
|
||||
fetchFilteredConversations(payload);
|
||||
}
|
||||
|
||||
@@ -574,6 +577,7 @@ 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);
|
||||
@@ -854,7 +858,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
:active-status="activeStatus"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
:conversation-stats="conversationStats"
|
||||
:is-list-loading="chatListLoading"
|
||||
:is-list-loading="isInitialLoad"
|
||||
@add-folders="onClickOpenAddFoldersModal"
|
||||
@delete-folders="onClickOpenDeleteFoldersModal"
|
||||
@filters-modal="onToggleAdvanceFiltersModal"
|
||||
|
||||
@@ -328,28 +328,6 @@
|
||||
"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": {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<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,7 +9,6 @@ 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: [
|
||||
@@ -101,14 +100,6 @@ 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',
|
||||
|
||||
@@ -43,8 +43,6 @@ 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
|
||||
@@ -60,8 +58,6 @@ 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
|
||||
@@ -79,16 +75,6 @@ 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'
|
||||
@@ -112,10 +98,6 @@ 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,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
|
||||
|
||||
@@ -159,9 +159,6 @@
|
||||
<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: 42 KiB After Width: | Height: | Size: 41 KiB |
@@ -168,7 +168,4 @@
|
||||
enabled: true
|
||||
- name: crm_integration
|
||||
display_name: CRM Integration
|
||||
enabled: false
|
||||
- name: github_integration
|
||||
display_name: Github Integration
|
||||
enabled: false
|
||||
@@ -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
|
||||
@@ -341,17 +341,3 @@
|
||||
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 ------ ##
|
||||
|
||||
@@ -278,10 +278,3 @@ leadsquared:
|
||||
'conversation_activity_score',
|
||||
'transcript_activity_score',
|
||||
]
|
||||
|
||||
github:
|
||||
id: github
|
||||
logo: github.png
|
||||
i18n_key: github
|
||||
hook_type: account
|
||||
allow_multiple_hooks: false
|
||||
|
||||
@@ -257,10 +257,6 @@ 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.'
|
||||
|
||||
@@ -265,9 +265,6 @@ Rails.application.routes.draw do
|
||||
get :linked_issues
|
||||
end
|
||||
end
|
||||
namespace :integrations do
|
||||
resource :github, only: [:show]
|
||||
end
|
||||
end
|
||||
resources :working_hours, only: [:update]
|
||||
|
||||
@@ -496,7 +493,6 @@ 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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -103,9 +103,3 @@ 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,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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
Before 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(
|
||||
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