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_logsand 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 byUserContextmarketplacedomain root → tools query directly fromuserCurrent.mcp_requestflag — not used; side effects fire normally per decision #6Approvableconcern — no approval workflows in scope- Organization membership policies — replaced with simple
current_user == record.userchecks
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 ifrevoked_at.present?. Updateslast_used_aton successful lookup.Mcp::Token.generate_raw_token— returns a random 40-char URL-safe string.#regenerate!— replacestoken_digestandtoken_prefix, invalidating the old raw token. Returns the new raw token (shown once).#revoke!— setsrevoked_at = Time.current.#permits?(domain)— boolean check againstpermissions[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(domainanalytics, actionread)- Args:
date(optional ISO date, defaults to today) - Returns: JSON mirroring
Dashboard::DataAggregator:kpis,patrimonial_history,institution_breakdown,projection_12_months,account_cards.
- Args:
get_analytics_summary(domainanalytics, actionread)- Args:
future_months(integer, default 12) - Returns: JSON with
future_projection,growth_registry,growth_mom,heatmap_registry,heatmap_mom,live_patrimony— all fromUser::AnalyticsCalculator.
- Args:
get_gamification_stats(domainanalytics, actionread)- 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, truncatedarguments,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) andtoken_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.currentkeys; cleared inensureblocks in the Rack app. - Audit logging: a module prepended into
Mcp::BaseToolwrites a log row after everycall, recording tool name, domain, action, sanitized args, and a truncated result summary. - Argument sanitization: the logging module redacts keys matching
/password|token|secret/ibefore 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/listsucceeds.
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::ActivityLogrow. - 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:
- Gemfile: add
gem "mcp", "~> 0.10.0"; bundle. - Migrations for
mcp_tokensandmcp_activity_logs. - Core runtime:
Mcp::Token,Mcp::ActivityLog,Mcp::UserContext,Mcp::BaseTool,Mcp::ServerBuilder, concerns. - Rack apps:
Mcp::BaseRackApp,Mcp::RackApp. Mount inconfig/routes.rb. - One end-to-end tool domain (accounts: list/get/create/update) to validate the pipeline.
- Token + Activity controllers, routes, views, policies.
- Remaining CRU tool domains (expenses → todos) in order from Section 7.2.
- Gamification read-only tools.
- Analytics tools (wrap
Dashboard::DataAggregator,User::AnalyticsCalculator). - Full test suite (
bin/ci) green.
13. Open questions / assumptions
- Assumption:
Mcp::Token.scopeis 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
Pagygem (per AGENTS.md) is the pagination engine for activity log views. - Assumption:
GamificationProfileishas_one :gamification_profileon User; tool returns the user's profile directly. Confirmed against models list. - Assumption:
TodoBoardishas_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.