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:
Rodribm10 2026-05-01 15:32:38 -03:00
parent cd519a73c4
commit 23911ea878
7 changed files with 393 additions and 0 deletions

View 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

View File

@ -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]

View 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

View 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

View 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

View 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

View 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