iachat/enterprise/app/services/captain/mcp/server.rb
Rodribm10 23911ea878 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>
2026-05-01 15:32:38 -03:00

97 lines
2.8 KiB
Ruby

# 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