mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 22:34:47 +00:00
* Add MCP server endpoint for external AI assistants Expose Sure's Assistant::Function tools via JSON-RPC 2.0 at POST /mcp, enabling external AI clients (Claude, GPT, etc.) to query financial data through the Model Context Protocol. - Bearer token auth via MCP_API_TOKEN / MCP_USER_EMAIL env vars - JSON-RPC 2.0 with proper id threading, notification handling (204) - Transient session (sessions.build) to prevent impersonation leaks - Centralize function_classes in Assistant module - Docker Compose example with Pipelock forward proxy - 18 integration tests with scoped env (ClimateControl) * Update compose for full Pipelock MCP reverse proxy integration Use Pipelock's --mcp-listen/--mcp-upstream flags (PR #127) to run bidirectional MCP scanning in the same container as the forward proxy. External AI clients connect to port 8889, Pipelock scans requests (DLP, injection, tool policy) and responses (injection, tool poisoning) before forwarding to Sure's /mcp endpoint. This supersedes the standalone compose in PR #1050. * Fix compose --preset→--mode, add port 3000 trust comment, notification test Review fixes: - pipelock run uses --mode not --preset (would prevent stack startup) - Document port 3000 exposes /mcp directly (auth still required) - Add version requirement note for Pipelock MCP listener support - Add test: tools/call sent as notification does not execute
151 lines
4.0 KiB
Ruby
151 lines
4.0 KiB
Ruby
class McpController < ApplicationController
|
|
PROTOCOL_VERSION = "2025-03-26"
|
|
|
|
# Skip session-based auth and CSRF — this is a token-authenticated API
|
|
skip_authentication
|
|
skip_before_action :verify_authenticity_token
|
|
skip_before_action :require_onboarding_and_upgrade
|
|
skip_before_action :set_default_chat
|
|
skip_before_action :detect_os
|
|
|
|
before_action :authenticate_mcp_token!
|
|
|
|
def handle
|
|
body = parse_request_body
|
|
return if performed?
|
|
|
|
unless valid_jsonrpc?(body)
|
|
render_jsonrpc_error(body&.dig("id"), -32600, "Invalid Request")
|
|
return
|
|
end
|
|
|
|
request_id = body["id"]
|
|
|
|
# JSON-RPC notifications omit the id field — server must not respond
|
|
unless body.key?("id")
|
|
return head(:no_content)
|
|
end
|
|
|
|
result = dispatch_jsonrpc(request_id, body["method"], body["params"])
|
|
return if performed?
|
|
|
|
render json: { jsonrpc: "2.0", id: request_id, result: result }
|
|
end
|
|
|
|
private
|
|
|
|
def parse_request_body
|
|
JSON.parse(request.raw_post)
|
|
rescue JSON::ParserError
|
|
render_jsonrpc_error(nil, -32700, "Parse error")
|
|
nil
|
|
end
|
|
|
|
def valid_jsonrpc?(body)
|
|
body.is_a?(Hash) && body["jsonrpc"] == "2.0" && body["method"].present?
|
|
end
|
|
|
|
def dispatch_jsonrpc(request_id, method, params)
|
|
case method
|
|
when "initialize"
|
|
handle_initialize
|
|
when "tools/list"
|
|
handle_tools_list
|
|
when "tools/call"
|
|
handle_tools_call(request_id, params)
|
|
else
|
|
render_jsonrpc_error(request_id, -32601, "Method not found: #{method}")
|
|
nil
|
|
end
|
|
end
|
|
|
|
def handle_initialize
|
|
{
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "sure", version: "1.0" }
|
|
}
|
|
end
|
|
|
|
def handle_tools_list
|
|
tools = Assistant.function_classes.map do |fn_class|
|
|
fn_instance = fn_class.new(mcp_user)
|
|
{
|
|
name: fn_instance.name,
|
|
description: fn_instance.description,
|
|
inputSchema: fn_instance.params_schema
|
|
}
|
|
end
|
|
|
|
{ tools: tools }
|
|
end
|
|
|
|
def handle_tools_call(request_id, params)
|
|
name = params&.dig("name")
|
|
arguments = params&.dig("arguments") || {}
|
|
|
|
fn_class = Assistant.function_classes.find { |fc| fc.name == name }
|
|
|
|
unless fn_class
|
|
render_jsonrpc_error(request_id, -32602, "Unknown tool: #{name}")
|
|
return nil
|
|
end
|
|
|
|
fn = fn_class.new(mcp_user)
|
|
result = fn.call(arguments)
|
|
|
|
{ content: [ { type: "text", text: result.to_json } ] }
|
|
rescue => e
|
|
Rails.logger.error "MCP tools/call error: #{e.message}"
|
|
{ content: [ { type: "text", text: { error: e.message }.to_json } ], isError: true }
|
|
end
|
|
|
|
def authenticate_mcp_token!
|
|
expected = ENV["MCP_API_TOKEN"]
|
|
|
|
unless expected.present?
|
|
render json: { error: "MCP endpoint not configured" }, status: :service_unavailable
|
|
return
|
|
end
|
|
|
|
token = request.headers["Authorization"]&.delete_prefix("Bearer ")&.strip
|
|
|
|
unless ActiveSupport::SecurityUtils.secure_compare(token.to_s, expected)
|
|
render json: { error: "unauthorized" }, status: :unauthorized
|
|
return
|
|
end
|
|
|
|
setup_mcp_user
|
|
end
|
|
|
|
def setup_mcp_user
|
|
email = ENV["MCP_USER_EMAIL"]
|
|
@mcp_user = User.find_by(email: email) if email.present?
|
|
|
|
unless @mcp_user
|
|
render json: { error: "MCP user not configured" }, status: :service_unavailable
|
|
return
|
|
end
|
|
|
|
# Build a fresh session to avoid inheriting impersonation state from
|
|
# existing sessions (Current.user resolves via active_impersonator_session
|
|
# first, which could leak another user's data into MCP tool calls).
|
|
Current.session = @mcp_user.sessions.build(
|
|
user_agent: request.user_agent,
|
|
ip_address: request.ip
|
|
)
|
|
end
|
|
|
|
def mcp_user
|
|
@mcp_user
|
|
end
|
|
|
|
def render_jsonrpc_error(id, code, message)
|
|
render json: {
|
|
jsonrpc: "2.0",
|
|
id: id,
|
|
error: { code: code, message: message }
|
|
}
|
|
end
|
|
end
|