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 active fixture in test/fixtures/mcp_tokens.yml so 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 as where(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 (DebtPayment belongs_to :user per 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; return not_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_amount writes if tracking_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 via user.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); return not_found_response("habit") if missing before creating.
  • List filters: habit_id (required for list since completions are habit-scoped), optional from/to dates
  • 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: Note has has_rich_text :content. Accept content_plain (string) and assign via record.content = args[:content_plain] before save. Return content_plain: record.content.to_plain_text in 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 per has_one :todo_board in 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_board must exist — if not, create it before creating a card, or return an error instructing the user to create a board first via update_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 -A to auto-correct.
  • Brakeman false positives → add specific # brakeman:ignore comments with justification where applicable.
  • Missing fixtures → add minimal rows to the relevant test/fixtures/<model>.yml files.

  • 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/call on 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/ci passes.
  • Manual smoke test succeeds end-to-end: token creation, tools/list, tools/call, activity log.
  • A user with a token granting only accounts and analytics sees exactly those tools in tools/list and is forbidden (tool not present) for everything else.
  • Cross-user isolation: a token for user A never returns user B's records.