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, declarestool_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)thenMarketListItem.where(...)). - Create/Update use explicit
PERMITTEDconstants andargs.slice(*PERMITTED)for mass-assignment safety. - Validation failures return a readable
text_response("Failed to ...: <messages>"). - List tools paginate via shared
Listableconcern (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):
list_<domain>returns paginated textget_<singular>returns the record's attribute hashcreate_<singular>persists a row and returns its idupdate_<singular>mutates an existing row- Cross-user: a token for user A returns "not found" for user B's records
- Activity log row written on success (asserted in Accounts only — pattern verified)
6. Strengths
- 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. - 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.
- Per-domain permission keys are discoverable in one place (
Mcp::Token::PERMISSION_KEYS) and drive both the UI checkbox grouping and the runtime filter. - Analytics tools wrap existing calculators rather than re-implementing queries —
Dashboard::DataAggregatorandUser::AnalyticsCalculatorare called directly. Future calculator changes flow through automatically. - No service objects, no new architectural concepts — sticks to Rails conventions and the AGENTS.md house style.
- 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.
- 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
- 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.
- Token in URL. Practical for MCP host configuration (URL-only setup) but appears in HTTP logs. Mitigation is HTTPS-only + token rotation.
- Permissions are coarse (per-domain only). Granting
expensesis 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). - 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. - 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.
- 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. - 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.
- 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_logsagainstcreated_attimestamps 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 inMcp::RackApp#authorized?afterfind_by_raw_tokenalready 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_requestflag if downstream audit requirements emerge
9. How to extend
To add a new permission domain:
- Add the key to
Mcp::Token::PERMISSION_KEYS - Add the namespace to
Mcp::ServerBuilder::TOOL_NAMESPACES - Create tool files at
app/models/mcp/tools/<domain>/{list,get,create,update}.rbfollowing the archetypes in the plan - Add the key to
permission_groupsandpermission_helpinapp/views/mcp/tokens/_form.html.erb - Update the
mcp_tokens.ymlactivefixture's permissions JSON if integration tests need the new domain - Add an integration test under
test/integration/mcp/tools/<domain>_test.rb(copyaccounts_test.rb)
To add a tool to an existing domain:
- Drop a new file in
app/models/mcp/tools/<existing_domain>/ - Inherit
Mcp::BaseTool, declaretool_name,mcp_domain(matching the existing key),mcp_action,description,input_schema - Implement
self.call(**args)starting fromuser.<association> - 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 alternativesdocs/superpowers/plans/2026-04-22-user-mcp.md— full implementation plan with Tool Archetypes sectiondocs/mcp/export_mcp_feature/— the porting playbook this implementation followsapp/models/mcp/server_builder.rb— single registration point for tool namespacesapp/middleware/mcp/base_rack_app.rb— transport, rate limit, error envelopesapp/models/mcp/token.rb—PERMISSION_KEYSconstant is the source of truth for the permission UIapp/views/mcp/tokens/_form.html.erb— permission grouping + help text definitionstest/integration/mcp/tools/accounts_test.rb— reference template for any new domain integration test