User MCP — Design Spec

Date: 2026-04-22 Owner: Yan Status: Draft for review Related docs: docs/mcp/export_mcp_feature/ (porting playbook)

1. Summary

Add a Model Context Protocol (MCP) server inside the Lifehub Rails app so each user can connect AI clients (Claude Desktop, Claude Code, etc.) to their own Lifehub data. The server exposes Create/Read/Update tools over the user's finance and life-tracking modules, Read tools over gamification, and a small set of Read tools that wrap the existing analytics calculators.

The implementation follows the porting playbook at docs/mcp/export_mcp_feature/, re-scoped from a tenant (OrganizationContext) model to a per-user (UserContext) model. No admin MCP is built — Lifehub is single-user-per-account and has no cross-user operations story.

2. Goals

  • Every user can create, name, regenerate, and revoke personal MCP tokens from /settings/mcp.
  • AI clients connect by URL only: POST https://<host>/mcp/:token.
  • Tools cover ~20 model families across 15 permission domains (see Section 7).
  • Analytics tools return the same data the Finance Dashboard and Analytics page render, so AI clients can build charts without re-implementing queries.
  • Every tool call is audited in mcp_activity_logs and viewable in the user's settings.

3. Non-goals

  • No admin/cross-user MCP server.
  • No Delete tools. Users delete data through the existing UI.
  • No mutation tools for gamification (profile, XP transactions, achievements, challenges, participations are read-only).
  • No websocket transport. Streamable HTTP only.
  • No feature flags, rollout staging, or backwards-compat shims — ship once (decision #4).

4. Key decisions

# Decision Choice Rationale
1 Who can create tokens Every user Lifehub is a multi-user platform; MCP is a user-level capability
2 Permission granularity Per-domain (one toggle per domain grants all permitted actions for that domain) Balanced UX; 15 toggles fit cleanly in a settings page
3 Analytics scope Finance Dashboard + Analytics page + gamification reads Wraps expensive existing calculators; gives AI clients pre-aggregated data
4 Phasing One release User preference
5 Token UI location New /settings/mcp namespace MCP is cross-cutting; doesn't belong under finance_settings
6 Side effects (XP, notifications, challenge progress) Fire normally — MCP calls are first-class user actions Consistency with UI behavior; no Current.mcp_request branching needed
7 Tool file layout Approach 1: one file per (domain, action) under Mcp::Tools::<Domain>::<Action> Matches export docs; smallest, most focused tool classes

5. Architecture

5.1 Runtime flow

AI client
  -> POST /mcp/:token
  -> Mcp::RackApp
       - resolve raw token via SHA256 digest lookup
       - reject if revoked or missing (401)
       - rate-limit check (60/min/token, in-memory) (429)
       - set Current.user = token.user
       - build Mcp::UserContext { user:, token: }
  -> Mcp::ServerBuilder.build(context)
       - filter tool classes by token.permissions (domain keys)
       - return an MCP::Server wired with the permitted tools
  -> StreamableHTTPTransport dispatches tools/list or tools/call
  -> Tool runs; queries start from `user.<association>`
  -> Prepended logging module writes Mcp::ActivityLog row
  -> JSON-RPC response returned
  -> Thread-local context cleared in ensure block

5.2 Component inventory

File Role Source
app/middleware/mcp/base_rack_app.rb Shared transport, digest lookup, rate limiting, transport reuse Adapted from templates/base_rack_app.rb.example
app/middleware/mcp/rack_app.rb User-scoped: resolves UserContext, sets Current.user, dispatches Adapted from templates/rack_app.rb.example
app/models/mcp/token.rb Token digest + permissions + lifecycle (create/regenerate/revoke) Adapted from templates/token.rb.example
app/models/mcp/activity_log.rb Per-call audit row Adapted from template
app/models/mcp/user_context.rb Thread-local { user:, token: } holder (replaces OrganizationContext) New (user-scoped variant)
app/models/mcp/base_tool.rb Exposes user and token; prepends logging module Adapted from template
app/models/mcp/server_builder.rb Namespace-scan discovery; filter by <domain> permission key Adapted from template
app/models/mcp/tools/concerns/formattable.rb json_response, text_response, not_found_response helpers Copy as-is
app/models/mcp/tools/concerns/listable.rb Pagination + plain-text list rendering Copy as-is
app/models/mcp/tools/concerns/tool_metadata.rb DSL: mcp_domain, mcp_action Copy as-is
app/models/mcp/tools/**/*.rb ~90 tool classes (see Section 7) New

5.3 Things deliberately dropped from the export docs

  • Admin MCP (Mcp::AdminRackApp, Mcp::Admin::*) — not needed
  • OrganizationContext → replaced by UserContext
  • marketplace domain root → tools query directly from user
  • Current.mcp_request flag — not used; side effects fire normally per decision #6
  • Approvable concern — no approval workflows in scope
  • Organization membership policies — replaced with simple current_user == record.user checks

6. Data model

6.1 Migration: mcp_tokens

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

6.2 Migration: mcp_activity_logs

create_table :mcp_activity_logs do |t|
  t.references :mcp_token, null: false, foreign_key: true
  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
  t.json :result_summary
  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]

6.3 Token model behaviors

  • Mcp::Token.find_by_raw_token(raw) — SHA256 digests the input, looks up by digest, rejects if revoked_at.present?. Updates last_used_at on successful lookup.
  • Mcp::Token.generate_raw_token — returns a random 40-char URL-safe string.
  • #regenerate! — replaces token_digest and token_prefix, invalidating the old raw token. Returns the new raw token (shown once).
  • #revoke! — sets revoked_at = Time.current.
  • #permits?(domain) — boolean check against permissions[domain].

Validations: name presence, token_digest presence + uniqueness, scope inclusion in %w[user].

7. Permissions and tool inventory

7.1 Permission domains

The permissions JSON column stores boolean flags keyed by domain:

{
  "accounts": true,
  "expenses": true,
  "debts": false,
  "investments": true,
  "goals": false,
  "habits": true,
  "sports": false,
  "birthdays": false,
  "market_lists": false,
  "notes": true,
  "countdowns": false,
  "construction": false,
  "todos": false,
  "gamification": true,
  "analytics": true
}

15 keys total. Missing keys default to false. The builder filters tools via token.permits?(tool.mcp_domain).

7.2 Tool classes

Each tool is one file at app/models/mcp/tools/<domain>/<action>.rb, declares tool_name, description, mcp_domain, mcp_action, and input_schema, and defines self.call(**args) that starts queries from user.<association>.

Writable domains (List / Get / Create / Update)

Permission domain Model(s) Tools Count
accounts Account list/get/create/update 4
expenses Expense list/get/create/update 4
debts Debt, DebtPayment list/get/create/update × 2 models 8
investments Investment list/get/create/update 4
goals Goal list/get/create/update 4
habits Habit, HabitCompletion list/get/create/update × 2 8
sports Sport, ActivityLog list/get/create/update × 2 8
birthdays Birthday list/get/create/update 4
market_lists MarketList, MarketListItem list/get/create/update × 2 8
notes Note list/get/create/update 4
countdowns Countdown list/get/create/update 4
construction ConstructionProject, ConstructionPhase, ConstructionExpense list/get/create/update × 3 12
todos TodoBoard (singleton → get/update only), TodoCard (list/get/create/update) 2 + 4 6

Writable subtotal: 78 tools.

TodoBoard is has_one :todo_board on User per AGENTS.md, so Create and List don't apply — Get returns the user's board, Update mutates it.

Read-only domains

Permission domain Models Tools Count
gamification GamificationProfile (singleton), XpTransaction, UserAchievement, Challenge, ChallengeParticipation get_gamification_profile + (list+get) × 4 9
analytics (none — wraps calculators) get_finance_dashboard, get_analytics_summary, get_gamification_stats 3

Read-only subtotal: 12 tools.

Grand total: ~90 tools.

7.3 Analytics tool contracts

Each analytics tool wraps an existing calculator — no query re-implementation.

  • get_finance_dashboard (domain analytics, action read)
    • Args: date (optional ISO date, defaults to today)
    • Returns: JSON mirroring Dashboard::DataAggregator: kpis, patrimonial_history, institution_breakdown, projection_12_months, account_cards.
  • get_analytics_summary (domain analytics, action read)
    • Args: future_months (integer, default 12)
    • Returns: JSON with future_projection, growth_registry, growth_mom, heatmap_registry, heatmap_mom, live_patrimony — all from User::AnalyticsCalculator.
  • get_gamification_stats (domain analytics, action read)
    • Args: none
    • Returns: total XP, current level, XP by category (last 12 months), achievements earned count, active/completed challenges count. Computed from user.xp_transactions, user.user_achievements, user.challenge_participations.

All analytics output is JSON-structured (arrays of {date, value}, KPI objects with amount + currency) — shaped for AI chart generation.

8. Token & activity management UI

Routes under /settings/mcp:

GET    /settings/mcp                       → Mcp::Settings#show (token index)
GET    /settings/mcp/tokens/new            → Mcp::Tokens#new
POST   /settings/mcp/tokens                → Mcp::Tokens#create (shows raw token once)
GET    /settings/mcp/tokens/:id/edit       → Mcp::Tokens#edit (permission toggles)
PATCH  /settings/mcp/tokens/:id            → Mcp::Tokens#update
POST   /settings/mcp/tokens/:id/regenerate → Mcp::Tokens#regenerate
DELETE /settings/mcp/tokens/:id            → Mcp::Tokens#destroy (revoke)
GET    /settings/mcp/activity              → Mcp::Activity#index (paginated log)

Controllers live at app/controllers/mcp/{settings_controller,tokens_controller,activity_controller}.rb. Views under app/views/mcp/. Pagy for pagination on activity.

Policies: Mcp::TokenPolicy and Mcp::ActivityPolicy — all actions require current_user == record.user (or current_user == record.mcp_token.user). No admin override.

UI particulars:

  • New-token form shows 15 permission checkboxes grouped visually (Finance / Life / Gamification / Analytics) with help text per domain.
  • After create or regenerate, the raw token is displayed once in a copy-to-clipboard panel with a warning that it will not be shown again.
  • Token index shows name, token_prefix, last_used_at, revoked_at, permission summary, and action buttons.
  • Activity view shows tool_name, domain, action_type, status_code, truncated arguments, created_at, with a "View details" expander for full JSON.

9. Security and operations

  • Transport: Streamable HTTP. Token is in the URL path. HTTPS-only in production.
  • Token storage: only token_digest (SHA256) and token_prefix (first 6 chars for display). Raw token shown once.
  • Rate limit: 60 req/min/token, in-memory. Documented as a single-process default — if Lifehub later horizontally scales web processes, move to Solid Cache or an API gateway.
  • Thread safety: context lives in Thread.current keys; cleared in ensure blocks in the Rack app.
  • Audit logging: a module prepended into Mcp::BaseTool writes a log row after every call, recording tool name, domain, action, sanitized args, and a truncated result summary.
  • Argument sanitization: the logging module redacts keys matching /password|token|secret/i before persisting.

10. Side effects and gamification

MCP calls run as first-class user actions. Model callbacks, Gamification::XpAwarder, Noticed notifications, and challenge progress all fire normally. There is no Current.mcp_request branching.

Implication: an AI client that polls get_analytics_summary daily will trigger the once-per-day XP award for reviewing analytics (the existing AnalyticsController#index behavior). This is accepted — the user chose consistency with UI behavior over a gamification carve-out.

11. Testing

Following docs/mcp/export_mcp_feature/05_security_ops_and_testing.md.

Model tests (test/models/mcp/)

  • Token: raw-lookup returns active tokens only; revoked tokens fail lookup; regenerate rotates digest + prefix; permits? reads the permissions hash correctly.
  • ActivityLog: belongs to token and user; validation presence.

Rack tests (test/middleware/mcp/ or integration)

  • Missing token → 401.
  • Invalid token → 401.
  • Revoked token → 401.
  • Rate limit → 429 after 60 requests in a minute.
  • Valid token → tools/list succeeds.

Builder tests (test/models/mcp/server_builder_test.rb)

  • Permitted domain returns its tool classes.
  • Forbidden domain's tools are excluded.
  • An empty permission hash exposes zero tools.

Tool tests (test/models/mcp/tools/)

  • Each tool is tenant-scoped: querying with a user's token only returns that user's rows (fixture with two users; token for user A must not see user B's data).
  • List tools include record IDs in results.
  • Validation failures return a readable text_response.
  • Successful calls create an Mcp::ActivityLog row.
  • Analytics tools return the expected top-level keys (smoke test; calculator internals already tested).

UI tests (test/system/mcp/)

  • User can create, regenerate, revoke a token.
  • New-token page shows raw token exactly once.
  • Activity page paginates and filters by domain.
  • Non-owners cannot access another user's tokens (403).

12. Implementation order

Even as a single release, implementation proceeds in this order to surface issues early:

  1. Gemfile: add gem "mcp", "~> 0.10.0"; bundle.
  2. Migrations for mcp_tokens and mcp_activity_logs.
  3. Core runtime: Mcp::Token, Mcp::ActivityLog, Mcp::UserContext, Mcp::BaseTool, Mcp::ServerBuilder, concerns.
  4. Rack apps: Mcp::BaseRackApp, Mcp::RackApp. Mount in config/routes.rb.
  5. One end-to-end tool domain (accounts: list/get/create/update) to validate the pipeline.
  6. Token + Activity controllers, routes, views, policies.
  7. Remaining CRU tool domains (expenses → todos) in order from Section 7.2.
  8. Gamification read-only tools.
  9. Analytics tools (wrap Dashboard::DataAggregator, User::AnalyticsCalculator).
  10. Full test suite (bin/ci) green.

13. Open questions / assumptions

  • Assumption: Mcp::Token.scope is retained with default "user" so future extensions (e.g., read-only service tokens) don't require a schema change. No UI exposes it.
  • Assumption: The existing Pagy gem (per AGENTS.md) is the pagination engine for activity log views.
  • Assumption: GamificationProfile is has_one :gamification_profile on User; tool returns the user's profile directly. Confirmed against models list.
  • Assumption: TodoBoard is has_one :todo_board; Get + Update only. Create/List would be meaningless. Confirmed against AGENTS.md user sketch.

14. Out of scope / future

  • Admin MCP for staff cross-user operations.
  • Delete tools (explicitly excluded).
  • Mutation tools for gamification.
  • Websocket transport.
  • Distributed rate-limiting (Solid Cache or gateway) — needed only if Lifehub scales beyond a single Puma process.
  • MCP-originated audit flag on writes (Current.mcp_request) — not used in this design; can be added later without schema changes.