User MCP — Implementation Review

Date: 2026-04-23 Branch: mcp Spec: docs/superpowers/specs/2026-04-22-user-mcp-design.md Plan: docs/superpowers/plans/2026-04-22-user-mcp.md Porting playbook source: docs/mcp/export_mcp_feature/


1. Outcome at a glance

A per-user Model Context Protocol (MCP) server is now mounted inside the Lifehub Rails app at POST /mcp/:token. Users create personal tokens at /settings/mcp, grant per-domain permissions, and connect any MCP-compatible AI client by URL.

Metric Value
Commits on mcp branch (vs. main) 49 MCP-related
Files added under app/{models,middleware,controllers,policies,views}/mcp/ 112
Tool classes under app/models/mcp/tools/ 93
MCP test files under test/{models,integration,system}/mcp/ 29
MCP tests passing 127 / 127 (0 failures, 0 errors, 0 skips)
bin/ci (full suite, rubocop, brakeman, bundler-audit) green

Manual smoke test path: boot bin/dev, sign in, visit /settings/mcp, create a token with the desired permission toggles, copy the raw token shown once, then curl -X POST $HOST/mcp/$TOKEN -H 'Accept: application/json, text/event-stream' -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1}'.


2. Architecture

2.1 Request lifecycle

AI client
  -> POST /mcp/:token
  -> Mcp::RackApp (extends Mcp::BaseRackApp)
       1. extract token from PATH_INFO
       2. Mcp::Token.find_by_raw_token (SHA256 digest lookup)
       3. authorize: token active and belongs to a user
       4. rate-limit check (60 req / 60s window, in-memory mutex)
       5. update last_used_at
       6. set Current.user = token.user
       7. build per-token StreamableHTTPTransport, swap server in
  -> Mcp::ServerBuilder.build(Mcp::UserContext.new(user, token:))
       - filter tool classes by token.permits?(domain)
       - return MCP::Server with permitted tools wired
  -> tool dispatch
       - tool.call(**args) runs against user.<association>
       - prepended CallLogging writes Mcp::ActivityLog row
  -> JSON-RPC response
  -> ensure: clear thread-local context + Current.user

2.2 Component map

app/middleware/mcp/
  base_rack_app.rb        Shared transport: rate limit, transport reuse, error envelopes
  rack_app.rb             User-scoped: authorize + UserContext + Current.user lifecycle

app/models/mcp/
  token.rb                15 PERMISSION_KEYS, generate/regenerate/revoke, digest lookup
  activity_log.rb         Per-call audit, sensitive-key redaction, result preview
  user_context.rb         Per-request {user, token} holder
  base_tool.rb            MCP::Tool subclass; thread-local context + CallLogging module
  server_builder.rb       Namespace scan, permission filtering, MCP::Server assembly
  tools/
    concerns/
      formattable.rb      json_response, text_response, not_found_response
      listable.rb         paginate(scope, page:), format_collection(records, &block)
      tool_metadata.rb    mcp_domain, mcp_action class DSL
    accounts/, expenses/, debts/, debt_payments/, investments/, goals/,
    habits/, habit_completions/, sports/, activity_logs/, birthdays/,
    market_lists/, market_list_items/, notes/, countdowns/,
    construction_projects/, construction_phases/, construction_expenses/,
    todo_boards/, todo_cards/, gamification/, analytics/

app/controllers/mcp/
  base_controller.rb      authenticate_user! + application layout
  settings_controller.rb  /settings/mcp index of tokens + raw-reveal flash
  tokens_controller.rb    new/create/edit/update/destroy + regenerate
  activity_controller.rb  /settings/mcp/activity (Pagy paginated)

app/policies/mcp/
  token_policy.rb         current_user == record.user (ownership)
  activity_policy.rb      Scope-based filtering by user

app/views/mcp/
  settings/show.html.erb  Token list + permission summary + actions
  tokens/{new,edit,_form,_reveal}.html.erb  Grouped permission UI
  activity/index.html.erb Paginated activity rows

config/routes.rb          mount Mcp::RackApp.new, at: "/mcp"
                          namespace :mcp, path: "settings/mcp" { ... }

db/migrate/
  *_create_mcp_tokens.rb         user_id, name, token_digest (unique), token_prefix, scope, permissions(json), last_used_at, revoked_at
  *_create_mcp_activity_logs.rb  mcp_token_id, user_id, tool_name, domain, action_type, arguments(json), result_summary(json), status_code

config/application.rb     autoload + eager_load paths add app/middleware

2.3 Decisions vs. the porting playbook

Aspect Playbook (multi-tenant origin) This implementation
Scope object OrganizationContext w/ tenant + domain root UserContext w/ user + token
Tenant resolution From token.organization From token.user; sets Current.user so existing app code Just Works
Admin MCP Optional second mount at /admin/mcp Dropped — Lifehub has no cross-user ops story
Permission keys per-(domain, action) — e.g. leads_update per-domain only — 15 keys, decision #2
Current.mcp_request audit flag Set to tag downstream writes Not used; MCP calls fire normal side effects (decision #6)
Raw token display Once at create/regenerate Same (flash[:mcp_raw_token])
Rate limit 60 req/min/token in-memory Same

3. Tool surface (final inventory)

15 permission domains, 22 tool namespaces, 93 tool classes:

Permission key Namespace(s) Tool count Notes
accounts Accounts 4 List/Get/Create/Update
expenses Expenses 4  
debts Debts + DebtPayments 8 DebtPayments grouped under same key
investments Investments 4  
goals Goals 4 progress_percentage exposed in attribute hash
habits Habits + HabitCompletions 8 Completions scoped via parent habit
sports Sports + ActivityLogs 8 Tool names: list_sport_logs etc. (renamed for clarity)
birthdays Birthdays 4  
market_lists MarketLists + MarketListItems 8 Items scoped via parent list
notes Notes 4 ActionText round-trip via content_plain field
countdowns Countdowns 4  
construction ConstructionProjects + Phases + Expenses 12 Phases/Expenses scoped via parent project
todos TodoBoards (singleton: get + update) + TodoCards 6 Auto-creates board if missing
gamification Gamification 9 Profile + XP txns + achievements + challenges + participations (read-only)
analytics Analytics 3 get_finance_dashboard, get_analytics_summary, get_gamification_stats (wrap calculators)

Conventions enforced across every tool:

  • Inherits Mcp::BaseTool, declares tool_name / mcp_domain / mcp_action / description / input_schema.
  • Queries start from user.<association> (or, for nested models, scoped via parent: user.market_lists.exists?(id) then MarketListItem.where(...)).
  • Create/Update use explicit PERMITTED constants and args.slice(*PERMITTED) for mass-assignment safety.
  • Validation failures return a readable text_response("Failed to ...: <messages>").
  • List tools paginate via shared Listable concern (25/page default).
  • Get/Update on nested resources verify parent ownership before touching the child.

4. Security posture

Control Implementation
Token storage SHA256 digest only; raw shown once at create/regenerate
Token lookup find_by(token_digest: ..., revoked_at: nil) — single query, unique index
Cross-user data isolation Every tool starts from user.<association>; nested models verified through user-owned parent before touch
Mass assignment PERMITTED constants per writable tool; args.slice(*PERMITTED)
Activity-log argument redaction Keys matching /password\|token\|secret/i replaced with [REDACTED]
Authorization (UI) Pundit Mcp::TokenPolicy and Mcp::ActivityPolicy enforce record.user_id == current_user.id
Rate limit 60 req/min/token in-memory, mutex-guarded
Thread safety Thread.current[:mcp_user_context] + ensure block in Rack app for cleanup
Permission filtering Happens before tool discovery — forbidden tools never appear in tools/list
Brakeman scan Clean (no warnings as of branch tip)

Threat model not addressed (out of scope):

  • Token in URL appears in proxy/CDN logs — mitigation is HTTPS-only deploy + short rotation cycle.
  • Distributed rate limiting (Solid Cache or gateway) — needed only if Lifehub scales beyond a single Puma process.

5. Test coverage

127 MCP tests across three layers:

Layer File count Test count What's covered
Models 3 13 Token digest lookup, revocation, regenerate, permits?, sanitize_permissions, ActivityLog argument redaction (added in follow-up), summarize_result truncation, ServerBuilder permission filtering
Integration 24 ~108 Per-domain CRU happy paths, cross-user isolation, activity-log row creation, tools/list permission filtering, rate-limit 429, missing/invalid/revoked-token 401
System 1 2 Token create + revoke flow with Devise sign-in (Capybara)

What integration tests verify per domain (template inherited from Accounts):

  1. list_<domain> returns paginated text
  2. get_<singular> returns the record's attribute hash
  3. create_<singular> persists a row and returns its id
  4. update_<singular> mutates an existing row
  5. Cross-user: a token for user A returns "not found" for user B's records
  6. Activity log row written on success (asserted in Accounts only — pattern verified)

6. Strengths

  1. Faithful execution of the porting playbook. The runtime decisions in docs/mcp/export_mcp_feature/ translate cleanly to the single-user model. Where the playbook over-specified (admin MCP, organization context, mcp_request flag), this implementation drops them rather than carrying dead complexity.
  2. One file per (domain, action), small and focused. Average tool file is ~30 LOC, largest is 62. Each is readable in one screen and edits are localized.
  3. Per-domain permission keys are discoverable in one place (Mcp::Token::PERMISSION_KEYS) and drive both the UI checkbox grouping and the runtime filter.
  4. Analytics tools wrap existing calculators rather than re-implementing queries — Dashboard::DataAggregator and User::AnalyticsCalculator are called directly. Future calculator changes flow through automatically.
  5. No service objects, no new architectural concepts — sticks to Rails conventions and the AGENTS.md house style.
  6. TDD pattern survived the scale. Even with 19 model groups × 4 actions, every domain has integration tests, and they all run in <2 seconds total.
  7. Side-effect fidelity. XP awards, notifications, and challenge progress fire normally on MCP-driven writes — same as UI writes — so the gamification system stays consistent.

7. Trade-offs and known limitations

  1. In-memory rate limiter. Per-process state. Multi-Puma-worker or multi-host deployments will get up to N×60 req/min effective ceiling per token. Documented as acceptable for the current single-process Lifehub deploy.
  2. Token in URL. Practical for MCP host configuration (URL-only setup) but appears in HTTP logs. Mitigation is HTTPS-only + token rotation.
  3. Permissions are coarse (per-domain only). Granting expenses is all-or-nothing for List/Get/Create/Update on Expense. Per-(domain, action) was rejected as over-engineered for a single-user platform (15 toggles vs. 50).
  4. No Delete tools. Explicit decision — destructive deletes belong in the UI where the user can confirm. The MCP can mark records archived/inactive via update.
  5. Tool descriptions are short. They guide the LLM but don't include examples or counter-examples. Real-world AI usage may reveal the need for richer descriptions; that's a follow-on iteration based on observed call patterns.
  6. No server-side enforcement of pagination limits. A client can request page: 999999 — they just get an empty page. The 25/page hard limit prevents large responses, so the risk is bounded.
  7. Activity log retention is unbounded. No purge job. At ~60 calls/min/token sustained, growth is non-trivial. A periodic Solid Queue job to prune logs older than N days is a sensible follow-on.
  8. No audit-flag separating MCP-originated writes from UI writes. Decision #6 (side effects fire normally) means downstream code can't distinguish the source. If a future audit requirement emerges, the data is recoverable by joining mcp_activity_logs against created_at timestamps on records.

8. Follow-up work

Tracked in this PR:

  • Token prefix length (token[0..7] is 8 chars; spec said 6) — cosmetic
  • Dead active? check in Mcp::RackApp#authorized? after find_by_raw_token already filters revoked
  • More system tests (regenerate flow, activity filter view, non-owner 403)
  • Direct unit test for Mcp::ActivityLog.sanitize_arguments

Future iterations (not blocking):

  • Activity log purge job (Solid Queue periodic task)
  • Per-tool description tuning based on observed AI usage patterns
  • Distributed rate limiting if/when Lifehub scales horizontally
  • Optional Current.mcp_request flag if downstream audit requirements emerge

9. How to extend

To add a new permission domain:

  1. Add the key to Mcp::Token::PERMISSION_KEYS
  2. Add the namespace to Mcp::ServerBuilder::TOOL_NAMESPACES
  3. Create tool files at app/models/mcp/tools/<domain>/{list,get,create,update}.rb following the archetypes in the plan
  4. Add the key to permission_groups and permission_help in app/views/mcp/tokens/_form.html.erb
  5. Update the mcp_tokens.yml active fixture's permissions JSON if integration tests need the new domain
  6. Add an integration test under test/integration/mcp/tools/<domain>_test.rb (copy accounts_test.rb)

To add a tool to an existing domain:

  1. Drop a new file in app/models/mcp/tools/<existing_domain>/
  2. Inherit Mcp::BaseTool, declare tool_name, mcp_domain (matching the existing key), mcp_action, description, input_schema
  3. Implement self.call(**args) starting from user.<association>
  4. Add a test case to the existing domain's integration test file

The namespace-scan discovery in Mcp::ServerBuilder.tool_classes picks up new files automatically — no registration step.

10. Files of interest for future maintainers

  • docs/superpowers/specs/2026-04-22-user-mcp-design.md — design rationale, decisions, rejected alternatives
  • docs/superpowers/plans/2026-04-22-user-mcp.md — full implementation plan with Tool Archetypes section
  • docs/mcp/export_mcp_feature/ — the porting playbook this implementation follows
  • app/models/mcp/server_builder.rb — single registration point for tool namespaces
  • app/middleware/mcp/base_rack_app.rb — transport, rate limit, error envelopes
  • app/models/mcp/token.rbPERMISSION_KEYS constant is the source of truth for the permission UI
  • app/views/mcp/tokens/_form.html.erb — permission grouping + help text definitions
  • test/integration/mcp/tools/accounts_test.rb — reference template for any new domain integration test