feat(captain): MCP server (HTTP) expondo tools pro Hermes Agent
Implementa servidor MCP (Model Context Protocol) HTTP no Captain pra o Hermes Agent invocar tools do Captain via `hermes mcp add`. Substrato pra integração de Nível 2 onde o agente consulta tools quando precisa executar ações reais (buscar FAQ, adicionar label, futuramente Pix etc). Arquivos: - app/controllers/webhooks/captain/mcp_controller.rb Endpoint POST /webhooks/captain/mcp. Valida HMAC (CAPTAIN_MCP_SECRET), parseia JSON-RPC, despacha pro Server. Extrai params._captain_context com multi-tenant ids (conversation_id, inbox_id, account_id, etc). - enterprise/app/services/captain/mcp/server.rb Subset MCP suficiente: initialize, tools/list, tools/call, ping, notifications/initialized. JSON-RPC síncrono (sem SSE). - enterprise/app/services/captain/mcp/tool_registry.rb Lista centralizada de tools. - enterprise/app/services/captain/mcp/tools/base_tool.rb Interface base pras tools (.name, .description, .input_schema, #call). - enterprise/app/services/captain/mcp/tools/add_label_tool.rb Tool 1: aplica label na conversation atual. - enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb Tool 2: busca semântica em FAQs (Captain::AssistantResponse via pgvector cosine). Reaproveita SearchReplyDocumentationService pra paridade com o caminho legado do Captain. - config/routes.rb Rota POST /webhooks/captain/mcp. Conexão pelo Hermes: hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp Auth: HMAC X-Hub-Signature-256 quando CAPTAIN_MCP_SECRET setado. TODO próxima sprint: generate_pix_tool, send_suite_images_tool. Handoff fica via automation hoje (UI Chatwoot). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cd519a73c4
commit
23911ea878
72
app/controllers/webhooks/captain/mcp_controller.rb
Normal file
72
app/controllers/webhooks/captain/mcp_controller.rb
Normal file
@ -0,0 +1,72 @@
|
||||
# Endpoint MCP (Model Context Protocol) HTTP do Captain.
|
||||
#
|
||||
# POST /webhooks/captain/mcp
|
||||
#
|
||||
# Hermes Agent (e qualquer cliente MCP) conecta aqui pra invocar tools do
|
||||
# Captain (add_label, faq_lookup, generate_pix, etc).
|
||||
#
|
||||
# Conexão pelo Hermes:
|
||||
# hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp
|
||||
#
|
||||
# Auth: HMAC-SHA256 do body via header `X-Hub-Signature-256`, secret
|
||||
# compartilhado via env var `CAPTAIN_MCP_SECRET` (igual ao padrão de
|
||||
# `hermes_callback`). Quando vazio, validação é desabilitada (PoC/dev).
|
||||
#
|
||||
# Multi-tenant: o cliente MCP pode mandar contexto (conversation_id,
|
||||
# inbox_id, account_id) num campo de extensão chamado `_captain_context`
|
||||
# dentro de `params` do JSON-RPC. Tools que precisam (add_label etc) leem
|
||||
# esse contexto pra resolver a conversa correta.
|
||||
class Webhooks::Captain::McpController < ApplicationController
|
||||
skip_before_action :verify_authenticity_token, raise: false
|
||||
before_action :verify_signature
|
||||
|
||||
def process_payload
|
||||
request_body = parse_request_body
|
||||
return head :bad_request if request_body.blank?
|
||||
|
||||
response = Captain::Mcp::Server.handle(
|
||||
request_body,
|
||||
context: extract_context(request_body)
|
||||
)
|
||||
|
||||
return head :ok if response.nil? # MCP notifications
|
||||
|
||||
render json: response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[Captain::Mcp] error: #{e.class}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n")
|
||||
render json: { jsonrpc: '2.0', error: { code: -32_603, message: 'Internal error' } }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_request_body
|
||||
JSON.parse(request.raw_post)
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def verify_signature
|
||||
secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil)
|
||||
return true if secret.blank?
|
||||
|
||||
signature = request.headers['X-Hub-Signature-256'].to_s
|
||||
return head :unauthorized if signature.blank?
|
||||
|
||||
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
|
||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Cliente MCP pode mandar contexto multi-tenant em params._captain_context.
|
||||
# Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation
|
||||
# é (já que MCP em si é stateless entre client/server).
|
||||
def extract_context(request_body)
|
||||
params = request_body['params'] || {}
|
||||
ctx = params['_captain_context'] || {}
|
||||
return {} unless ctx.is_a?(Hash)
|
||||
|
||||
ctx.symbolize_keys
|
||||
end
|
||||
end
|
||||
@ -638,6 +638,7 @@ Rails.application.routes.draw do
|
||||
post 'webhooks/shopify', to: 'webhooks/shopify#events'
|
||||
post 'webhooks/wuzapi/:inbox_id', to: 'webhooks/wuzapi#process_payload'
|
||||
post 'webhooks/captain/hermes_callback', to: 'webhooks/captain/hermes_callback#process_payload'
|
||||
post 'webhooks/captain/mcp', to: 'webhooks/captain/mcp#process_payload'
|
||||
|
||||
namespace :twitter do
|
||||
resource :callback, only: [:show]
|
||||
|
||||
96
enterprise/app/services/captain/mcp/server.rb
Normal file
96
enterprise/app/services/captain/mcp/server.rb
Normal file
@ -0,0 +1,96 @@
|
||||
# Servidor MCP (Model Context Protocol) HTTP do Captain.
|
||||
#
|
||||
# Implementa subset suficiente da spec MCP pra Hermes Agent consumir como
|
||||
# cliente via `hermes mcp add captain-tools --url <url>`. Métodos
|
||||
# implementados:
|
||||
# - initialize — handshake (cliente apresenta info, server responde
|
||||
# capabilities + serverInfo)
|
||||
# - tools/list — devolve a lista de tools registradas
|
||||
# - tools/call — executa uma tool específica e devolve o resultado
|
||||
# - notifications/initialized — no-op (cliente confirma que está pronto)
|
||||
# - ping — no-op (health check)
|
||||
#
|
||||
# Não suporta SSE/streaming ainda — modo POST/JSON síncrono basta pro
|
||||
# caso de uso atual (tools que retornam rápido como add_label, faq_lookup).
|
||||
#
|
||||
# Auth/segurança ficam no controller (HMAC), aqui só roteia.
|
||||
class Captain::Mcp::Server
|
||||
PROTOCOL_VERSION = '2024-11-05'.freeze
|
||||
SERVER_NAME = 'captain-mcp'.freeze
|
||||
SERVER_VERSION = '0.1.0'.freeze
|
||||
|
||||
class << self
|
||||
def handle(request, context: {})
|
||||
new(context: context).handle(request)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(context: {})
|
||||
@context = context || {}
|
||||
end
|
||||
|
||||
def handle(request)
|
||||
rid = request['id']
|
||||
method = request['method'].to_s
|
||||
params = request['params'] || {}
|
||||
|
||||
dispatch(method, rid, params)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Captain::Mcp::Server] error handling #{method}: #{e.class}: #{e.message}")
|
||||
error_response(rid, -32_603, "Internal error: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch(method, rid, params)
|
||||
case method
|
||||
when 'initialize' then respond(rid, initialize_result(params))
|
||||
when 'tools/list' then respond(rid, { tools: Captain::Mcp::ToolRegistry.descriptors })
|
||||
when 'tools/call' then respond(rid, tools_call(params))
|
||||
when 'ping' then respond(rid, {})
|
||||
when 'notifications/initialized', 'notifications/cancelled' then nil
|
||||
else
|
||||
error_response(rid, -32_601, "Method not found: #{method}")
|
||||
end
|
||||
end
|
||||
|
||||
attr_reader :context
|
||||
|
||||
def initialize_result(_params)
|
||||
{
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {
|
||||
tools: { listChanged: false }
|
||||
},
|
||||
serverInfo: {
|
||||
name: SERVER_NAME,
|
||||
version: SERVER_VERSION
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def tools_call(params)
|
||||
name = params['name'].to_s
|
||||
args = params['arguments'] || {}
|
||||
Captain::Mcp::ToolRegistry.call(name, args, context: context)
|
||||
end
|
||||
|
||||
def respond(id, result)
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: id,
|
||||
result: result
|
||||
}
|
||||
end
|
||||
|
||||
def error_response(id, code, message)
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: id,
|
||||
error: {
|
||||
code: code,
|
||||
message: message
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
35
enterprise/app/services/captain/mcp/tool_registry.rb
Normal file
35
enterprise/app/services/captain/mcp/tool_registry.rb
Normal file
@ -0,0 +1,35 @@
|
||||
# Registry centralizado das tools MCP do Captain.
|
||||
#
|
||||
# Adicionar uma tool nova = incluir a classe em TOOLS abaixo. Cada tool
|
||||
# herda de Captain::Mcp::Tools::BaseTool e responde a .to_mcp_descriptor
|
||||
# (pra `tools/list`) e #call(args, context:) (pra `tools/call`).
|
||||
#
|
||||
# Hermes consulta tools/list pra saber o que pode chamar e tools/call pra
|
||||
# executar. Toda tool aqui está disponível pra qualquer profile do Hermes
|
||||
# que se conecte ao MCP server do Captain via `hermes mcp add`.
|
||||
class Captain::Mcp::ToolRegistry
|
||||
TOOLS = [
|
||||
Captain::Mcp::Tools::AddLabelTool,
|
||||
Captain::Mcp::Tools::FaqLookupTool
|
||||
# Captain::Mcp::Tools::GeneratePixTool — TODO depois MCP base validar
|
||||
# Captain::Mcp::Tools::SendSuiteImagesTool — TODO depois MCP base validar
|
||||
# Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro
|
||||
].freeze
|
||||
|
||||
class << self
|
||||
def descriptors
|
||||
TOOLS.map(&:to_mcp_descriptor)
|
||||
end
|
||||
|
||||
def find(name)
|
||||
TOOLS.find { |klass| klass.name == name.to_s }
|
||||
end
|
||||
|
||||
def call(name, args, context:)
|
||||
klass = find(name)
|
||||
raise ArgumentError, "Tool não registrada: #{name}" if klass.nil?
|
||||
|
||||
klass.new.call(args || {}, context: context || {})
|
||||
end
|
||||
end
|
||||
end
|
||||
57
enterprise/app/services/captain/mcp/tools/add_label_tool.rb
Normal file
57
enterprise/app/services/captain/mcp/tools/add_label_tool.rb
Normal file
@ -0,0 +1,57 @@
|
||||
# Tool MCP: adiciona uma etiqueta na conversation atual.
|
||||
#
|
||||
# Caso de uso: Hermes detecta cliente recorrente / VIP / situação especial
|
||||
# e quer marcar a conversa pro time humano filtrar depois.
|
||||
#
|
||||
# Exemplos de uso pelo LLM:
|
||||
# - "marca como cliente_recorrente"
|
||||
# - "etiqueta como pedido_desconto"
|
||||
class Captain::Mcp::Tools::AddLabelTool < Captain::Mcp::Tools::BaseTool
|
||||
class << self
|
||||
def name
|
||||
'add_label'
|
||||
end
|
||||
|
||||
def description
|
||||
'Adiciona uma etiqueta (label) à conversa atual do cliente. ' \
|
||||
'Use pra marcar contexto importante: cliente_recorrente, pedido_desconto, ' \
|
||||
'reclamacao, vip, etc. A etiqueta deve ser snake_case curto.'
|
||||
end
|
||||
|
||||
def input_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'Nome da etiqueta em snake_case (ex: "cliente_recorrente").'
|
||||
}
|
||||
},
|
||||
required: ['label']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def call(args, context:)
|
||||
label = args['label'].to_s.strip
|
||||
return error_response('Argumento "label" é obrigatório.') if label.blank?
|
||||
|
||||
conversation = resolve_conversation(context)
|
||||
return error_response('Conversation atual não encontrada no contexto.') if conversation.blank?
|
||||
|
||||
conversation.add_labels([label])
|
||||
text_response("Etiqueta '#{label}' adicionada à conversa #{conversation.display_id}.")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Captain::Mcp::AddLabelTool] error: #{e.class}: #{e.message}")
|
||||
error_response("Falha ao adicionar etiqueta: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_conversation(context)
|
||||
conv_id = context[:conversation_internal_id] || context[:conversation_id]
|
||||
return nil if conv_id.blank?
|
||||
|
||||
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
|
||||
end
|
||||
end
|
||||
56
enterprise/app/services/captain/mcp/tools/base_tool.rb
Normal file
56
enterprise/app/services/captain/mcp/tools/base_tool.rb
Normal file
@ -0,0 +1,56 @@
|
||||
# Interface base pras tools MCP do Captain.
|
||||
#
|
||||
# Cada tool concreta herda desta classe e implementa:
|
||||
# - .name — identificador (snake_case)
|
||||
# - .description — texto pro LLM decidir quando chamar
|
||||
# - .input_schema — JSON Schema (Draft 2020-12) dos argumentos
|
||||
# - #call(args, context:) — execução real
|
||||
#
|
||||
# context é um hash com metadata da invocação (ex: conversation_id,
|
||||
# inbox_id, account_id) extraído do request MCP. Tools usam isso pra
|
||||
# resolver entidades do Captain (Conversation, Inbox, etc).
|
||||
class Captain::Mcp::Tools::BaseTool
|
||||
class ExecutionError < StandardError; end
|
||||
|
||||
class << self
|
||||
def name
|
||||
raise NotImplementedError, "#{self} must implement .name"
|
||||
end
|
||||
|
||||
def description
|
||||
raise NotImplementedError, "#{self} must implement .description"
|
||||
end
|
||||
|
||||
def input_schema
|
||||
raise NotImplementedError, "#{self} must implement .input_schema"
|
||||
end
|
||||
|
||||
def to_mcp_descriptor
|
||||
{
|
||||
name: name,
|
||||
description: description,
|
||||
inputSchema: input_schema
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def call(_args, context:)
|
||||
raise NotImplementedError, "#{self.class} must implement #call"
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def text_response(text)
|
||||
{
|
||||
content: [{ type: 'text', text: text.to_s }],
|
||||
isError: false
|
||||
}
|
||||
end
|
||||
|
||||
def error_response(message)
|
||||
{
|
||||
content: [{ type: 'text', text: message.to_s }],
|
||||
isError: true
|
||||
}
|
||||
end
|
||||
end
|
||||
76
enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb
Normal file
76
enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb
Normal file
@ -0,0 +1,76 @@
|
||||
# Tool MCP: busca semântica em FAQs/documentação aprovada pelas gerentes.
|
||||
#
|
||||
# Caso de uso típico: cliente pergunta algo que NÃO está na skill estruturada
|
||||
# do Hermes (ex: aceita pet, formas de pagamento alternativo, política de
|
||||
# alguma situação específica). Em vez de inventar, Hermes chama esta tool
|
||||
# e responde com base no FAQ atualizado em tempo real pelo Captain UI.
|
||||
#
|
||||
# Reaproveita Captain::Tools::SearchReplyDocumentationService — exatamente
|
||||
# o mesmo serviço que o orquestrador interno do Captain usava antes,
|
||||
# garantindo que Hermes vê os mesmos FAQs que o caminho legado veria.
|
||||
class Captain::Mcp::Tools::FaqLookupTool < Captain::Mcp::Tools::BaseTool
|
||||
class << self
|
||||
def name
|
||||
'faq_lookup'
|
||||
end
|
||||
|
||||
def description
|
||||
'Busca semântica em FAQs/documentação aprovada pelas gerentes do hotel. ' \
|
||||
'Use quando o cliente perguntar algo que NÃO está na sua skill ' \
|
||||
'(ex: política de pets, horários especiais, convênios, regras pontuais). ' \
|
||||
'Retorna até 5 perguntas/respostas mais próximas semanticamente da query. ' \
|
||||
'Se não encontrar nada relevante, prefira transferir pro humano em vez ' \
|
||||
'de inventar.'
|
||||
end
|
||||
|
||||
def input_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Pergunta ou tema a buscar em linguagem natural ' \
|
||||
'(ex: aceitam pets, estacionamento coberto, ' \
|
||||
'forma de pagamento sem ser Pix).'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def call(args, context:)
|
||||
query = args['query'].to_s.strip
|
||||
return error_response('Argumento "query" é obrigatório.') if query.blank?
|
||||
|
||||
account = resolve_account(context)
|
||||
return error_response('Account não encontrada no contexto MCP.') if account.blank?
|
||||
|
||||
assistant = resolve_assistant(context, account)
|
||||
result = ::Captain::Tools::SearchReplyDocumentationService.new(
|
||||
account: account,
|
||||
assistant: assistant
|
||||
).execute(query: query)
|
||||
|
||||
text_response(result.presence || 'Nenhum FAQ relevante encontrado pra essa pergunta.')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[Captain::Mcp::FaqLookupTool] error: #{e.class}: #{e.message}")
|
||||
error_response("Falha na busca de FAQ: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_account(context)
|
||||
account_id = context[:account_id]
|
||||
return nil if account_id.blank?
|
||||
|
||||
Account.find_by(id: account_id)
|
||||
end
|
||||
|
||||
def resolve_assistant(context, account)
|
||||
assistant_id = context[:assistant_id]
|
||||
return nil if assistant_id.blank?
|
||||
|
||||
account.captain_assistants.find_by(id: assistant_id)
|
||||
end
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user