App reference

ADR-0016: MCP server endpoint and admin-managed API keys

Metadata

  • Status: Accepted
  • Date: 2026-07-30
  • Deciders: EagraΓ­ Clainne Team
  • Related: ADR-0002 (Centralised RPC authorization); ADR-0007 (JWT authentication); ADR-0008 (RBAC); ADR-0009 (Service-layer authorization)

Context

We want machine clients to act on EagraΓ­ Clainne data: Claude (per person, via each person's own MCP config) and shared automations such as Home Assistant (one config, many humans behind it).

The current auth model serves interactive humans:

  • Session JWTs signed with the install secret, 12h expiry, delivered as a bearer header or an httpOnly cookie.
  • Every request resolves its subject from the live user record (LookupSubject), not from the claims.
  • A password change bumps the account's token_generation (tgen claim) and orphans every earlier token.

Machine clients break three assumptions of that model:

  1. Credential lifetime. A 12h token is useless in a config file.
  2. Revocation granularity. tgen kills all of a user's tokens at once. Revoking one device must not log the person out of the web UI.
  3. Attribution. A shared device key cannot tell us which human acted. Reward and chore accounting needs the real person.

Decision

Three parts, one design:

  1. An MCP endpoint mounted at /mcp on the existing mux, using the official Go SDK (github.com/modelcontextprotocol/go-sdk/mcp) with the streamable HTTP transport. Tools call back into the existing Connect services through an in-process loopback client, so the full interceptor chain (auth, sanitize, audit, notify) applies unchanged.
  2. Admin-managed API keys stored as first-class records. A key is either personal (bound to a user, acts as that user) or standalone (its own principal, e.g. home-assistant). The token is still a JWT β€” same signing machinery β€” but the database record is the authority, matching the per-request-record philosophy of ADR-0007.
  3. On-behalf-of attribution. Mutating calls may carry an acting user. The auth layer honours it only when the key's admin-set act_as allowlist permits that uid. The audit log records both the credential and the effective actor.

Structures

ApiKey record (new table + proto)

// api/core/v1/apikey.proto
message ApiKey {
  string uid = 1;                              // key id; JWT jti mirrors this
  string name = 2;                             // "lukes-claude", "home-assistant"

  // Personal key: set to the owning user's uid. Roles and state come from
  // that user on every request. Standalone key: empty; the key is its own
  // principal and carries its own roles below.
  string user_uid = 3;

  // Standalone keys only. Ignored when user_uid is set (personal keys
  // inherit, and are capped by, the user's roles).
  repeated string roles = 4;

  // Uids this key may claim via acting_user. Empty = no impersonation.
  // Admin-managed. Typical: home-assistant key lists the family members.
  repeated string act_as = 5;

  api.core.v1.UserRef created_by = 6;          // admin who minted it
  google.protobuf.Timestamp created_at = 7;
  google.protobuf.Timestamp last_used_at = 8;  // best-effort touch
  google.protobuf.Timestamp revoked_at = 9;    // set = dead, immediately

  repeated api.core.v1.Mutation mutations = 10; // house convention
}

The token secret is never stored. The JWT signature (install secret, HS256) proves authenticity; the record proves liveness. CreateApiKey returns the signed token exactly once.

JWT claims (extended)

type Claims struct {
    UserID          string   `json:"sub"`  // personal: user uid; standalone: key uid
    Email           string   `json:"email,omitempty"`
    Roles           []string `json:"roles"`           // debug only, as today
    TokenGeneration int64    `json:"tgen,omitempty"`  // session tokens only
    TokenType       string   `json:"typ,omitempty"`   // "" = session, "api_key"
    jwt.RegisteredClaims                              // ID (jti) = ApiKey.uid
}

Session tokens are byte-compatible with today's tokens (typ absent). API-key tokens have no exp and no tgen.

Subject (extended)

type PrincipalKind int // KindUser | KindAPIKey

type Credential struct {
    Kind    string // "session" | "api_key"
    KeyUID  string // api_key only
    KeyName string // api_key only, for audit/display
}

type Subject struct {
    // existing fields: Email, Roles, CanSignIn, TokenGeneration
    Kind       PrincipalKind
    Credential Credential
    ActAs      []string // from the key record; empty for sessions
}

AuditEntry (extended)

message AuditEntry {
  // ... existing fields 1-7 unchanged ...

  // How the change was made. Empty for pre-key history (reads as "session").
  string credential_kind = 8;  // "session" | "api_key"
  string credential_uid = 9;   // ApiKey.uid when api_key
  string credential_name = 10; // ApiKey.name snapshot, survives key deletion

  // When acting_user was honoured: `actor` (field 3) is the claimed human,
  // and this is the credential's own principal. Empty when actor == subject.
  api.core.v1.UserRef subject = 11;
}

Reading rule: actor is always the effective actor (who gets chore credit). subject + credential_* answer "via what, claimed by whom".


                       users                          api_keys
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚ uid           β”‚ 1       0..*β”‚ uid  (= jti)        β”‚
                 β”‚ roles         │◄────────────│ user_uid (nullable) β”‚  personal
                 β”‚ token_gen     β”‚             β”‚ roles               β”‚  keys only
                 β”‚ password_hash β”‚      β”Œβ”€β”€β”€β”€β”€β–Ίβ”‚ act_as[]            β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚      β”‚ revoked_at          β”‚
                         β”‚        allowlist    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚        entries                 β”‚
                         β–Ό                                β–Ό
                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                  β”‚                audit log                     β”‚
                  β”‚  actor: effective human (or standalone key)  β”‚
                  β”‚  subject: credential principal, if different β”‚
                  β”‚  credential: session | api_key (uid, name)   β”‚
                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  • A user owns 0..n personal keys. Revoking a key never touches the user's sessions; a password change (tgen bump) never touches keys.
  • A standalone key references no user. Its uid appears as an actor uid in audit/display, so uidβ†’name resolution gains a fallback: users first, then api_keys.
  • act_as entries point at user uids. Only mutating calls consult them.

Flows

1. Admin mints a key

Admin (web/CLI) ──CreateApiKey{name, user_uid?, roles?, act_as?}──► SystemService*
                                                                        β”‚
                                            insert api_keys record ◄─────
                                            sign JWT (sub, typ, jti) ◄───
Admin ◄──────────────── token, shown once β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
        (sanitize interceptor: token never appears in any later response)

* exact home service decided at build time; admin-role gated via ADR-0008.

2. Request authentication (one interceptor, three branches)

request ──► extract bearer/cookie ──► ValidateToken (signature, parse claims)
                                            β”‚
                            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        typ = "" (session)              typ = "api_key"
                            β”‚                                β”‚
                    LookupSubject(sub)               load api_keys[jti]
                    tgen must match β”€β”€βœ—β”€β”€β–Ί 401       revoked_at set? β”€β”€βœ—β”€β”€β–Ί 401
                            β”‚                                β”‚
                            β”‚                   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                            β”‚              user_uid set              user_uid empty
                            β”‚              (personal)                (standalone)
                            β”‚                   β”‚                        β”‚
                            β”‚        LookupSubject(user_uid)     Subject from key:
                            β”‚        (live roles, CanSignIn;     uid = key uid,
                            β”‚        skip tgen check)            roles = key.roles
                            β”‚                   β”‚                        β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                             β–Ό
                                          Subject (+ Kind, Credential, ActAs)
                                          into ctx ──► authorize (ADR-0002) ──► svc

3. MCP tool call, shared device with acting_user

Home Assistant ──MCP tool call: complete_item{uid, acting_user: "kid-a"}──► /mcp
   β”‚  Authorization: Bearer <standalone key JWT>
   β–Ό
HTTP auth middleware (same branch logic as flow 2) ── 401 on bad/revoked key
   β–Ό
mcp.Server tool handler
   β–Ό
loopback Connect client β†’ self mux
   β”‚  forwards same bearer token
   β”‚  X-Acting-User: kid-a
   β–Ό
auth interceptor: subject = home-assistant key
   β”‚  "kid-a" ∈ key.act_as ? β”€β”€βœ—β”€β”€β–Ί PermissionDenied
   β”‚  effective actor = kid-a
   β–Ό
item service: CompleteItem β€” reward ledger credits kid-a
   β–Ό
audit: actor=kid-a, subject=home-assistant, credential=api_key("home-assistant")
notify broker: live update to open web clients, as any other mutation

Rules:

  • acting_user absent on a personal key β†’ actor = the bound user. The common Claude case needs no extra plumbing.
  • acting_user absent on a standalone key β†’ actor = the key itself. Reward-bearing mutations reject this ("say who") rather than crediting a machine; plain reads and non-reward writes proceed.
  • acting_user present but not in act_as β†’ PermissionDenied. A lying or misconfigured client degrades to an error, never to wrong personal credit.

4. Revocation

Admin ──RevokeApiKey{uid}──► set revoked_at ──► next request with that jti β†’ 401

Immediate, per-key, no tgen involvement. Deleting a user cascades a revoke of their personal keys (and their uid should be dropped from act_as lists).


Attribution matrix

Surface Credential Subject Audit actor Reward credit
Web UI session session JWT/cookie the user the user the user
Personal key (Claude) api_key (personal) bound user bound user bound user
Standalone key, no actor api_key (standalone) the key the key rejected
Standalone key + acting_user api_key (standalone) the key claimed user claimed user

MCP endpoint composition

http.ServeMux (internal/server)
β”œβ”€β”€ /api.core.v1.*  Connect services ── [auth, sanitize, ...] interceptors
└── /mcp ── HTTP auth middleware (reuses jwt.go + key lookup)
        └── mcp.NewStreamableHTTPHandler
                └── mcp.Server ── AddTool (schemas inferred from Go structs)
                        └── tool handlers ── loopback Connect client ──┐
                                                                       β”‚
        same-process round trip back into the Connect handlers β—„β”€β”€β”€β”€β”€β”€β”€β”˜
        (interceptor chain applies: auth, authorize, sanitize,
         audit sink, notify broker β€” one code path for every surface)

Initial tool surface (small on purpose): list_items, add_item, complete_item, list_events, add_event. Dates ISO YYYY-MM-DD, times 24-hour, per house convention. Write tools carry optional acting_user.

Amended 2026-07-30: the surface grew to answer follow-up questions without leaving MCP β€” full-detail reads (get_event, get_item), uid resolution (list_family, list_item_lists), rewards (list_rewards with the ADR-0003 points balance), and two more writes (update_event, assign_item, both carrying acting_user). Deletes and role/credential management stay off the MCP surface: those remain admin-UI work.

Amended 2026-08-08: the endpoint is now gated by the mcp_enabled system setting, off by default. The handler authenticates the bearer first β€” anonymous and revoked callers get the usual 401 and never learn the endpoint's state β€” then reads the settings cache and answers 403 with a named reason while the setting is off, so the toggle takes effect without a restart. The switch lives in the web Admin System settings (UpdateSystemSettings); the CLI shows the state. API keys are untouched β€” a disabled endpoint refuses every caller uniformly, and the keys keep working against the Connect API.


Consequences

Positive:

  • One auth code path for humans and machines; the branch is data, not a parallel stack.
  • Per-key revocation and per-key audit visibility without touching sessions.
  • No password-less pseudo-user for devices: CanSignIn, credential generation, and the sign-in surface stay pure user concerns.
  • Attribution is fail-safe: worst case is device-level attribution or a rejected call, never wrong personal attribution.

Negative / costs:

  • Non-expiring bearer tokens exist. Mitigations: shown once, revocable instantly, last_used_at visibility, homelab threat model.
  • uidβ†’name display resolution needs the api_keys fallback everywhere actors are rendered.
  • AuditEntry grows credential fields; old rows read as session entries.
  • The MCP HTTP middleware duplicates a thin slice of the interceptor (token extraction + branch) β€” kept thin by sharing the same helpers.

Deferred:

  • Key scopes narrower than roles (read-only keys, per-service grants) β€” add as a record field + claim later without breaking existing keys.
  • Deny-by-default for admin-role actions over api_key credentials β€” decide at build time.
  • OAuth for the MCP endpoint (spec-preferred for public servers) β€” overkill for a self-hosted install; bearer keys are the accepted pattern for local MCP servers.