User MCP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a per-user Model Context Protocol (MCP) server to Lifehub so users can connect AI clients to their own finance, life-tracking, gamification, and analytics data.
Architecture: Single Rack-mounted MCP server at POST /mcp/:token, Streamable HTTP transport, per-user tokens with per-domain permission toggles. All queries start from user.<association>. ~90 tool classes spread across 15 permission domains. Reuses existing Dashboard::DataAggregator and User::AnalyticsCalculator for analytics tools.
Tech Stack: Rails 8.1, mcp gem (~> 0.10.0), Minitest + fixtures, Pundit, Pagy, Tailwind + Hotwire (per AGENTS.md).
Spec: docs/superpowers/specs/2026-04-22-user-mcp-design.md
File Structure
Created
app/middleware/mcp/base_rack_app.rb
app/middleware/mcp/rack_app.rb
app/models/mcp/token.rb
app/models/mcp/activity_log.rb
app/models/mcp/user_context.rb
app/models/mcp/base_tool.rb
app/models/mcp/server_builder.rb
app/models/mcp/tools/concerns/formattable.rb
app/models/mcp/tools/concerns/listable.rb
app/models/mcp/tools/concerns/tool_metadata.rb
app/models/mcp/tools/accounts/{list,get,create,update}.rb
app/models/mcp/tools/expenses/{list,get,create,update}.rb
app/models/mcp/tools/debts/{list,get,create,update}.rb
app/models/mcp/tools/debt_payments/{list,get,create,update}.rb
app/models/mcp/tools/investments/{list,get,create,update}.rb
app/models/mcp/tools/goals/{list,get,create,update}.rb
app/models/mcp/tools/habits/{list,get,create,update}.rb
app/models/mcp/tools/habit_completions/{list,get,create,update}.rb
app/models/mcp/tools/sports/{list,get,create,update}.rb
app/models/mcp/tools/activity_logs/{list,get,create,update}.rb
app/models/mcp/tools/birthdays/{list,get,create,update}.rb
app/models/mcp/tools/market_lists/{list,get,create,update}.rb
app/models/mcp/tools/market_list_items/{list,get,create,update}.rb
app/models/mcp/tools/notes/{list,get,create,update}.rb
app/models/mcp/tools/countdowns/{list,get,create,update}.rb
app/models/mcp/tools/construction_projects/{list,get,create,update}.rb
app/models/mcp/tools/construction_phases/{list,get,create,update}.rb
app/models/mcp/tools/construction_expenses/{list,get,create,update}.rb
app/models/mcp/tools/todo_boards/{get,update}.rb
app/models/mcp/tools/todo_cards/{list,get,create,update}.rb
app/models/mcp/tools/gamification/{get_profile,list_xp_transactions,get_xp_transaction,list_user_achievements,get_user_achievement,list_challenges,get_challenge,list_challenge_participations,get_challenge_participation}.rb
app/models/mcp/tools/analytics/{get_finance_dashboard,get_analytics_summary,get_gamification_stats}.rb
app/controllers/mcp/base_controller.rb
app/controllers/mcp/settings_controller.rb
app/controllers/mcp/tokens_controller.rb
app/controllers/mcp/activity_controller.rb
app/policies/mcp/token_policy.rb
app/policies/mcp/activity_policy.rb
app/views/mcp/settings/show.html.erb
app/views/mcp/tokens/new.html.erb
app/views/mcp/tokens/edit.html.erb
app/views/mcp/tokens/_form.html.erb
app/views/mcp/tokens/_reveal.html.erb
app/views/mcp/activity/index.html.erb
db/migrate/YYYYMMDDHHMMSS_create_mcp_tokens.rb
db/migrate/YYYYMMDDHHMMSS_create_mcp_activity_logs.rb
test/models/mcp/token_test.rb
test/models/mcp/activity_log_test.rb
test/models/mcp/server_builder_test.rb
test/integration/mcp/rack_app_test.rb
test/integration/mcp/tools/<domain>_test.rb (one per domain — smoke-test all CRU actions)
test/system/mcp/tokens_test.rb
test/fixtures/mcp_tokens.yml
test/fixtures/mcp_activity_logs.yml
Modified
Gemfile
Gemfile.lock
config/routes.rb
Tool Archetypes
Every CRU tool file follows one of four archetypes. When Tasks 13 and 20–32 produce tool files, they instantiate these templates with the per-domain values specified in the task.
All tool files live in app/models/mcp/tools/<domain>/<action>.rb.
Archetype A — List
class Mcp::Tools::<DOMAIN>::List < Mcp::BaseTool
include Mcp::Tools::Concerns::Listable
tool_name "list_<domain>"
mcp_domain "<permission_key>"
mcp_action "read"
description "<one-line description>"
input_schema(
properties: {
page: { type: "integer", description: "Page number (default 1)" }
<filter properties as needed>
}
)
def self.call(page: 1, **filters)
scope = <user.association.includes(...).order(...)>
<apply filters>
records = paginate(scope, page: page)
format_collection(records, page: page) do |record|
"[ID: #{record.id}] #{<one-line summary>}"
end
end
end
Archetype B — Get
class Mcp::Tools::<DOMAIN>::Get < Mcp::BaseTool
tool_name "get_<singular>"
mcp_domain "<permission_key>"
mcp_action "read"
description "Fetch a single <singular> by ID"
input_schema(
properties: { id: { type: "integer", description: "<Singular> ID" } },
required: ["id"]
)
def self.call(id:)
record = <user.association>.find_by(id: id)
return not_found_response("<singular>") unless record
json_response(<attributes_hash>)
end
end
Archetype C — Create
class Mcp::Tools::<DOMAIN>::Create < Mcp::BaseTool
tool_name "create_<singular>"
mcp_domain "<permission_key>"
mcp_action "create"
description "Create a new <singular>"
input_schema(
properties: { <field schemas> },
required: <required field names as string array>
)
def self.call(**args)
record = <user.association>.create!(args.slice(<permitted keys>))
json_response(<attributes_hash>)
rescue ActiveRecord::RecordInvalid => error
text_response("Failed to create <singular>: #{error.record.errors.full_messages.join(", ")}")
end
end
Archetype D — Update
class Mcp::Tools::<DOMAIN>::Update < Mcp::BaseTool
tool_name "update_<singular>"
mcp_domain "<permission_key>"
mcp_action "update"
description "Update an existing <singular>"
input_schema(
properties: { id: { type: "integer" }, <updatable field schemas> },
required: ["id"]
)
def self.call(id:, **args)
record = <user.association>.find_by(id: id)
return not_found_response("<singular>") unless record
record.update!(args.slice(<permitted keys>))
json_response(<attributes_hash>)
rescue ActiveRecord::RecordInvalid => error
text_response("Failed to update <singular>: #{error.record.errors.full_messages.join(", ")}")
end
end
Attributes-hash convention
Each json_response(...) call returns the record's ID plus 4–8 readable attributes (not the whole row). Skip large JSON blobs (monthly_history, price_history, sport_data, etc.) unless the tool is explicitly a "get" for a model where those blobs are the point. Skip created_at/updated_at unless informative.
Phase 0 — Gem and migrations
Task 1: Add the mcp gem
Files:
-
Modify:
Gemfile -
Step 1: Add the gem
Add this line to Gemfile under the # Core Rails & Server block (or near the authentication gems — any top-level location works):
gem "mcp", "~> 0.10.0"
- Step 2: Install
bundle install
Expected: Gemfile.lock updated; mcp 0.10.x installed.
- Step 3: Verify load
bin/rails runner 'puts MCP::Server.name'
Expected: MCP::Server
- Step 4: Commit
git add Gemfile Gemfile.lock
git commit -m "Add mcp gem for MCP server runtime"
Task 2: Create mcp_tokens table
Files:
-
Create:
db/migrate/YYYYMMDDHHMMSS_create_mcp_tokens.rb -
Step 1: Generate migration
bin/rails g migration CreateMcpTokens
- Step 2: Fill in the migration
Replace the generated file contents:
class CreateMcpTokens < ActiveRecord::Migration[8.1]
def change
create_table :mcp_tokens do |t|
t.references :user, null: false, foreign_key: true
t.string :name, null: false
t.string :token_digest, null: false
t.string :token_prefix
t.string :scope, null: false, default: "user"
t.json :permissions, null: false, default: {}
t.datetime :last_used_at
t.datetime :revoked_at
t.timestamps
end
add_index :mcp_tokens, :token_digest, unique: true
add_index :mcp_tokens, [ :user_id, :revoked_at ]
end
end
- Step 3: Run migration
bin/rails db:migrate
Expected: migration runs cleanly; db/schema.rb updated.
- Step 4: Commit
git add db/migrate/*_create_mcp_tokens.rb db/schema.rb
git commit -m "Create mcp_tokens table"
Task 3: Create mcp_activity_logs table
Files:
-
Create:
db/migrate/YYYYMMDDHHMMSS_create_mcp_activity_logs.rb -
Step 1: Generate migration
bin/rails g migration CreateMcpActivityLogs
- Step 2: Fill in the migration
class CreateMcpActivityLogs < ActiveRecord::Migration[8.1]
def change
create_table :mcp_activity_logs do |t|
t.references :mcp_token, null: false, foreign_key: { to_table: :mcp_tokens }
t.references :user, null: false, foreign_key: true
t.string :tool_name, null: false
t.string :domain, null: false
t.string :action_type, null: false
t.json :arguments, default: {}
t.json :result_summary, default: {}
t.integer :status_code
t.timestamps
end
add_index :mcp_activity_logs, [ :user_id, :created_at ]
add_index :mcp_activity_logs, [ :mcp_token_id, :created_at ]
end
end
- Step 3: Run migration
bin/rails db:migrate
- Step 4: Commit
git add db/migrate/*_create_mcp_activity_logs.rb db/schema.rb
git commit -m "Create mcp_activity_logs table"
Phase 1 — Models and runtime
Task 4: Mcp::Token model
Files:
- Create:
app/models/mcp/token.rb - Create:
test/fixtures/mcp_tokens.yml -
Create:
test/models/mcp/token_test.rb - Step 1: Write the model
Create app/models/mcp/token.rb:
class Mcp::Token < ApplicationRecord
self.table_name = "mcp_tokens"
PERMISSION_KEYS = %w[
accounts expenses debts investments goals habits sports
birthdays market_lists notes countdowns construction todos
gamification analytics
].freeze
SCOPES = %w[user].freeze
belongs_to :user
has_many :activity_logs,
class_name: "Mcp::ActivityLog",
foreign_key: :mcp_token_id,
dependent: :destroy
validates :name, presence: true
validates :token_digest, presence: true, uniqueness: true
validates :scope, inclusion: { in: SCOPES }
scope :active, -> { where(revoked_at: nil) }
scope :revoked, -> { where.not(revoked_at: nil) }
def permits?(domain)
permissions[domain.to_s] == true
end
def active?
revoked_at.nil?
end
def revoke!
update!(revoked_at: Time.current)
end
def regenerate!
raw = self.class.generate_raw_token
update!(token_digest: Digest::SHA256.hexdigest(raw), token_prefix: raw[0..7])
raw
end
def self.generate_raw_token
SecureRandom.hex(32)
end
def self.generate_for(user, name:, permissions: {})
raw = generate_raw_token
token = create!(
user: user,
name: name,
token_digest: Digest::SHA256.hexdigest(raw),
token_prefix: raw[0..7],
permissions: sanitize_permissions(permissions),
scope: "user"
)
[ token, raw ]
end
def self.find_by_raw_token(raw_token)
return nil if raw_token.blank?
find_by(token_digest: Digest::SHA256.hexdigest(raw_token), revoked_at: nil)
end
def self.permissions_from_params(params)
submitted = params.dig(:mcp_token, :permissions) || {}
sanitize_permissions(submitted)
end
def self.sanitize_permissions(hash)
PERMISSION_KEYS.each_with_object({}) do |key, memo|
memo[key] = ActiveModel::Type::Boolean.new.cast(hash[key] || hash[key.to_sym])
end
end
end
- Step 2: Add fixture
Create test/fixtures/mcp_tokens.yml:
active:
user: primary
name: "Test token"
token_digest: <%= Digest::SHA256.hexdigest("rawtoken_active_abcdef0123456789") %>
token_prefix: "rawtoken"
scope: "user"
permissions: <%= { accounts: true, expenses: true, gamification: true, analytics: true }.to_json %>
revoked:
user: primary
name: "Revoked token"
token_digest: <%= Digest::SHA256.hexdigest("rawtoken_revoked_000000000000000") %>
token_prefix: "rawtoken"
scope: "user"
permissions: {}
revoked_at: <%= 1.day.ago %>
(Note: primary refers to the existing users.yml primary fixture. Verify the fixture name with head test/fixtures/users.yml — if it's different, match it.)
- Step 3: Write model tests
Create test/models/mcp/token_test.rb:
require "test_helper"
class Mcp::TokenTest < ActiveSupport::TestCase
test "find_by_raw_token returns active tokens" do
token = Mcp::Token.find_by_raw_token("rawtoken_active_abcdef0123456789")
assert_equal mcp_tokens(:active), token
end
test "find_by_raw_token rejects revoked tokens" do
assert_nil Mcp::Token.find_by_raw_token("rawtoken_revoked_000000000000000")
end
test "find_by_raw_token returns nil for unknown token" do
assert_nil Mcp::Token.find_by_raw_token("does-not-exist")
end
test "permits? reads the permissions hash" do
token = mcp_tokens(:active)
assert token.permits?(:accounts)
assert_not token.permits?(:debts)
end
test "regenerate! rotates digest and prefix" do
token = mcp_tokens(:active)
old_digest = token.token_digest
raw = token.regenerate!
assert_not_equal old_digest, token.reload.token_digest
assert_equal raw[0..7], token.token_prefix
end
test "revoke! sets revoked_at" do
token = mcp_tokens(:active)
token.revoke!
assert_not_nil token.reload.revoked_at
end
test "generate_for creates token and returns raw value" do
user = users(:primary)
token, raw = Mcp::Token.generate_for(user, name: "Test", permissions: { accounts: true })
assert_equal 64, raw.length
assert_equal Digest::SHA256.hexdigest(raw), token.token_digest
assert token.permits?(:accounts)
assert_not token.permits?(:expenses)
end
test "sanitize_permissions keeps only known keys and coerces booleans" do
cleaned = Mcp::Token.sanitize_permissions(accounts: "1", bogus: "1", expenses: false)
assert_equal true, cleaned["accounts"]
assert_equal false, cleaned["expenses"]
assert_not cleaned.key?("bogus")
end
end
- Step 4: Run tests
bin/rails test test/models/mcp/token_test.rb
Expected: 7 tests pass.
- Step 5: Commit
git add app/models/mcp/token.rb test/fixtures/mcp_tokens.yml test/models/mcp/token_test.rb
git commit -m "Add Mcp::Token model with digest-based lookup"
Task 5: Mcp::ActivityLog model
Files:
- Create:
app/models/mcp/activity_log.rb - Create:
test/fixtures/mcp_activity_logs.yml -
Create:
test/models/mcp/activity_log_test.rb - Step 1: Write the model
class Mcp::ActivityLog < ApplicationRecord
self.table_name = "mcp_activity_logs"
SENSITIVE_KEY_PATTERN = /password|token|secret/i
RESULT_PREVIEW_LIMIT = 500
belongs_to :mcp_token, class_name: "Mcp::Token"
belongs_to :user
validates :tool_name, :domain, :action_type, presence: true
scope :recent, -> { order(created_at: :desc) }
def self.log_tool_call(token:, tool_class:, arguments:, result:, status_code: 200)
create!(
mcp_token: token,
user: token.user,
tool_name: tool_class.tool_name,
domain: tool_class.mcp_domain,
action_type: tool_class.mcp_action,
arguments: sanitize_arguments(arguments),
result_summary: summarize_result(result),
status_code: status_code
)
rescue ActiveRecord::RecordInvalid, ActiveRecord::StatementInvalid => error
Rails.logger.warn("[MCP] Activity log failed: #{error.message}")
nil
end
def self.sanitize_arguments(args)
return {} unless args.is_a?(Hash)
args.each_with_object({}) do |(k, v), memo|
memo[k.to_s] = k.to_s.match?(SENSITIVE_KEY_PATTERN) ? "[REDACTED]" : v
end
end
def self.summarize_result(result)
text = result.respond_to?(:content) ? Array(result.content).first&.dig(:text) : result.to_s
{ preview: text.to_s[0, RESULT_PREVIEW_LIMIT] }
end
end
- Step 2: Add fixture
# test/fixtures/mcp_activity_logs.yml
one:
mcp_token: active
user: primary
tool_name: "list_accounts"
domain: "accounts"
action_type: "read"
arguments: <%= {}.to_json %>
result_summary: <%= { preview: "ok" }.to_json %>
status_code: 200
- Step 3: Write model tests
# test/models/mcp/activity_log_test.rb
require "test_helper"
class Mcp::ActivityLogTest < ActiveSupport::TestCase
test "belongs to token and user" do
log = mcp_activity_logs(:one)
assert_equal mcp_tokens(:active), log.mcp_token
assert_equal users(:primary), log.user
end
test "sanitize_arguments redacts sensitive keys" do
cleaned = Mcp::ActivityLog.sanitize_arguments(name: "foo", password: "secret123", api_token: "abc")
assert_equal "foo", cleaned["name"]
assert_equal "[REDACTED]", cleaned["password"]
assert_equal "[REDACTED]", cleaned["api_token"]
end
test "summarize_result truncates to preview limit" do
long = "x" * 1000
summary = Mcp::ActivityLog.summarize_result(long)
assert_equal 500, summary[:preview].length
end
end
- Step 4: Run tests
bin/rails test test/models/mcp/activity_log_test.rb
Expected: 3 tests pass.
- Step 5: Commit
git add app/models/mcp/activity_log.rb test/fixtures/mcp_activity_logs.yml test/models/mcp/activity_log_test.rb
git commit -m "Add Mcp::ActivityLog with sanitized argument capture"
Task 6: Mcp::UserContext
Files:
-
Create:
app/models/mcp/user_context.rb -
Step 1: Write the class
class Mcp::UserContext
attr_reader :user, :token
def initialize(user, token:)
@user = user
@token = token
raise ArgumentError, "user is required" if @user.nil?
end
end
- Step 2: Commit
git add app/models/mcp/user_context.rb
git commit -m "Add Mcp::UserContext scope object"
Task 7: Tool concerns
Files:
- Create:
app/models/mcp/tools/concerns/formattable.rb - Create:
app/models/mcp/tools/concerns/listable.rb -
Create:
app/models/mcp/tools/concerns/tool_metadata.rb - Step 1: Write Formattable
# app/models/mcp/tools/concerns/formattable.rb
module Mcp::Tools::Concerns::Formattable
extend ActiveSupport::Concern
class_methods do
def text_response(text)
MCP::Tool::Response.new([ { type: "text", text: text } ])
end
def json_response(data)
MCP::Tool::Response.new([ { type: "text", text: JSON.pretty_generate(data) } ])
end
def not_found_response(label)
text_response("#{label} not found")
end
end
end
- Step 2: Write Listable
# app/models/mcp/tools/concerns/listable.rb
module Mcp::Tools::Concerns::Listable
extend ActiveSupport::Concern
PER_PAGE = 25
class_methods do
def paginate(scope, page:, per: PER_PAGE)
page = [ page.to_i, 1 ].max
scope.offset((page - 1) * per).limit(per)
end
def format_collection(records, page: 1, per: PER_PAGE, &block)
items = records.map(&block)
total = records.respond_to?(:unscope) ? records.unscope(:offset, :limit).count : items.size
header = "Page #{page} (#{items.size} of #{total} total)"
text_response([ header, "", *items ].join("\n"))
end
end
end
- Step 3: Write ToolMetadata
# app/models/mcp/tools/concerns/tool_metadata.rb
module Mcp::Tools::Concerns::ToolMetadata
extend ActiveSupport::Concern
class_methods do
def mcp_domain(value = nil)
@mcp_domain = value if value
@mcp_domain
end
def mcp_action(value = nil)
@mcp_action = value if value
@mcp_action || "read"
end
end
end
- Step 4: Commit
git add app/models/mcp/tools/concerns/
git commit -m "Add MCP tool concerns for formatting, listing, metadata"
Task 8: Mcp::BaseTool
Files:
-
Create:
app/models/mcp/base_tool.rb -
Step 1: Write the class
class Mcp::BaseTool < MCP::Tool
include Mcp::Tools::Concerns::Formattable
include Mcp::Tools::Concerns::ToolMetadata
module CallLogging
def call(**args)
tool_args = args.except(:server_context)
result = super(**tool_args)
if user_context&.token
Mcp::ActivityLog.log_tool_call(
token: user_context.token,
tool_class: self,
arguments: tool_args,
result: result
)
end
result
end
end
class << self
def inherited(subclass)
super
subclass.singleton_class.prepend(CallLogging)
end
def user_context
Thread.current[:mcp_user_context]
end
def user_context=(context)
Thread.current[:mcp_user_context] = context
end
def user
user_context.user
end
def token
user_context.token
end
end
end
- Step 2: Commit
git add app/models/mcp/base_tool.rb
git commit -m "Add Mcp::BaseTool with thread-local user context and call logging"
Task 9: Mcp::ServerBuilder
Files:
- Create:
app/models/mcp/server_builder.rb -
Create:
test/models/mcp/server_builder_test.rb - Step 1: Write the builder
class Mcp::ServerBuilder
TOOL_NAMESPACES = %w[
Mcp::Tools::Accounts
Mcp::Tools::Expenses
Mcp::Tools::Debts
Mcp::Tools::DebtPayments
Mcp::Tools::Investments
Mcp::Tools::Goals
Mcp::Tools::Habits
Mcp::Tools::HabitCompletions
Mcp::Tools::Sports
Mcp::Tools::ActivityLogs
Mcp::Tools::Birthdays
Mcp::Tools::MarketLists
Mcp::Tools::MarketListItems
Mcp::Tools::Notes
Mcp::Tools::Countdowns
Mcp::Tools::ConstructionProjects
Mcp::Tools::ConstructionPhases
Mcp::Tools::ConstructionExpenses
Mcp::Tools::TodoBoards
Mcp::Tools::TodoCards
Mcp::Tools::Gamification
Mcp::Tools::Analytics
].freeze
def self.tool_classes
TOOL_NAMESPACES.flat_map do |name|
namespace = name.safe_constantize
next [] unless namespace
namespace.constants.map { |c| namespace.const_get(c) }.select { |c| c.is_a?(Class) && c < Mcp::BaseTool }
end
end
def self.build(context)
tools = tool_classes.select do |klass|
klass.user_context = context
context.token.nil? || context.token.permits?(klass.mcp_domain)
end
MCP::Server.new(
name: "Lifehub (#{context.user.email})",
version: "1.0.0",
tools: tools
)
end
end
- Step 2: Write builder tests
# test/models/mcp/server_builder_test.rb
require "test_helper"
class Mcp::ServerBuilderTest < ActiveSupport::TestCase
setup do
@user = users(:primary)
end
test "tool_classes returns BaseTool subclasses across namespaces" do
classes = Mcp::ServerBuilder.tool_classes
# At least some classes should exist once tool domains are implemented.
classes.each { |c| assert c < Mcp::BaseTool }
end
test "build filters tools by token permissions" do
token = mcp_tokens(:active) # permits :accounts, :expenses, :gamification, :analytics
context = Mcp::UserContext.new(@user, token: token)
server = Mcp::ServerBuilder.build(context)
tool_names = server.tools.map(&:tool_name)
# Positive: accounts tools present if implemented.
# Negative: a non-permitted domain like :debts should not appear.
tool_names.each do |name|
assert_not name.to_s.include?("_debt"), "debt tools should be filtered out: #{name}"
end
end
end
- Step 3: Run tests
bin/rails test test/models/mcp/server_builder_test.rb
Expected: 2 tests pass (they're lenient — they'll assert properties that hold when zero or more tools exist).
- Step 4: Commit
git add app/models/mcp/server_builder.rb test/models/mcp/server_builder_test.rb
git commit -m "Add Mcp::ServerBuilder with namespace-based tool discovery"
Phase 2 — Transport and routes
Task 10: Mcp::BaseRackApp
Files:
-
Create:
app/middleware/mcp/base_rack_app.rb -
Step 1: Write the base Rack app
class Mcp::BaseRackApp
RATE_LIMIT_WINDOW = 60
RATE_LIMIT_MAX = 60
def initialize
@rate_limiter = {}
@rate_limiter_mutex = Mutex.new
@transports = {}
@transports_mutex = Mutex.new
end
def call(env)
raw_token = extract_token(env)
return unauthorized_response if raw_token.blank?
mcp_token = Mcp::Token.find_by_raw_token(raw_token)
return unauthorized_response unless authorized?(mcp_token)
return rate_limit_response if rate_limited?(mcp_token.id)
mcp_token.update_column(:last_used_at, Time.current)
before_dispatch(mcp_token)
request = Rack::Request.new(env)
transport_for(mcp_token).handle_request(request)
rescue => error
Rails.logger.error("[MCP] #{error.class}: #{error.message}\n#{error.backtrace.first(5).join("\n")}")
error_response
ensure
after_dispatch
end
private
def authorized?(_mcp_token) = raise NotImplementedError
def build_server(_mcp_token) = raise NotImplementedError
def before_dispatch(_mcp_token); end
def after_dispatch; end
def extract_token(env)
(env["PATH_INFO"] || "").split("/").last
end
def transport_for(mcp_token)
@transports_mutex.synchronize do
server = build_server(mcp_token)
if @transports[mcp_token.id]
@transports[mcp_token.id].instance_variable_set(:@server, server)
@transports[mcp_token.id]
else
@transports[mcp_token.id] = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
end
end
end
def rate_limited?(token_id)
@rate_limiter_mutex.synchronize do
now = Time.now.to_i
window_key = now / RATE_LIMIT_WINDOW
@rate_limiter.delete_if { |_key, (window, _count)| window < window_key }
window, count = @rate_limiter[token_id]
if window == window_key
return true if count >= RATE_LIMIT_MAX
@rate_limiter[token_id] = [ window_key, count + 1 ]
else
@rate_limiter[token_id] = [ window_key, 1 ]
end
false
end
end
def unauthorized_response
[ 401, { "Content-Type" => "application/json" },
[ '{"jsonrpc":"2.0","error":{"code":-32001,"message":"Unauthorized"},"id":null}' ] ]
end
def rate_limit_response
[ 429, { "Content-Type" => "application/json", "Retry-After" => RATE_LIMIT_WINDOW.to_s },
[ '{"jsonrpc":"2.0","error":{"code":-32001,"message":"Rate limit exceeded"},"id":null}' ] ]
end
def error_response
[ 500, { "Content-Type" => "application/json" },
[ '{"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error"},"id":null}' ] ]
end
end
- Step 2: Commit
git add app/middleware/mcp/base_rack_app.rb
git commit -m "Add Mcp::BaseRackApp transport with rate limiting"
Task 11: Mcp::RackApp
Files:
-
Create:
app/middleware/mcp/rack_app.rb -
Step 1: Write the user-scoped Rack app
class Mcp::RackApp < Mcp::BaseRackApp
private
def authorized?(mcp_token)
mcp_token&.active? && mcp_token.user.present?
end
def build_server(mcp_token)
context = Mcp::UserContext.new(mcp_token.user, token: mcp_token)
Mcp::ServerBuilder.build(context)
end
def before_dispatch(mcp_token)
Current.user = mcp_token.user
end
def after_dispatch
Mcp::BaseTool.user_context = nil
Current.user = nil
end
end
- Step 2: Commit
git add app/middleware/mcp/rack_app.rb
git commit -m "Add Mcp::RackApp wiring UserContext and Current.user"
Task 12: Mount and integration test
Files:
- Modify:
config/routes.rb -
Create:
test/integration/mcp/rack_app_test.rb - Step 1: Mount at
/mcp
Edit config/routes.rb. Immediately inside Rails.application.routes.draw do, add:
mount Mcp::RackApp.new, at: "/mcp"
Place it before the authenticate :user, ->(user) { user.admin? } block so it isn't shadowed.
- Step 2: Write integration tests
# test/integration/mcp/rack_app_test.rb
require "test_helper"
class Mcp::RackAppIntegrationTest < ActionDispatch::IntegrationTest
test "missing token returns 401" do
post "/mcp/"
assert_response :unauthorized
end
test "invalid token returns 401" do
post "/mcp/not-a-real-token", params: { jsonrpc: "2.0", method: "tools/list", id: 1 }.to_json,
headers: { "Content-Type" => "application/json" }
assert_response :unauthorized
end
test "revoked token returns 401" do
raw = "rawtoken_revoked_000000000000000"
post "/mcp/#{raw}", params: jsonrpc_body("tools/list"), headers: json_headers
assert_response :unauthorized
end
test "valid token can list tools" do
raw = "rawtoken_active_abcdef0123456789"
post "/mcp/#{raw}", params: jsonrpc_body("tools/list"), headers: json_headers
assert_response :success
body = JSON.parse(response.body)
assert body["result"]["tools"].is_a?(Array)
end
private
def jsonrpc_body(method)
{ jsonrpc: "2.0", method: method, params: {}, id: 1 }.to_json
end
def json_headers
{ "Content-Type" => "application/json", "Accept" => "application/json" }
end
end
- Step 3: Run tests
bin/rails test test/integration/mcp/rack_app_test.rb
Expected: 4 tests pass. The fourth requires at least one tool to exist in a permitted domain — this is satisfied once Task 13 (Accounts) is done. Skip this assertion for now (mark as skip in setup) and remove the skip at the end of Task 13.
Add at the top of the last test:
skip "requires at least one implemented tool domain" if Mcp::ServerBuilder.tool_classes.empty?
- Step 4: Commit
git add config/routes.rb test/integration/mcp/rack_app_test.rb
git commit -m "Mount Mcp::RackApp at /mcp and add Rack integration tests"
Phase 3 — First domain (Accounts)
Task 13: Accounts CRU tools
Domain spec:
- Model:
Account - Permission key:
accounts - Association from user:
user.accounts - Ransack sortable/filterable:
name,account_type,currency,balance,created_at - Create/Update fields:
name(required for create),account_type(enum:bank/international),currency(enum:BRL/USD),balance,color - Attributes hash:
id,name,account_type,currency,balance,color
Files:
- Create:
app/models/mcp/tools/accounts/list.rb - Create:
app/models/mcp/tools/accounts/get.rb - Create:
app/models/mcp/tools/accounts/create.rb - Create:
app/models/mcp/tools/accounts/update.rb -
Create:
test/integration/mcp/tools/accounts_test.rb - Step 1: Write List tool
# app/models/mcp/tools/accounts/list.rb
class Mcp::Tools::Accounts::List < Mcp::BaseTool
include Mcp::Tools::Concerns::Listable
tool_name "list_accounts"
mcp_domain "accounts"
mcp_action "read"
description "List the current user's accounts with balances"
input_schema(
properties: {
page: { type: "integer", description: "Page number (default 1)" },
account_type: { type: "string", description: "Filter by bank or international", enum: %w[bank international] },
currency: { type: "string", description: "Filter by currency", enum: %w[BRL USD] }
}
)
def self.call(page: 1, account_type: nil, currency: nil)
scope = user.accounts.order(:position, :name)
scope = scope.where(account_type: account_type) if account_type.present?
scope = scope.where(currency: currency) if currency.present?
records = paginate(scope, page: page)
format_collection(records, page: page) do |account|
"[ID: #{account.id}] #{account.name} | #{account.account_type} | #{account.currency} | #{account.balance}"
end
end
end
- Step 2: Write Get tool
# app/models/mcp/tools/accounts/get.rb
class Mcp::Tools::Accounts::Get < Mcp::BaseTool
tool_name "get_account"
mcp_domain "accounts"
mcp_action "read"
description "Fetch a single account by ID"
input_schema(
properties: { id: { type: "integer", description: "Account ID" } },
required: [ "id" ]
)
def self.call(id:)
record = user.accounts.find_by(id: id)
return not_found_response("account") unless record
json_response(
id: record.id,
name: record.name,
account_type: record.account_type,
currency: record.currency,
balance: record.balance,
color: record.color
)
end
end
- Step 3: Write Create tool
# app/models/mcp/tools/accounts/create.rb
class Mcp::Tools::Accounts::Create < Mcp::BaseTool
PERMITTED = %i[name account_type currency balance color].freeze
tool_name "create_account"
mcp_domain "accounts"
mcp_action "create"
description "Create a new account for the current user"
input_schema(
properties: {
name: { type: "string" },
account_type: { type: "string", enum: %w[bank international] },
currency: { type: "string", enum: %w[BRL USD] },
balance: { type: "number", description: "BRL-equivalent balance" },
color: { type: "string", description: "Hex color like #3B82F6" }
},
required: [ "name" ]
)
def self.call(**args)
record = user.accounts.create!(args.slice(*PERMITTED))
json_response(
id: record.id,
name: record.name,
account_type: record.account_type,
currency: record.currency,
balance: record.balance
)
rescue ActiveRecord::RecordInvalid => error
text_response("Failed to create account: #{error.record.errors.full_messages.join(", ")}")
end
end
- Step 4: Write Update tool
# app/models/mcp/tools/accounts/update.rb
class Mcp::Tools::Accounts::Update < Mcp::BaseTool
PERMITTED = %i[name account_type currency balance color].freeze
tool_name "update_account"
mcp_domain "accounts"
mcp_action "update"
description "Update an existing account for the current user"
input_schema(
properties: {
id: { type: "integer" },
name: { type: "string" },
account_type: { type: "string", enum: %w[bank international] },
currency: { type: "string", enum: %w[BRL USD] },
balance: { type: "number" },
color: { type: "string" }
},
required: [ "id" ]
)
def self.call(id:, **args)
record = user.accounts.find_by(id: id)
return not_found_response("account") unless record
record.update!(args.slice(*PERMITTED))
json_response(
id: record.id,
name: record.name,
account_type: record.account_type,
currency: record.currency,
balance: record.balance
)
rescue ActiveRecord::RecordInvalid => error
text_response("Failed to update account: #{error.record.errors.full_messages.join(", ")}")
end
end
- Step 5: Write integration test
# test/integration/mcp/tools/accounts_test.rb
require "test_helper"
class Mcp::Tools::AccountsTest < ActionDispatch::IntegrationTest
setup do
@raw_token = "rawtoken_active_abcdef0123456789"
@user = users(:primary)
end
test "list returns user's accounts" do
body = call_tool("list_accounts", { page: 1 })
text = body.dig("result", "content", 0, "text")
assert_match(/Page 1/, text)
end
test "get returns account attributes" do
account = @user.accounts.first
body = call_tool("get_account", { id: account.id })
json = JSON.parse(body.dig("result", "content", 0, "text"))
assert_equal account.id, json["id"]
assert_equal account.name, json["name"]
end
test "create persists new account" do
assert_difference -> { @user.accounts.count }, 1 do
body = call_tool("create_account", { name: "MCP Test", account_type: "bank", currency: "BRL", balance: 100.0 })
json = JSON.parse(body.dig("result", "content", 0, "text"))
assert_equal "MCP Test", json["name"]
end
end
test "update mutates existing account" do
account = @user.accounts.first
call_tool("update_account", { id: account.id, name: "Renamed via MCP" })
assert_equal "Renamed via MCP", account.reload.name
end
test "forbidden domain is not exposed" do
body = rpc_call("tools/list")
names = body.dig("result", "tools").map { |t| t["name"] }
assert names.include?("list_accounts")
# debts is not granted on this fixture token
assert_not names.include?("list_debts")
end
test "cross-user isolation" do
other = users(:secondary)
other_account = @user.accounts.first.dup
other_account.user = other
other_account.name = "Other user's account"
other_account.save!
body = call_tool("get_account", { id: other_account.id })
text = body.dig("result", "content", 0, "text")
assert_match(/not found/, text)
end
test "successful call writes activity log" do
assert_difference -> { Mcp::ActivityLog.count }, 1 do
call_tool("list_accounts", {})
end
end
private
def call_tool(name, args)
rpc_call("tools/call", { name: name, arguments: args })
end
def rpc_call(method, params = {})
post "/mcp/#{@raw_token}",
params: { jsonrpc: "2.0", method: method, params: params, id: 1 }.to_json,
headers: { "Content-Type" => "application/json", "Accept" => "application/json" }
JSON.parse(response.body)
end
end
If a :secondary user fixture doesn't exist, add one to test/fixtures/users.yml mirroring :primary's structure with a different email.
- Step 6: Run tests
bin/rails test test/integration/mcp/tools/accounts_test.rb
Expected: 7 tests pass.
- Step 7: Remove the skip in Task 12's rack test
Delete the skip "requires at least one implemented tool domain" if ... line. Re-run:
bin/rails test test/integration/mcp/rack_app_test.rb
Expected: 4 tests pass.
- Step 8: Commit
git add app/models/mcp/tools/accounts/ test/integration/mcp/tools/accounts_test.rb test/integration/mcp/rack_app_test.rb test/fixtures/users.yml
git commit -m "Add Accounts CRU MCP tools with end-to-end integration tests"
Phase 4 — Token management UI
Task 14: Policies
Files:
- Create:
app/policies/mcp/token_policy.rb -
Create:
app/policies/mcp/activity_policy.rb - Step 1: Write token policy
# app/policies/mcp/token_policy.rb
class Mcp::TokenPolicy < ApplicationPolicy
def index? = user.present?
def show? = owned?
def new? = user.present?
def create? = user.present?
def edit? = owned?
def update? = owned?
def destroy? = owned?
def regenerate? = owned?
class Scope < Scope
def resolve
scope.where(user: user)
end
end
private
def owned?
record.user_id == user.id
end
end
- Step 2: Write activity policy
# app/policies/mcp/activity_policy.rb
class Mcp::ActivityPolicy < ApplicationPolicy
def index? = user.present?
class Scope < Scope
def resolve
scope.where(user: user)
end
end
end
- Step 3: Commit
git add app/policies/mcp/
git commit -m "Add Pundit policies for MCP token and activity"
Task 15: Base controller and tokens controller
Files:
- Create:
app/controllers/mcp/base_controller.rb -
Create:
app/controllers/mcp/tokens_controller.rb - Step 1: Base controller
# app/controllers/mcp/base_controller.rb
class Mcp::BaseController < ApplicationController
before_action :authenticate_user!
layout "application"
end
- Step 2: Tokens controller
# app/controllers/mcp/tokens_controller.rb
class Mcp::TokensController < Mcp::BaseController
before_action :set_token, only: %i[edit update destroy regenerate]
def new
@token = Mcp::Token.new(scope: "user", permissions: default_permissions)
authorize @token, policy_class: Mcp::TokenPolicy
end
def create
authorize Mcp::Token, policy_class: Mcp::TokenPolicy
permissions = Mcp::Token.permissions_from_params(params)
@token, raw_token = Mcp::Token.generate_for(
current_user,
name: params.dig(:mcp_token, :name).to_s.presence || "Untitled token",
permissions: permissions
)
if @token.persisted?
flash[:mcp_raw_token] = raw_token
redirect_to mcp_settings_path, notice: "Token created — copy it now; it will not be shown again."
else
render :new, status: :unprocessable_entity
end
end
def edit
end
def update
permissions = Mcp::Token.permissions_from_params(params)
if @token.update(name: params.dig(:mcp_token, :name), permissions: permissions)
redirect_to mcp_settings_path, notice: "Token updated"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@token.revoke!
redirect_to mcp_settings_path, notice: "Token revoked"
end
def regenerate
raw = @token.regenerate!
flash[:mcp_raw_token] = raw
redirect_to mcp_settings_path, notice: "Token regenerated — copy the new value; it will not be shown again."
end
private
def set_token
@token = Mcp::Token.where(user: current_user).find(params[:id])
authorize @token, policy_class: Mcp::TokenPolicy
end
def default_permissions
Mcp::Token::PERMISSION_KEYS.index_with { false }
end
end
- Step 3: Commit
git add app/controllers/mcp/base_controller.rb app/controllers/mcp/tokens_controller.rb
git commit -m "Add Mcp::TokensController for token CRUD and regeneration"
Task 16: Settings and activity controllers
Files:
- Create:
app/controllers/mcp/settings_controller.rb -
Create:
app/controllers/mcp/activity_controller.rb - Step 1: Settings controller
# app/controllers/mcp/settings_controller.rb
class Mcp::SettingsController < Mcp::BaseController
def show
@tokens = Mcp::TokenPolicy::Scope.new(current_user, Mcp::Token).resolve.order(:created_at)
@raw_token = flash[:mcp_raw_token]
end
end
- Step 2: Activity controller
# app/controllers/mcp/activity_controller.rb
class Mcp::ActivityController < Mcp::BaseController
include Pagy::Backend
def index
scope = Mcp::ActivityPolicy::Scope.new(current_user, Mcp::ActivityLog).resolve.recent
@pagy, @logs = pagy(scope, limit: 25)
end
end
- Step 3: Commit
git add app/controllers/mcp/settings_controller.rb app/controllers/mcp/activity_controller.rb
git commit -m "Add Mcp::SettingsController and Mcp::ActivityController"
Task 17: Routes
Files:
-
Modify:
config/routes.rb -
Step 1: Add MCP settings routes
Inside the authenticated :user do block in config/routes.rb, add:
namespace :mcp, path: "settings/mcp" do
get "", to: "settings#show", as: :settings
resources :tokens, only: %i[new create edit update destroy] do
post :regenerate, on: :member
end
resources :activity, only: :index
end
- Step 2: Verify
bin/rails routes | grep mcp
Expected: entries for mcp_settings, new_mcp_token, mcp_tokens, edit_mcp_token, regenerate_mcp_token, mcp_activity_index, and the root POST /mcp/:token mount.
- Step 3: Commit
git add config/routes.rb
git commit -m "Add /settings/mcp routes for tokens and activity"
Task 18: Views
Files:
- Create:
app/views/mcp/settings/show.html.erb - Create:
app/views/mcp/tokens/new.html.erb - Create:
app/views/mcp/tokens/edit.html.erb - Create:
app/views/mcp/tokens/_form.html.erb - Create:
app/views/mcp/tokens/_reveal.html.erb - Create:
app/views/mcp/activity/index.html.erb
Use Tailwind per AGENTS.md. Views should be plain, readable, and follow the visual language of existing settings pages. The code below is the minimum functional UI.
- Step 1: Settings show
<%# app/views/mcp/settings/show.html.erb %>
<div class="max-w-4xl mx-auto p-6 space-y-6">
<header class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">MCP tokens</h1>
<p class="text-sm text-gray-500">Connect AI clients to your Lifehub data.</p>
</div>
<%= link_to "New token", new_mcp_token_path, class: "btn btn-primary" %>
</header>
<% if @raw_token %>
<%= render "mcp/tokens/reveal", raw_token: @raw_token %>
<% end %>
<section class="border rounded-lg divide-y">
<% if @tokens.empty? %>
<div class="p-6 text-gray-500">No tokens yet. Create one to get started.</div>
<% end %>
<% @tokens.each do |token| %>
<div class="p-4 flex items-center justify-between">
<div>
<div class="font-medium"><%= token.name %></div>
<div class="text-xs text-gray-500">
prefix <code><%= token.token_prefix %>…</code>
· last used <%= token.last_used_at&.to_fs(:short) || "never" %>
<% if token.revoked_at %> · <span class="text-red-600">revoked</span><% end %>
</div>
<div class="text-xs text-gray-500 mt-1">
permissions: <%= token.permissions.select { |_, v| v }.keys.join(", ").presence || "none" %>
</div>
</div>
<div class="flex gap-2">
<% unless token.revoked_at %>
<%= link_to "Edit", edit_mcp_token_path(token), class: "btn btn-sm" %>
<%= button_to "Regenerate", regenerate_mcp_token_path(token), method: :post,
data: { turbo_confirm: "This replaces the current token. Continue?" },
class: "btn btn-sm" %>
<%= button_to "Revoke", mcp_token_path(token), method: :delete,
data: { turbo_confirm: "Revoke this token?" },
class: "btn btn-sm btn-danger" %>
<% end %>
</div>
</div>
<% end %>
</section>
<div class="text-sm">
<%= link_to "View activity log →", mcp_activity_index_path, class: "text-blue-600 hover:underline" %>
</div>
</div>
- Step 2: Tokens new
<%# app/views/mcp/tokens/new.html.erb %>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">New MCP token</h1>
<%= render "form", token: @token, url: mcp_tokens_path %>
</div>
- Step 3: Tokens edit
<%# app/views/mcp/tokens/edit.html.erb %>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-4">Edit MCP token</h1>
<%= render "form", token: @token, url: mcp_token_path(@token), method: :patch %>
</div>
- Step 4: Tokens form partial
<%# app/views/mcp/tokens/_form.html.erb %>
<%= form_with model: token, url: url, method: (local_assigns[:method] || :post), local: true, class: "space-y-6" do |f| %>
<% if token.errors.any? %>
<div class="p-3 bg-red-50 border border-red-200 text-red-700 rounded">
<%= token.errors.full_messages.to_sentence %>
</div>
<% end %>
<div>
<%= f.label :name, class: "block text-sm font-medium mb-1" %>
<%= f.text_field :name, class: "w-full border rounded px-3 py-2", required: true %>
</div>
<fieldset class="space-y-2">
<legend class="font-medium">Permissions</legend>
<% Mcp::Token::PERMISSION_KEYS.each do |key| %>
<label class="flex items-center gap-2">
<%= check_box_tag "mcp_token[permissions][#{key}]", "1",
token.permissions[key] == true,
class: "rounded" %>
<span><%= key.humanize %></span>
</label>
<% end %>
</fieldset>
<div class="flex gap-2">
<%= f.submit class: "btn btn-primary" %>
<%= link_to "Cancel", mcp_settings_path, class: "btn" %>
</div>
<% end %>
- Step 5: Reveal partial
<%# app/views/mcp/tokens/_reveal.html.erb %>
<div class="p-4 border border-amber-300 bg-amber-50 rounded-lg space-y-2"
data-controller="clipboard"
data-clipboard-source-value="<%= raw_token %>">
<div class="font-medium text-amber-900">Your new raw token:</div>
<pre class="text-sm bg-white border border-amber-200 rounded p-2 overflow-x-auto"><%= raw_token %></pre>
<div class="text-xs text-amber-800">
Copy this now. It is shown only once and stored as a SHA-256 hash afterwards.
Use it in your MCP client URL: <code>POST <%= "#{request.base_url}/mcp/#{raw_token}" %></code>
</div>
</div>
- Step 6: Activity index
<%# app/views/mcp/activity/index.html.erb %>
<div class="max-w-5xl mx-auto p-6 space-y-4">
<header class="flex items-center justify-between">
<h1 class="text-2xl font-bold">MCP activity</h1>
<%= link_to "← Tokens", mcp_settings_path, class: "text-blue-600 hover:underline text-sm" %>
</header>
<% if @logs.empty? %>
<div class="p-6 text-gray-500 border rounded-lg">No MCP activity yet.</div>
<% else %>
<div class="border rounded-lg divide-y text-sm">
<% @logs.each do |log| %>
<div class="p-3 grid grid-cols-6 gap-2 items-center">
<div class="col-span-2">
<div class="font-medium"><%= log.tool_name %></div>
<div class="text-xs text-gray-500"><%= log.domain %> · <%= log.action_type %></div>
</div>
<div class="text-xs text-gray-500"><%= log.created_at.to_fs(:short) %></div>
<div class="col-span-2 text-xs text-gray-600 truncate"><%= log.arguments.to_json %></div>
<div class="text-xs text-right">
<span class="px-2 py-0.5 rounded bg-gray-100"><%= log.status_code %></span>
</div>
</div>
<% end %>
</div>
<%== pagy_nav(@pagy) %>
<% end %>
</div>
- Step 7: Commit
git add app/views/mcp/
git commit -m "Add MCP settings and activity views"
Task 19: System test for token UI
Files:
-
Create:
test/system/mcp/tokens_test.rb -
Step 1: Write a smoke system test
# test/system/mcp/tokens_test.rb
require "application_system_test_case"
class Mcp::TokensSystemTest < ApplicationSystemTestCase
setup do
@user = users(:primary)
sign_in @user
end
test "user can create a token" do
visit mcp_settings_path
click_on "New token"
fill_in "Name", with: "System test token"
check "Accounts"
click_on "Create"
assert_text "Token created"
assert_text "Your new raw token"
end
test "user can revoke a token" do
visit mcp_settings_path
accept_confirm { click_on "Revoke", match: :first }
assert_text "Token revoked"
end
end
- Step 2: Run
bin/rails test:system test/system/mcp/tokens_test.rb
Expected: both tests pass. (If sign_in helper is named differently — e.g., login_as — match your existing system test helpers.)
- Step 3: Commit
git add test/system/mcp/tokens_test.rb
git commit -m "Add system test for MCP token create and revoke"
Phase 5 — Remaining writable domains
Each task below produces four tool files (or fewer if a model is singleton) and one integration smoke test (test/integration/mcp/tools/<domain>_test.rb) modeled on accounts_test.rb. Follow the archetypes in the Tool Archetypes section at the top of this plan.
For every task in Phase 5:
- Step 1: Create the tool files (List, Get, Create, Update) using the per-domain specification below.
- Step 2: Add the permission key to the
activefixture intest/fixtures/mcp_tokens.ymlso the integration test can exercise it. (Alternatively, create a dedicated fixture token with<domain>: true.) - Step 3: Create an integration test with the same structure as
accounts_test.rb: list, get, create, update, cross-user isolation. Use 3–5 tests per domain. - Step 4: Run
bin/rails test test/integration/mcp/tools/<domain>_test.rb. - Step 5: Commit:
git add app/models/mcp/tools/<domain>/ test/integration/mcp/tools/<domain>_test.rb test/fixtures/mcp_tokens.yml && git commit -m "Add <Domain> CRU MCP tools".
Task 20: Expenses
- Namespace:
Mcp::Tools::Expenses - Permission key:
expenses - Association:
user.expenses - Order:
date: :desc, created_at: :desc - Create/Update permitted:
description,amount,date,category,expense_type,observation,account_id - Required on create:
description,amount,date - List filters:
category,expense_type,date_from/date_to(applied aswhere(date: from..to)) - Attributes hash:
id,description,amount,date,category,expense_type,account_id - Enums:
category∈ Expense::CATEGORIES;expense_type∈%w[one_time recurring_monthly installments](verify against the model file if unclear)
Task 21: Debts
- Namespace:
Mcp::Tools::Debts - Permission key:
debts - Association:
user.debts - Order:
created_at: :desc - Create/Update permitted:
name,lender_name,debt_type,status,currency,original_amount,current_balance,interest_rate,interest_rate_period,minimum_payment,due_day,started_on,ends_on,installment_total,installments_paid,notes,account_id - Required on create:
name,original_amount - List filters:
status,debt_type,currency - Attributes hash:
id,name,lender_name,debt_type,status,currency,original_amount,current_balance,interest_rate,minimum_payment,due_day
Task 22: DebtPayments
- Namespace:
Mcp::Tools::DebtPayments - Permission key:
debts(grouped under the debts permission) - Association:
user.debt_payments(DebtPaymentbelongs_to :userper schema) - Order:
paid_on: :desc - Create/Update permitted:
debt_id,amount,paid_on,principal_amount,interest_amount,fee_amount,notes,account_id - Required on create:
debt_id,amount,paid_on - Create/Update note: verify the debt belongs to the current user before saving — use
user.debts.find_by(id: args[:debt_id])as a guard; returnnot_found_response("debt")if missing. - List filters:
debt_id - Attributes hash:
id,debt_id,amount,paid_on,principal_amount,interest_amount,fee_amount,account_id
Task 23: Investments
- Namespace:
Mcp::Tools::Investments - Permission key:
investments - Association:
user.investments - Order:
created_at: :desc - Create/Update permitted:
name,investment_type,sub_type,ticker,institution,holder,currency,shares,average_price,current_price,purchase_date,maturity_date,liquidity_date,indexador,rate,status,notes,account_id,color - Required on create:
name,investment_type - List filters:
investment_type,status,currency - Attributes hash:
id,name,investment_type,ticker,institution,currency,shares,average_price,current_price,status
Task 24: Goals
- Namespace:
Mcp::Tools::Goals - Permission key:
goals - Association:
user.goals - Order:
created_at: :desc - Create/Update permitted:
name,goal_type,status,currency,target_amount,current_amount,deadline,tracking_mode,notes,icon,color,unit - Required on create:
name,target_amount - List filters:
status,goal_type,tracking_mode - Attributes hash:
id,name,goal_type,status,currency,target_amount,current_amount,progress_percentage,deadline,tracking_mode - Note: skip auto-tracked goals'
current_amountwrites iftracking_mode != "manual"— the model recalculates; explicit writes won't stick. This is fine; the model guards it.
Task 25: Habits
- Namespace:
Mcp::Tools::Habits - Permission key:
habits - Association:
user.habits - Order:
position: :asc, created_at: :desc - Create/Update permitted:
name,category,frequency,tracking_type,target_value,start_date,status,icon,color,notes,reminder_time - Required on create:
name,start_date - List filters:
status,category,frequency - Attributes hash:
id,name,category,frequency,tracking_type,status,current_streak,best_streak
Task 26: HabitCompletions
- Namespace:
Mcp::Tools::HabitCompletions - Permission key:
habits(grouped) - Association:
habit.habit_completions— scope viauser.habits.find_by(id: habit_id)then.habit_completions - Order:
date: :desc - Create/Update permitted:
habit_id,date,completed,value,notes - Required on create:
habit_id,date - Tool-specific guard: fetch
user.habits.find_by(id: habit_id); returnnot_found_response("habit")if missing before creating. - List filters:
habit_id(required for list since completions are habit-scoped), optionalfrom/todates - Attributes hash:
id,habit_id,date,completed,value
Task 27: Sports
- Namespace:
Mcp::Tools::Sports - Permission key:
sports - Association:
user.sports - Order:
created_at: :desc - Create/Update permitted:
name,sport_type,icon,color - Required on create:
name,sport_type - List filters:
sport_type - Attributes hash:
id,name,sport_type,icon,color
Task 28: ActivityLogs
- Namespace:
Mcp::Tools::ActivityLogs - Permission key:
sports(grouped) - Association:
user.activity_logs - Order:
date: :desc - Create/Update permitted:
sport_id,activity_type,date,duration_minutes,intensity,notes - Required on create:
sport_id,activity_type,date - Guard: verify
user.sports.exists?(args[:sport_id])before create. - List filters:
sport_id,activity_type,from/to - Attributes hash:
id,sport_id,activity_type,date,duration_minutes,intensity
Task 29: Birthdays
- Namespace:
Mcp::Tools::Birthdays - Permission key:
birthdays - Association:
user.birthdays - Order:
date: :asc - Create/Update permitted:
name,date,event_type,notes,reminder_days_before - Required on create:
name,date - List filters:
event_type - Attributes hash:
id,name,date,event_type,reminder_days_before
Task 30: MarketLists
- Namespace:
Mcp::Tools::MarketLists - Permission key:
market_lists - Association:
user.market_lists - Order:
created_at: :desc - Create/Update permitted:
name,list_type,notes - Required on create:
name - List filters:
list_type - Attributes hash:
id,name,list_type,notes
Task 31: MarketListItems
- Namespace:
Mcp::Tools::MarketListItems - Permission key:
market_lists(grouped) - Association:
MarketListItem.where(market_list_id: user.market_lists.select(:id)) - Order:
created_at: :desc - Create/Update permitted:
market_list_id,name,category,quantity,checked,notes - Required on create:
market_list_id,name - Guard: verify
user.market_lists.exists?(market_list_id)before create/update. - List filters:
market_list_id(required),checked - Attributes hash:
id,market_list_id,name,category,quantity,checked
Task 32: Notes
- Namespace:
Mcp::Tools::Notes - Permission key:
notes - Association:
user.notes - Order:
pinned: :desc, created_at: :desc - Create/Update permitted:
title,note_type,color,pinned,archived,content_plain - Required on create:
title - ActionText caveat:
Notehashas_rich_text :content. Acceptcontent_plain(string) and assign viarecord.content = args[:content_plain]before save. Returncontent_plain: record.content.to_plain_textin the attributes hash. - List filters:
archived,pinned,note_type - Attributes hash:
id,title,note_type,pinned,archived,color,content_plain
Task 33: Countdowns
- Namespace:
Mcp::Tools::Countdowns - Permission key:
countdowns - Association:
user.countdowns - Order:
target_date: :asc - Create/Update permitted:
name,target_date,description,recurring,status,icon,color - Required on create:
name,target_date - List filters:
status - Attributes hash:
id,name,target_date,description,recurring,status,icon
Task 34: ConstructionProjects
- Namespace:
Mcp::Tools::ConstructionProjects - Permission key:
construction - Association:
user.construction_projects - Order:
created_at: :desc - Create/Update permitted:
name,project_type,status,currency,budget,area_sqm,address,description,planned_start,planned_end,actual_start,actual_end - Required on create:
name - List filters:
status,project_type - Attributes hash:
id,name,project_type,status,currency,budget,planned_start,planned_end,actual_start,actual_end
Task 35: ConstructionPhases
- Namespace:
Mcp::Tools::ConstructionPhases - Permission key:
construction(grouped) - Association:
ConstructionPhase.where(construction_project_id: user.construction_projects.select(:id)) - Order:
position: :asc - Create/Update permitted:
construction_project_id,name,status,position,planned_budget,start_date,end_date,completion_pct,icon - Required on create:
construction_project_id,name - Guard: verify
user.construction_projects.exists?(construction_project_id). - List filters:
construction_project_id(required),status - Attributes hash:
id,construction_project_id,name,status,position,planned_budget,completion_pct,start_date,end_date
Task 36: ConstructionExpenses
- Namespace:
Mcp::Tools::ConstructionExpenses - Permission key:
construction(grouped) - Association:
ConstructionExpense.where(construction_project_id: user.construction_projects.select(:id)) - Order:
date: :desc - Create/Update permitted:
construction_project_id,construction_phase_id,description,amount,date,category,payment_method,status,contractor_name,notes - Required on create:
construction_project_id,construction_phase_id,description,amount,date - Guard: verify both the project and phase belong to the current user (phase via
user.construction_projects.find(project_id).construction_phases.exists?(phase_id)). - List filters:
construction_project_id,construction_phase_id,category,status - Attributes hash:
id,construction_project_id,construction_phase_id,description,amount,date,category,payment_method,status
Task 37: TodoBoards (singleton — get + update only)
Files:
- Create:
app/models/mcp/tools/todo_boards/get.rb - Create:
app/models/mcp/tools/todo_boards/update.rb -
Create:
test/integration/mcp/tools/todo_boards_test.rb - Permission key:
todos - Association:
user.todo_board(singleton perhas_one :todo_boardin User model)
Code:
# app/models/mcp/tools/todo_boards/get.rb
class Mcp::Tools::TodoBoards::Get < Mcp::BaseTool
tool_name "get_todo_board"
mcp_domain "todos"
mcp_action "read"
description "Fetch the current user's todo board (columns and labels)"
input_schema(properties: {})
def self.call(**)
board = user.todo_board
return not_found_response("todo_board") unless board
json_response(id: board.id, columns: board.columns, labels: board.labels)
end
end
# app/models/mcp/tools/todo_boards/update.rb
class Mcp::Tools::TodoBoards::Update < Mcp::BaseTool
PERMITTED = %i[columns labels].freeze
tool_name "update_todo_board"
mcp_domain "todos"
mcp_action "update"
description "Update the user's todo board columns and labels"
input_schema(
properties: {
columns: { type: "array", items: { type: "object" } },
labels: { type: "array", items: { type: "object" } }
}
)
def self.call(**args)
board = user.todo_board || user.build_todo_board
board.update!(args.slice(*PERMITTED))
json_response(id: board.id, columns: board.columns, labels: board.labels)
rescue ActiveRecord::RecordInvalid => error
text_response("Failed to update todo board: #{error.record.errors.full_messages.join(", ")}")
end
end
Integration test: smoke-test get + update. Commit: git commit -m "Add TodoBoard MCP tools".
Task 38: TodoCards
- Namespace:
Mcp::Tools::TodoCards - Permission key:
todos(grouped) - Association:
user.todo_cards - Order:
position: :asc - Create/Update permitted:
title,description,column_id,position,priority,due_date,label_ids,cover_color,checklists,archived - Required on create:
title,column_id - Board linkage:
user.todo_boardmust exist — if not, create it before creating a card, or return an error instructing the user to create a board first viaupdate_todo_board. - List filters:
column_id,archived,priority - Attributes hash:
id,title,column_id,position,priority,due_date,archived
Commit after this task: also grant todos: true on the active fixture token if you want the integration tests above to pass — or keep a domain-by-domain permissions fixture.
Phase 6 — Read-only domains
Task 39: Gamification read tools
Files:
- Create:
app/models/mcp/tools/gamification/get_profile.rb - Create:
app/models/mcp/tools/gamification/list_xp_transactions.rb - Create:
app/models/mcp/tools/gamification/get_xp_transaction.rb - Create:
app/models/mcp/tools/gamification/list_user_achievements.rb - Create:
app/models/mcp/tools/gamification/get_user_achievement.rb - Create:
app/models/mcp/tools/gamification/list_challenges.rb - Create:
app/models/mcp/tools/gamification/get_challenge.rb - Create:
app/models/mcp/tools/gamification/list_challenge_participations.rb - Create:
app/models/mcp/tools/gamification/get_challenge_participation.rb - Create:
test/integration/mcp/tools/gamification_test.rb
Permission key: gamification. All tools use mcp_action "read".
- Step 1: Profile get
# app/models/mcp/tools/gamification/get_profile.rb
class Mcp::Tools::Gamification::GetProfile < Mcp::BaseTool
tool_name "get_gamification_profile"
mcp_domain "gamification"
mcp_action "read"
description "Get the current user's gamification profile (XP and level)"
input_schema(properties: {})
def self.call(**)
profile = user.gamification_profile
return not_found_response("gamification_profile") unless profile
json_response(
id: profile.id,
total_xp: profile.total_xp,
level: profile.level,
finance_xp: profile.finance_xp,
sports_xp: profile.sports_xp,
habits_xp: profile.habits_xp,
tools_xp: profile.tools_xp,
featured_achievements: profile.featured_achievements
)
end
end
- Step 2: XP transactions list
# app/models/mcp/tools/gamification/list_xp_transactions.rb
class Mcp::Tools::Gamification::ListXpTransactions < Mcp::BaseTool
include Mcp::Tools::Concerns::Listable
tool_name "list_xp_transactions"
mcp_domain "gamification"
mcp_action "read"
description "List XP transactions (most recent first)"
input_schema(
properties: {
page: { type: "integer" },
category: { type: "string", enum: XpTransaction::CATEGORIES }
}
)
def self.call(page: 1, category: nil)
scope = user.xp_transactions.order(created_at: :desc)
scope = scope.where(category: category) if category.present?
records = paginate(scope, page: page)
format_collection(records, page: page) do |tx|
"[ID: #{tx.id}] #{tx.created_at.to_date} | #{tx.category} | +#{tx.amount} XP | #{tx.description}"
end
end
end
- Step 3: XP transaction get
# app/models/mcp/tools/gamification/get_xp_transaction.rb
class Mcp::Tools::Gamification::GetXpTransaction < Mcp::BaseTool
tool_name "get_xp_transaction"
mcp_domain "gamification"
mcp_action "read"
description "Fetch a single XP transaction"
input_schema(properties: { id: { type: "integer" } }, required: [ "id" ])
def self.call(id:)
tx = user.xp_transactions.find_by(id: id)
return not_found_response("xp_transaction") unless tx
json_response(
id: tx.id,
amount: tx.amount,
category: tx.category,
description: tx.description,
source_type: tx.source_type,
source_id: tx.source_id,
created_at: tx.created_at
)
end
end
- Step 4: User achievements list
# app/models/mcp/tools/gamification/list_user_achievements.rb
class Mcp::Tools::Gamification::ListUserAchievements < Mcp::BaseTool
include Mcp::Tools::Concerns::Listable
tool_name "list_user_achievements"
mcp_domain "gamification"
mcp_action "read"
description "List achievements the current user has unlocked"
input_schema(properties: { page: { type: "integer" } })
def self.call(page: 1)
scope = user.user_achievements.includes(:achievement).order(unlocked_at: :desc)
records = paginate(scope, page: page)
format_collection(records, page: page) do |ua|
"[ID: #{ua.id}] #{ua.unlocked_at.to_date} | #{ua.achievement.name} (#{ua.achievement.rarity})"
end
end
end
- Step 5: User achievement get
# app/models/mcp/tools/gamification/get_user_achievement.rb
class Mcp::Tools::Gamification::GetUserAchievement < Mcp::BaseTool
tool_name "get_user_achievement"
mcp_domain "gamification"
mcp_action "read"
description "Fetch a single user achievement"
input_schema(properties: { id: { type: "integer" } }, required: [ "id" ])
def self.call(id:)
record = user.user_achievements.includes(:achievement).find_by(id: id)
return not_found_response("user_achievement") unless record
json_response(
id: record.id,
unlocked_at: record.unlocked_at,
achievement_id: record.achievement_id,
name: record.achievement.name,
description: record.achievement.description,
rarity: record.achievement.rarity,
category: record.achievement.category
)
end
end
- Step 6: Challenges list
# app/models/mcp/tools/gamification/list_challenges.rb
class Mcp::Tools::Gamification::ListChallenges < Mcp::BaseTool
include Mcp::Tools::Concerns::Listable
tool_name "list_challenges"
mcp_domain "gamification"
mcp_action "read"
description "List the user's challenges"
input_schema(
properties: {
page: { type: "integer" },
active: { type: "boolean", description: "Filter to active challenges only" }
}
)
def self.call(page: 1, active: nil)
scope = user.challenges.order(end_date: :desc)
scope = scope.where(active: active) unless active.nil?
records = paginate(scope, page: page)
format_collection(records, page: page) do |c|
"[ID: #{c.id}] #{c.name} | #{c.challenge_type} | #{c.category} | #{c.start_date}..#{c.end_date}"
end
end
end
- Step 7: Challenge get
# app/models/mcp/tools/gamification/get_challenge.rb
class Mcp::Tools::Gamification::GetChallenge < Mcp::BaseTool
tool_name "get_challenge"
mcp_domain "gamification"
mcp_action "read"
description "Fetch a single challenge"
input_schema(properties: { id: { type: "integer" } }, required: [ "id" ])
def self.call(id:)
c = user.challenges.find_by(id: id)
return not_found_response("challenge") unless c
json_response(
id: c.id, name: c.name, description: c.description, challenge_type: c.challenge_type,
category: c.category, requirement_type: c.requirement_type, target_value: c.target_value,
xp_reward: c.xp_reward, start_date: c.start_date, end_date: c.end_date, active: c.active
)
end
end
- Step 8: Challenge participations list
# app/models/mcp/tools/gamification/list_challenge_participations.rb
class Mcp::Tools::Gamification::ListChallengeParticipations < Mcp::BaseTool
include Mcp::Tools::Concerns::Listable
tool_name "list_challenge_participations"
mcp_domain "gamification"
mcp_action "read"
description "List challenge participations for the current user"
input_schema(properties: { page: { type: "integer" }, status: { type: "string", enum: %w[active completed failed] } })
def self.call(page: 1, status: nil)
scope = user.challenge_participations.includes(:challenge).order(created_at: :desc)
scope = scope.where(status: status) if status.present?
records = paginate(scope, page: page)
format_collection(records, page: page) do |p|
"[ID: #{p.id}] #{p.challenge.name} | #{p.status} | progress #{p.progress}/#{p.challenge.target_value}"
end
end
end
- Step 9: Challenge participation get
# app/models/mcp/tools/gamification/get_challenge_participation.rb
class Mcp::Tools::Gamification::GetChallengeParticipation < Mcp::BaseTool
tool_name "get_challenge_participation"
mcp_domain "gamification"
mcp_action "read"
description "Fetch a single challenge participation"
input_schema(properties: { id: { type: "integer" } }, required: [ "id" ])
def self.call(id:)
p = user.challenge_participations.includes(:challenge).find_by(id: id)
return not_found_response("challenge_participation") unless p
json_response(
id: p.id, status: p.status, progress: p.progress, completed_at: p.completed_at,
challenge_id: p.challenge_id, challenge_name: p.challenge.name,
target_value: p.challenge.target_value
)
end
end
- Step 10: Integration test
# test/integration/mcp/tools/gamification_test.rb
require "test_helper"
class Mcp::Tools::GamificationTest < ActionDispatch::IntegrationTest
setup do
@raw_token = "rawtoken_active_abcdef0123456789"
@user = users(:primary)
end
test "get_gamification_profile returns user's profile" do
body = rpc_tool("get_gamification_profile", {})
json = JSON.parse(body.dig("result", "content", 0, "text"))
assert json.key?("total_xp")
assert json.key?("level")
end
test "list_xp_transactions paginates" do
body = rpc_tool("list_xp_transactions", { page: 1 })
text = body.dig("result", "content", 0, "text")
assert_match(/Page 1/, text)
end
test "list_challenges filters by active" do
body = rpc_tool("list_challenges", { active: true })
text = body.dig("result", "content", 0, "text")
assert_match(/Page 1/, text)
end
private
def rpc_tool(name, args)
post "/mcp/#{@raw_token}",
params: { jsonrpc: "2.0", method: "tools/call", params: { name: name, arguments: args }, id: 1 }.to_json,
headers: { "Content-Type" => "application/json" }
JSON.parse(response.body)
end
end
- Step 11: Run and commit
bin/rails test test/integration/mcp/tools/gamification_test.rb
Expected: 3 tests pass.
git add app/models/mcp/tools/gamification/ test/integration/mcp/tools/gamification_test.rb
git commit -m "Add gamification read-only MCP tools"
Task 40: Analytics tools
Files:
- Create:
app/models/mcp/tools/analytics/get_finance_dashboard.rb - Create:
app/models/mcp/tools/analytics/get_analytics_summary.rb - Create:
app/models/mcp/tools/analytics/get_gamification_stats.rb -
Create:
test/integration/mcp/tools/analytics_test.rb - Step 1: Finance dashboard
# app/models/mcp/tools/analytics/get_finance_dashboard.rb
class Mcp::Tools::Analytics::GetFinanceDashboard < Mcp::BaseTool
tool_name "get_finance_dashboard"
mcp_domain "analytics"
mcp_action "read"
description "Return the Finance Dashboard data: KPIs, patrimonial history, institution breakdown, 12-month projection, account cards"
input_schema(
properties: { date: { type: "string", description: "Reference ISO date; defaults to today" } }
)
def self.call(date: nil)
ref = date.present? ? Date.parse(date) : Date.current
agg = Dashboard::DataAggregator.new(user, date: ref)
json_response(
reference_date: ref,
kpis: agg.kpis,
patrimonial_history: agg.patrimonial_history,
institution_breakdown: agg.institution_breakdown,
projection_12_months: agg.projection_12_months,
account_cards: agg.account_cards
)
rescue ArgumentError => error
text_response("Invalid date: #{error.message}")
end
end
- Step 2: Analytics summary
# app/models/mcp/tools/analytics/get_analytics_summary.rb
class Mcp::Tools::Analytics::GetAnalyticsSummary < Mcp::BaseTool
tool_name "get_analytics_summary"
mcp_domain "analytics"
mcp_action "read"
description "Return patrimony growth and projection data used on the Analytics page"
input_schema(
properties: { future_months: { type: "integer", description: "Projection horizon (default 12)" } }
)
def self.call(future_months: 12)
calc = User::AnalyticsCalculator.new(user)
json_response(
future_projection: calc.future_projection_chart(future_months: future_months),
growth_registry: calc.monthly_growth_chart_data,
growth_mom: calc.monthly_growth_chart_data_mom,
heatmap_registry: calc.patrimony_growth_heatmap_data,
heatmap_mom: calc.patrimony_growth_heatmap_data_mom
)
end
end
- Step 3: Gamification stats
# app/models/mcp/tools/analytics/get_gamification_stats.rb
class Mcp::Tools::Analytics::GetGamificationStats < Mcp::BaseTool
tool_name "get_gamification_stats"
mcp_domain "analytics"
mcp_action "read"
description "Aggregate gamification stats: XP by category over last 12 months, achievements earned, challenge counts"
input_schema(properties: {})
def self.call(**)
profile = user.gamification_profile
xp_by_category = user.xp_transactions
.where(created_at: 12.months.ago.beginning_of_month..Time.current)
.group(:category)
.sum(:amount)
xp_monthly = user.xp_transactions
.where(created_at: 12.months.ago.beginning_of_month..Time.current)
.group("strftime('%Y-%m', created_at)")
.sum(:amount)
json_response(
total_xp: profile&.total_xp || 0,
level: profile&.level || 1,
xp_by_category_12mo: xp_by_category,
xp_by_month_12mo: xp_monthly,
achievements_earned: user.user_achievements.count,
challenges_active: user.challenge_participations.where(status: :active).count,
challenges_completed: user.challenge_participations.where(status: :completed).count
)
end
end
- Step 4: Integration test
# test/integration/mcp/tools/analytics_test.rb
require "test_helper"
class Mcp::Tools::AnalyticsTest < ActionDispatch::IntegrationTest
setup do
@raw_token = "rawtoken_active_abcdef0123456789"
end
test "get_finance_dashboard returns the expected top-level keys" do
body = rpc_tool("get_finance_dashboard", {})
json = JSON.parse(body.dig("result", "content", 0, "text"))
%w[reference_date kpis patrimonial_history institution_breakdown projection_12_months account_cards].each do |key|
assert json.key?(key), "missing key: #{key}"
end
end
test "get_analytics_summary returns growth and projection keys" do
body = rpc_tool("get_analytics_summary", { future_months: 6 })
json = JSON.parse(body.dig("result", "content", 0, "text"))
%w[future_projection growth_registry growth_mom heatmap_registry heatmap_mom].each do |key|
assert json.key?(key), "missing key: #{key}"
end
end
test "get_gamification_stats returns counts" do
body = rpc_tool("get_gamification_stats", {})
json = JSON.parse(body.dig("result", "content", 0, "text"))
assert json.key?("total_xp")
assert json.key?("achievements_earned")
end
private
def rpc_tool(name, args)
post "/mcp/#{@raw_token}",
params: { jsonrpc: "2.0", method: "tools/call", params: { name: name, arguments: args }, id: 1 }.to_json,
headers: { "Content-Type" => "application/json" }
JSON.parse(response.body)
end
end
- Step 5: Run and commit
bin/rails test test/integration/mcp/tools/analytics_test.rb
Expected: 3 tests pass.
git add app/models/mcp/tools/analytics/ test/integration/mcp/tools/analytics_test.rb
git commit -m "Add analytics MCP tools wrapping existing calculators"
Phase 7 — Final verification
Task 41: Full CI
- Step 1: Run the full test suite
bin/ci
Expected: all steps pass (test suite, brakeman, bundler-audit, rubocop).
- Step 2: Fix any issues inline
For each failure: read the error, fix the offending file, rerun. Common expected issues:
- Rubocop complaints on new files → run
bin/rubocop -Ato auto-correct. - Brakeman false positives → add specific
# brakeman:ignorecomments with justification where applicable. -
Missing fixtures → add minimal rows to the relevant
test/fixtures/<model>.ymlfiles. - Step 3: Final commit
git add -A
git commit -m "Pass bin/ci after User MCP implementation"
Task 42: Manual smoke test
- Step 1: Boot dev server
bin/dev
- Step 2: Sign in, create a token
Visit http://localhost:3000/settings/mcp. Create a token granting accounts, gamification, analytics. Copy the raw value.
- Step 3: Call
tools/list
curl -X POST http://localhost:3000/mcp/<RAW_TOKEN> \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'
Expected: response contains tool names from the three permitted domains (list_accounts, get_gamification_profile, get_finance_dashboard, etc.) and excludes non-permitted tools.
- Step 4: Call
tools/callon a read tool
curl -X POST http://localhost:3000/mcp/<RAW_TOKEN> \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_gamification_profile","arguments":{}},"id":2}'
Expected: JSON response with XP and level.
- Step 5: Verify activity log
Visit http://localhost:3000/settings/mcp/activity. Expect rows for each curl call.
Success criteria
- All task checkboxes marked complete.
bin/cipasses.- Manual smoke test succeeds end-to-end: token creation,
tools/list,tools/call, activity log. - A user with a token granting only
accountsandanalyticssees exactly those tools intools/listand is forbidden (tool not present) for everything else. - Cross-user isolation: a token for user A never returns user B's records.