mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* feat(bills): assistant and MCP tools for bills Last of three chunks carved out of #3083, stacked on the UI bundle. Exposes bills to the builtin assistant and to MCP clients. Everything here is gated behind preview features, so the tools are absent from tools/list until a user opts in. Seven tools: - get_bills, get_bill_details and get_paycheck_plan for reads - get_bill_audit, a deterministic review that surfaces likely duplicates, price changes, trials about to convert, upcoming renewals and long-overdue bills - create_bill, update_bill and record_bill_payment for writes Shared argument parsing, permission checks and error shapes live in BillsSupport, so every tool answers with the same {error, hint} contract the existing tools use, and a bad argument never aborts the turn. The write tools mutate financial records on a model's say-so, so they refuse rather than guess: a payment cannot exceed what its cycle still owes, a repeated settle will not quietly close next month, an unrecognized frequency is an error instead of a silent monthly default, and non-finite or negative amounts are rejected before they reach the database. The read tools say what they filtered. An empty result names the statuses that do hold matches, the paycheck plan discloses the unconfirmed series it excluded from spending headroom, and history and price-change windows report their real totals rather than letting a caller sum a truncated list. A not-found no longer returns the scoped relation's SQL, which handed any MCP client the access-control schema for the cost of a guessed id. The in-page AI helpers are not here. Smart fill and smart configuration are buttons on the bills pages, so they ship with the UI bundle along with the provider-side suggester they call. Suite 7,854 runs, 0 failures. Rubocop clean, eager loading verified. * Address the ready-review round * Reject an out-of-range audit lookback out loud * Speak the cycle remainder guard through the allocator locale
245 lines
7.5 KiB
Ruby
245 lines
7.5 KiB
Ruby
class McpController < ApplicationController
|
|
include OauthBase
|
|
|
|
PROTOCOL_VERSION = "2025-06-18"
|
|
SUPPORTED_PROTOCOL_VERSIONS = [ "2025-03-26", PROTOCOL_VERSION ].freeze
|
|
MCP_SESSION_TTL = 1.day
|
|
|
|
# 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!
|
|
after_action :set_mcp_response_headers
|
|
|
|
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)
|
|
return unless prepare_mcp_request_context(request_id, method, params)
|
|
|
|
case method
|
|
when "initialize"
|
|
handle_initialize(params)
|
|
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(params)
|
|
@mcp_protocol_version = negotiated_protocol_version(params)
|
|
@mcp_session_id = SecureRandom.uuid
|
|
Rails.cache.write(mcp_session_cache_key(@mcp_session_id), mcp_user.id, expires_in: MCP_SESSION_TTL)
|
|
|
|
{
|
|
protocolVersion: @mcp_protocol_version,
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "sure", version: "1.0" },
|
|
sessionId: @mcp_session_id
|
|
}
|
|
end
|
|
|
|
def handle_tools_list
|
|
tools = Assistant.function_classes(mcp_user).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") || {}
|
|
|
|
# Scoped to the authenticated user so a tool hidden from tools/list is not
|
|
# callable by guessing its name — a non-preview caller gets the same
|
|
# "Unknown tool" response as for a name that does not exist.
|
|
fn_class = Assistant.function_classes(mcp_user).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.class}: #{e.message}"
|
|
|
|
# Whatever the tool raised, its message was written for a log, not for an
|
|
# external client: a RecordNotFound carries the access-control SQL, and a
|
|
# PG range error carries the column definition. The full text stays in the
|
|
# log above, where an operator can read it.
|
|
{ content: [ { type: "text", text: { error: "The tool failed to run", tool: name }.to_json } ], isError: true }
|
|
end
|
|
|
|
def authenticate_mcp_token!
|
|
auth_header = request.authorization.to_s
|
|
token = auth_header[/\ABearer\s+(.+)\z/i, 1]&.strip&.presence # pipelock:ignore
|
|
|
|
return if token.present? && authenticate_via_doorkeeper(token)
|
|
return if token.present? && authenticate_via_env_token(token)
|
|
|
|
render_mcp_unauthorized
|
|
end
|
|
|
|
def authenticate_via_doorkeeper(token)
|
|
access_token = Doorkeeper::AccessToken.by_token(token)
|
|
return false unless access_token&.accessible?
|
|
return false unless access_token.scopes.include?("read_write")
|
|
|
|
user = User.find_by(id: access_token.resource_owner_id)
|
|
return false unless user&.active?
|
|
|
|
setup_mcp_session(user)
|
|
true
|
|
end
|
|
|
|
def authenticate_via_env_token(token)
|
|
expected = ENV["MCP_API_TOKEN"]
|
|
return false unless expected.present?
|
|
return false unless ActiveSupport::SecurityUtils.secure_compare(
|
|
OpenSSL::Digest::SHA256.hexdigest(token),
|
|
OpenSSL::Digest::SHA256.hexdigest(expected)
|
|
)
|
|
|
|
user = User.find_by(email: ENV["MCP_USER_EMAIL"])
|
|
|
|
unless user
|
|
Rails.logger.warn "[MCP] MCP_USER_EMAIL does not match any user — check environment configuration"
|
|
return false
|
|
end
|
|
|
|
setup_mcp_session(user)
|
|
true
|
|
end
|
|
|
|
def setup_mcp_session(user)
|
|
@mcp_user = user
|
|
# 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 = user.sessions.build(
|
|
user_agent: request.user_agent,
|
|
ip_address: request.ip
|
|
)
|
|
end
|
|
|
|
def mcp_user
|
|
@mcp_user
|
|
end
|
|
|
|
def prepare_mcp_request_context(request_id, method, params)
|
|
return true if method == "initialize"
|
|
|
|
@mcp_protocol_version = mcp_request_header("Mcp-Protocol-Version").presence || PROTOCOL_VERSION
|
|
|
|
unless SUPPORTED_PROTOCOL_VERSIONS.include?(@mcp_protocol_version)
|
|
render_jsonrpc_error(
|
|
request_id,
|
|
-32600,
|
|
t("mcp.errors.unsupported_protocol_version", version: @mcp_protocol_version),
|
|
status: :bad_request
|
|
)
|
|
return false
|
|
end
|
|
|
|
session_id = mcp_request_header("Mcp-Session-Id").presence
|
|
return true unless session_id
|
|
|
|
unless Rails.cache.read(mcp_session_cache_key(session_id)) == mcp_user.id
|
|
render_jsonrpc_error(request_id, -32600, t("mcp.errors.invalid_session_id"), status: :not_found)
|
|
return false
|
|
end
|
|
|
|
@mcp_session_id = session_id
|
|
true
|
|
end
|
|
|
|
def negotiated_protocol_version(params)
|
|
requested_version = params&.dig("protocolVersion").presence || PROTOCOL_VERSION
|
|
return requested_version if SUPPORTED_PROTOCOL_VERSIONS.include?(requested_version)
|
|
|
|
PROTOCOL_VERSION
|
|
end
|
|
|
|
def mcp_session_cache_key(session_id)
|
|
"mcp:session:#{session_id}"
|
|
end
|
|
|
|
def mcp_request_header(name)
|
|
request.headers[name] || request.get_header("HTTP_#{name.upcase.tr('-', '_')}")
|
|
end
|
|
|
|
def render_mcp_unauthorized
|
|
response.set_header(
|
|
"WWW-Authenticate",
|
|
"Bearer resource_metadata=\"#{configured_base_url}/.well-known/oauth-protected-resource\""
|
|
)
|
|
render json: { error: "unauthorized" }, status: :unauthorized
|
|
end
|
|
|
|
def render_jsonrpc_error(id, code, message, status: :ok, data: nil)
|
|
error = { code: code, message: message }
|
|
error[:data] = data if data
|
|
|
|
render json: {
|
|
jsonrpc: "2.0",
|
|
id: id,
|
|
error: error
|
|
}, status: status
|
|
end
|
|
|
|
def set_mcp_response_headers
|
|
response.set_header("Mcp-Protocol-Version", @mcp_protocol_version || PROTOCOL_VERSION)
|
|
response.set_header("Mcp-Session-Id", @mcp_session_id) if @mcp_session_id.present?
|
|
end
|
|
end
|