Add MCP server endpoint for external AI assistants (#1051)

* 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
This commit is contained in:
LPW
2026-02-23 09:13:15 -05:00
committed by GitHub
parent 111d6839e0
commit 17e9bb8fbf
6 changed files with 734 additions and 9 deletions

View File

@@ -0,0 +1,150 @@
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

View File

@@ -20,6 +20,18 @@ module Assistant
REGISTRY.keys
end
def function_classes
[
Function::GetTransactions,
Function::GetAccounts,
Function::GetHoldings,
Function::GetBalanceSheet,
Function::GetIncomeStatement,
Function::ImportBankStatement,
Function::SearchFamilyFiles
]
end
private
def implementation_for(chat)

View File

@@ -52,15 +52,7 @@ module Assistant::Configurable
end
def default_functions
[
Assistant::Function::GetTransactions,
Assistant::Function::GetAccounts,
Assistant::Function::GetHoldings,
Assistant::Function::GetBalanceSheet,
Assistant::Function::GetIncomeStatement,
Assistant::Function::ImportBankStatement,
Assistant::Function::SearchFamilyFiles
]
Assistant.function_classes
end
def default_instructions(preferred_currency, preferred_date_format)

View File

@@ -0,0 +1,275 @@
# ===========================================================================
# Example Docker Compose file with Pipelock agent security proxy
# ===========================================================================
#
# Purpose:
# --------
#
# This file adds Pipelock (https://github.com/luckyPipewrench/pipelock)
# as a security proxy for Sure, providing two layers of protection:
#
# 1. Forward proxy (port 8888) — routes outbound HTTPS through Pipelock
# for clients that respect the HTTPS_PROXY environment variable.
#
# 2. MCP reverse proxy (port 8889) — scans inbound MCP traffic from
# external AI assistants bidirectionally (DLP, prompt injection,
# tool poisoning, tool call policy).
#
# Forward proxy coverage:
# -----------------------
#
# Covered (Faraday-based clients respect HTTPS_PROXY automatically):
# - OpenAI API calls (ruby-openai gem)
# - Market data providers using Faraday
#
# NOT covered (these clients ignore HTTPS_PROXY):
# - SimpleFin (HTTParty / Net::HTTP)
# - Coinbase (HTTParty / Net::HTTP)
# - Any code using Net::HTTP or HTTParty directly
#
# For covered traffic, Pipelock provides:
# - Domain allowlisting (only known-good external APIs can be reached)
# - SSRF protection (blocks connections to private/internal IPs)
# - DLP scanning on connection targets (detects exfiltration patterns)
# - Rate limiting per domain
# - Structured JSON audit logging of all outbound connections
#
# MCP reverse proxy coverage:
# ---------------------------
#
# External AI assistants connect to Pipelock on port 8889 instead of
# directly to Sure's /mcp endpoint. Pipelock scans all traffic:
#
# Request scanning (client → Sure):
# - DLP detection (blocks credential/secret leakage in tool arguments)
# - Prompt injection detection in tool call parameters
# - Tool call policy enforcement (blocks dangerous operations)
#
# Response scanning (Sure → client):
# - Prompt injection detection in tool response content
# - Tool poisoning / drift detection (tool definitions changing)
#
# The MCP endpoint on Sure (port 3000/mcp) should NOT be exposed directly
# to the internet. Route all external MCP traffic through Pipelock.
#
# Limitations:
# ------------
#
# HTTPS_PROXY is cooperative. Docker Compose has no egress network policy,
# so any code path that doesn't check the env var can connect directly.
# For hard enforcement, deploy with network-level controls that deny all
# egress except through the proxy. Example for Kubernetes:
#
# # NetworkPolicy: deny all egress, allow only proxy + DNS
# egress:
# - to:
# - podSelector:
# matchLabels:
# app: pipelock
# ports:
# - port: 8888
# - ports:
# - port: 53
# protocol: UDP
#
# Monitoring:
# -----------
#
# Pipelock logs every connection and MCP request as structured JSON to stdout.
# View logs with: docker compose logs pipelock
#
# Forward proxy endpoints (port 8888):
# http://localhost:8888/health - liveness check
# http://localhost:8888/metrics - Prometheus metrics
# http://localhost:8888/stats - JSON summary
#
# More info: https://github.com/luckyPipewrench/pipelock
#
# Setup:
# ------
#
# 1. Copy this file to compose.yml (or use -f flag)
# 2. Set your environment variables (OPENAI_ACCESS_TOKEN, MCP_API_TOKEN, etc.)
# 3. docker compose up
#
# Pipelock runs both proxies in a single container:
# - Port 8888: forward proxy for outbound HTTPS (internal only)
# - Port 8889: MCP reverse proxy for external AI assistants
#
# External AI clients connect to http://<host>:8889 as their MCP endpoint.
# Pipelock scans the traffic and forwards clean requests to Sure's /mcp.
#
# Customization:
# --------------
#
# Requires Pipelock with MCP HTTP listener support (--mcp-listen flag).
# See: https://github.com/luckyPipewrench/pipelock/releases
#
# Edit the pipelock command to change the mode:
# --mode strict Block unknown domains (recommended for production)
# --mode balanced Warn on unknown domains, block known-bad (default)
# --mode audit Log everything, block nothing (for evaluation)
#
# For a custom config, mount a file and use --config instead of --mode:
# volumes:
# - ./config/pipelock.yml:/etc/pipelock/config.yml:ro
# command: ["run", "--config", "/etc/pipelock/config.yml",
# "--mcp-listen", "0.0.0.0:8889", "--mcp-upstream", "http://web:3000/mcp"]
#
x-db-env: &db_env
POSTGRES_USER: ${POSTGRES_USER:-sure_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sure_password}
POSTGRES_DB: ${POSTGRES_DB:-sure_production}
x-rails-env: &rails_env
<<: *db_env
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-a7523c3d0ae56415046ad8abae168d71074a79534a7062258f8d1d51ac2f76d3c3bc86d86b6b0b307df30d9a6a90a2066a3fa9e67c5e6f374dbd7dd4e0778e13}
SELF_HOSTED: "true"
RAILS_FORCE_SSL: "false"
RAILS_ASSUME_SSL: "false"
DB_HOST: db
DB_PORT: 5432
REDIS_URL: redis://redis:6379/1
# NOTE: enabling OpenAI will incur costs when you use AI-related features in the app (chat, rules). Make sure you have set appropriate spend limits on your account before adding this.
OPENAI_ACCESS_TOKEN: ${OPENAI_ACCESS_TOKEN}
# MCP server endpoint — enables /mcp for external AI assistants (e.g. Claude, GPT).
# Set both values to activate. MCP_USER_EMAIL must match an existing user's email.
# External AI clients connect via Pipelock (port 8889), not directly to /mcp.
MCP_API_TOKEN: ${MCP_API_TOKEN:-}
MCP_USER_EMAIL: ${MCP_USER_EMAIL:-}
# Route outbound HTTPS through Pipelock for clients that respect HTTPS_PROXY.
# See "Forward proxy coverage" section above for which clients are covered.
HTTPS_PROXY: "http://pipelock:8888"
HTTP_PROXY: "http://pipelock:8888"
# Skip proxy for internal Docker network services
NO_PROXY: "db,redis,pipelock,localhost,127.0.0.1"
services:
pipelock:
image: ghcr.io/luckypipewrench/pipelock:latest
container_name: pipelock
hostname: pipelock
restart: unless-stopped
command:
- "run"
- "--listen"
- "0.0.0.0:8888"
- "--mode"
- "balanced"
- "--mcp-listen"
- "0.0.0.0:8889"
- "--mcp-upstream"
- "http://web:3000/mcp"
ports:
# MCP reverse proxy — external AI assistants connect here
- "${MCP_PROXY_PORT:-8889}:8889"
# Uncomment to expose forward proxy endpoints (/health, /metrics, /stats):
# - "8888:8888"
healthcheck:
test: ["CMD", "/pipelock", "healthcheck", "--addr", "127.0.0.1:8888"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
networks:
- sure_net
web:
image: ghcr.io/we-promise/sure:stable
volumes:
- app-storage:/rails/storage
ports:
# Web UI for browser access. Note: /mcp is also reachable on this port,
# bypassing Pipelock's MCP scanning (auth token is still required).
# For hardened deployments, use `expose: [3000]` instead and front
# the web UI with a separate reverse proxy.
- ${PORT:-3000}:3000
restart: unless-stopped
environment:
<<: *rails_env
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
pipelock:
condition: service_healthy
networks:
- sure_net
worker:
image: ghcr.io/we-promise/sure:stable
command: bundle exec sidekiq
volumes:
- app-storage:/rails/storage
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
pipelock:
condition: service_healthy
environment:
<<: *rails_env
networks:
- sure_net
db:
image: postgres:16
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
<<: *db_env
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" ]
interval: 5s
timeout: 5s
retries: 5
networks:
- sure_net
backup:
profiles:
- backup
image: prodrigestivill/postgres-backup-local
restart: unless-stopped
volumes:
- /opt/sure-data/backups:/backups # Change this path to your desired backup location on the host machine
environment:
- POSTGRES_HOST=db
- POSTGRES_DB=${POSTGRES_DB:-sure_production}
- POSTGRES_USER=${POSTGRES_USER:-sure_user}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-sure_password}
- SCHEDULE=@daily # Runs once a day at midnight
- BACKUP_KEEP_DAYS=7 # Keeps the last 7 days of backups
- BACKUP_KEEP_WEEKS=4 # Keeps 4 weekly backups
- BACKUP_KEEP_MONTHS=6 # Keeps 6 monthly backups
depends_on:
- db
networks:
- sure_net
redis:
image: redis:latest
restart: unless-stopped
volumes:
- redis-data:/data
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 5s
timeout: 5s
retries: 5
networks:
- sure_net
volumes:
app-storage:
postgres-data:
redis-data:
networks:
sure_net:
driver: bridge

View File

@@ -472,6 +472,9 @@ Rails.application.routes.draw do
get "redis-configuration-error", to: "pages#redis_configuration_error"
# MCP server endpoint for external AI assistants (JSON-RPC 2.0)
post "mcp", to: "mcp#handle"
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check

View File

@@ -0,0 +1,293 @@
require "test_helper"
class McpControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@token = "test-mcp-token-#{SecureRandom.hex(8)}"
end
# -- Authentication --
test "returns 401 without authorization header" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("initialize").to_json,
headers: { "Content-Type" => "application/json" }
assert_response :unauthorized
assert_equal "unauthorized", JSON.parse(response.body)["error"]
end
end
test "returns 401 with wrong token" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("initialize").to_json,
headers: mcp_headers("wrong-token")
assert_response :unauthorized
end
end
test "returns 503 when MCP_API_TOKEN is not set" do
with_env_overrides("MCP_USER_EMAIL" => @user.email) do
post "/mcp", params: jsonrpc_request("initialize").to_json,
headers: mcp_headers(@token)
assert_response :service_unavailable
assert_includes JSON.parse(response.body)["error"], "not configured"
end
end
test "returns 503 when MCP_USER_EMAIL is not set" do
with_env_overrides("MCP_API_TOKEN" => @token) do
post "/mcp", params: jsonrpc_request("initialize").to_json,
headers: mcp_headers(@token)
assert_response :service_unavailable
assert_includes JSON.parse(response.body)["error"], "user not configured"
end
end
test "returns 503 when MCP_USER_EMAIL does not match any user" do
with_env_overrides("MCP_API_TOKEN" => @token, "MCP_USER_EMAIL" => "nonexistent@example.com") do
post "/mcp", params: jsonrpc_request("initialize").to_json,
headers: mcp_headers(@token)
assert_response :service_unavailable
end
end
# -- JSON-RPC protocol --
test "returns parse error for invalid JSON" do
with_mcp_env do
# Send with text/plain to bypass Rails JSON middleware parsing
post "/mcp", params: "not valid json",
headers: mcp_headers(@token).merge("Content-Type" => "text/plain")
assert_response :ok
body = JSON.parse(response.body)
assert_equal(-32700, body["error"]["code"])
assert_includes body["error"]["message"], "Parse error"
end
end
test "returns invalid request for missing jsonrpc version" do
with_mcp_env do
post "/mcp", params: { method: "initialize" }.to_json,
headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
assert_equal(-32600, body["error"]["code"])
end
end
test "returns method not found for unknown method with request id preserved" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("unknown/method", {}, id: 77).to_json,
headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
assert_equal(-32601, body["error"]["code"])
assert_includes body["error"]["message"], "unknown/method"
assert_equal 77, body["id"], "Error response must echo the request id"
end
end
# -- Notifications (requests without id) --
test "notifications receive no response body" do
with_mcp_env do
post "/mcp", params: jsonrpc_notification("notifications/initialized").to_json,
headers: mcp_headers(@token)
assert_response :no_content
assert response.body.blank?, "Notification must not produce a response body"
end
end
test "tools/call sent as notification does not execute" do
with_mcp_env do
post "/mcp", params: jsonrpc_notification("tools/call", { name: "get_balance_sheet", arguments: {} }).to_json,
headers: mcp_headers(@token)
assert_response :no_content
assert response.body.blank?, "Notification-style tools/call must not execute or respond"
end
end
test "unknown notification method still returns no content" do
with_mcp_env do
post "/mcp", params: jsonrpc_notification("notifications/unknown").to_json,
headers: mcp_headers(@token)
assert_response :no_content
assert response.body.blank?
end
end
# -- initialize --
test "initialize returns server info and capabilities" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("initialize", { protocolVersion: "2025-03-26" }).to_json,
headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
result = body["result"]
assert_equal "2.0", body["jsonrpc"]
assert_equal 1, body["id"]
assert_equal "2025-03-26", result["protocolVersion"]
assert_equal "sure", result["serverInfo"]["name"]
assert result["capabilities"].key?("tools")
end
end
# -- tools/list --
test "tools/list returns all assistant function tools" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("tools/list").to_json,
headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
tools = body["result"]["tools"]
assert_kind_of Array, tools
assert_equal Assistant.function_classes.size, tools.size
tool_names = tools.map { |t| t["name"] }
assert_includes tool_names, "get_transactions"
assert_includes tool_names, "get_accounts"
assert_includes tool_names, "get_holdings"
assert_includes tool_names, "get_balance_sheet"
assert_includes tool_names, "get_income_statement"
# Each tool has required fields
tools.each do |tool|
assert tool["name"].present?, "Tool missing name"
assert tool["description"].present?, "Tool #{tool['name']} missing description"
assert tool["inputSchema"].present?, "Tool #{tool['name']} missing inputSchema"
assert_equal "object", tool["inputSchema"]["type"]
end
end
end
# -- tools/call --
test "tools/call returns error for unknown tool with request id preserved" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("tools/call", { name: "nonexistent_tool", arguments: {} }, id: 99).to_json,
headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
assert_equal(-32602, body["error"]["code"])
assert_includes body["error"]["message"], "nonexistent_tool"
assert_equal 99, body["id"], "Error response must echo the request id"
end
end
test "tools/call executes get_balance_sheet" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("tools/call", {
name: "get_balance_sheet",
arguments: {}
}).to_json, headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
result = body["result"]
assert_kind_of Array, result["content"]
assert_equal "text", result["content"][0]["type"]
# The text field should be valid JSON
inner = JSON.parse(result["content"][0]["text"])
assert inner.key?("net_worth") || inner.key?("error"),
"Expected balance sheet data or error, got: #{inner.keys}"
end
end
test "tools/call wraps function errors as isError response" do
with_mcp_env do
# Force a function error by stubbing
Assistant::Function::GetBalanceSheet.any_instance.stubs(:call).raises(StandardError, "test error")
post "/mcp", params: jsonrpc_request("tools/call", {
name: "get_balance_sheet",
arguments: {}
}).to_json, headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
result = body["result"]
assert result["isError"], "Expected isError to be true"
inner = JSON.parse(result["content"][0]["text"])
assert_equal "test error", inner["error"]
end
end
# -- Session isolation --
test "does not persist sessions or inherit impersonation state" do
with_mcp_env do
assert_no_difference "Session.count" do
post "/mcp", params: jsonrpc_request("initialize").to_json,
headers: mcp_headers(@token)
end
assert_response :ok
end
end
# -- JSON-RPC id preservation --
test "preserves request id in successful response" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("initialize", {}, id: 42).to_json,
headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
assert_equal 42, body["id"]
end
end
test "preserves string request id" do
with_mcp_env do
post "/mcp", params: jsonrpc_request("initialize", {}, id: "req-abc-123").to_json,
headers: mcp_headers(@token)
assert_response :ok
body = JSON.parse(response.body)
assert_equal "req-abc-123", body["id"]
end
end
private
def with_mcp_env(&block)
with_env_overrides("MCP_API_TOKEN" => @token, "MCP_USER_EMAIL" => @user.email, &block)
end
def mcp_headers(token)
{
"Content-Type" => "application/json",
"Authorization" => "Bearer #{token}"
}
end
def jsonrpc_request(method, params = {}, id: 1)
{ jsonrpc: "2.0", id: id, method: method, params: params }
end
def jsonrpc_notification(method, params = {})
{ jsonrpc: "2.0", method: method, params: params }
end
end