diff --git a/app/controllers/webhooks/captain/mcp_controller.rb b/app/controllers/webhooks/captain/mcp_controller.rb new file mode 100644 index 000000000..3e6b18afc --- /dev/null +++ b/app/controllers/webhooks/captain/mcp_controller.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 680f1ca87..1fd35cb52 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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] diff --git a/enterprise/app/services/captain/mcp/server.rb b/enterprise/app/services/captain/mcp/server.rb new file mode 100644 index 000000000..b81882f58 --- /dev/null +++ b/enterprise/app/services/captain/mcp/server.rb @@ -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 `. 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 diff --git a/enterprise/app/services/captain/mcp/tool_registry.rb b/enterprise/app/services/captain/mcp/tool_registry.rb new file mode 100644 index 000000000..4922b63f1 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tool_registry.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/add_label_tool.rb b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb new file mode 100644 index 000000000..ee06b90e6 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/base_tool.rb b/enterprise/app/services/captain/mcp/tools/base_tool.rb new file mode 100644 index 000000000..2253c2ce0 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/base_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb b/enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb new file mode 100644 index 000000000..c03906433 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb @@ -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