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(tgenclaim) and orphans every earlier token.
Machine clients break three assumptions of that model:
- Credential lifetime. A 12h token is useless in a config file.
- Revocation granularity.
tgenkills all of a user's tokens at once. Revoking one device must not log the person out of the web UI. - Attribution. A shared device key cannot tell us which human acted. Reward and chore accounting needs the real person.
Decision
Three parts, one design:
- An MCP endpoint mounted at
/mcpon 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. - 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. - On-behalf-of attribution. Mutating calls may carry an acting user.
The auth layer honours it only when the key's admin-set
act_asallowlist 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".
Links
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 (
tgenbump) 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_userabsent on a personal key β actor = the bound user. The common Claude case needs no extra plumbing.acting_userabsent 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_userpresent but not inact_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_atvisibility, homelab threat model. - uidβname display resolution needs the api_keys fallback everywhere actors are rendered.
AuditEntrygrows 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.