Commit Graph
24 Commits
Author SHA1 Message Date
Marc-André Bombeck 0f2b548138 style: apply prettier 3.9.1 formatting
Mechanical reflow from the prettier 3.9.1 bump; no behaviour change.
2026-06-28 19:26:16 +02:00
Marc-André Bombeck a69697b50f fix(insights): wire grip / pain / waist / waist-to-height across web and MCP
Grip strength, pain (0–10 NRS), waist circumference, and waist-to-height
existed in the measurement enum, signal registry, and FHIR export but had
no web entry, no detail page, single non-sex-aware bands, and a split-brain
MCP exposure. This closes those gaps.

Web capture: the four signals join the manual measurement form, so a
web-only self-hoster can log them (the iOS client and the measurements API
already could).

Detail pages: flip detailPage on for the four signals and mount each on the
shared HealthKit metric template (chart, stat strip, Coach-read strip,
assessment card) via routed sub-pages and tab-strip pills, the same detail
spine every other metric carries.

Sex-aware bands: grip strength (EWGSOP2 men 27 / women 16 kg) and waist
(WHO men 94 / women 80 cm) now classify by the profile sex through the
age/sex norm resolver the assessment already threads; the resolver also
returns a sex-specific, age-independent band when no date of birth is set.
Fix the waist-to-height boundary so the NICE >= 0.5 rule is correct (the
last in-range value is 0.49).

MCP single source of truth: decouple the registry mcp facet from the Coach
snapshot — a signal can be MCP-readable without being on the Coach surface.
Mark the four physical signals mcp:true, derive the MCP rich-reads clinical
set from the registry flag instead of a duplicate allowlist, and relax the
invariant to coachSnapshot implies mcp (not the reverse). PHQ-9 / GAD-7 and
the environmental signals stay mcp:false and unreachable. compare_metric and
detect_changepoints now advertise clinical-signal support to match
get_metric_baseline.
2026-06-28 18:33:53 +02:00
Marc-André Bombeck 7cd67c8dc8 feat(mcp): expose v1.25 clinical signals as grounded reads
Bring the new physical clinical signals onto the MCP read surface through
the existing rollup-backed reads. Grip strength, pain (0-10 NRS), waist
circumference, and waist-to-height ratio now resolve in get_metric_baseline,
compare_metric, and detect_changepoints, are discoverable through search
(present-only), and hydrate through fetch (metric:<KEY>).

These signals sit off the Coach snapshot by design, so the MCP layer owns
an explicit exposure allowlist and consumes the signal registry only for
grounding (backing measurement type, unit, population band). The PHQ-9 /
GAD-7 mental-health screeners and the environmental signals are absent from
the allowlist, so they never resolve through any read tool. The longevity
lab panel is already reachable through get_labs and the lab resource
template; no change needed there.
2026-06-28 17:37:40 +02:00
Marc-André Bombeck 3c6cb8b08d fix(mcp): record why a client fails to resolve at /authorize
CIMD client resolution swallowed every fetch failure as ssrf_blocked and
/authorize logged a bare invalid event, so an unreachable metadata host
was indistinguishable from a genuine SSRF block and the consent flow
returned invalid_client with no diagnosable cause.

Split the resolution failure into ssrf_blocked (a real private-host
refusal) and fetch_failed (timeout / connect error), carrying the
concrete SafeFetchError kind and cause as a short non-secret detail.
Thread the resolution reason and detail into the authorize.invalid
annotation behind a pinned meta shape, and cache a resolved CIMD
document for up to 24h so the consent flow does not re-fetch on every
hit.
2026-06-28 15:18:12 +02:00
Marc-André Bombeck 9bc3b0b101 fix(mcp): validate the write preview so it matches the commit outcome
The confirm:false preview echoed the would-be record without running the
type / range / instant validation, so a preview could show a value the
commit then refused. Run the same shared validation in the preview and
surface wouldFail with the error + reason the commit would return, so a
preview and its commit never disagree.

Also note at the REST read-method allowlist that the MCP-audience guard
assumes GET/HEAD/OPTIONS are side-effect-free — a future side-effecting
GET must revisit that audience binding.
2026-06-28 02:00:44 +02:00
Marc-André Bombeck b9c164c4bb feat(mcp): expose medication schedule, integration status, and preventive-care reads
Add three grounded read tools, each a thin façade over an existing
server-authoritative engine (no new analytics, userId from the session):

- get_medication_schedule: next-due / overdue per active medication via
  the canonical recurrence engine (computeDisplayDue), with as-needed
  flagged and no scheduled due time.
- get_integration_status: per-provider sync health (connected, last sync,
  reauth-required/failing) from the integration-status ledger, so a user
  can see why their data is stale. No secrets or tokens.
- get_preventive_care: the user's already-configured Vorsorge reminders
  with their persisted next-due dates and an overdue flag; it surfaces
  configured reminders only and invents no screening logic.

Each declares an output schema, read-only annotations, and {present:false}
on absence. Page the search tool across the full assembled result set
(the prior 200-row scan made later items unreachable even through the
cursor) and give each result a per-item deep link where an id exists.
2026-06-28 02:00:37 +02:00
Marc-André Bombeck 1d9cc4b3d2 refactor(mcp): share one doctor-visit summariser across the resource and prompt
The doctor-visit summariser was duplicated and had drifted: the
healthlog://report/doctor-visit resource omitted mood, the glucose
panel, the wellness composites, and illness episodes that the
doctor_visit_summary prompt already carried. Extract one shared reducer
(doctor-visit-summary.ts) using the richer superset and have both
surfaces call it, so they ground identically and a future change reaches
both wires at once. Both keep the grounded latest+aggregate shape and
omit absent sections honestly.
2026-06-28 02:00:26 +02:00
Marc-André Bombeck 82b43f135e fix(mcp): bound the write timestamp like the manual route
The MCP measurement and blood-pressure write tools accepted a
client-supplied measuredAt with no temporal bound and persisted it
verbatim, so a far-future or ancient instant could poison "latest"
reads and rollup buckets. Bound it through the shared entry-instant
validator both manual capture paths use — no future beyond a 5-minute
clock-skew tolerance, nothing before 1900 — inside the write cores, and
return the structured out_of_range refusal rather than throwing.

Extract the type/range/instant checks into shared pre-write validators so
the same verdict can be reused by the confirm-gate preview.
2026-06-28 02:00:18 +02:00
Marc-André Bombeck 391c78674e Merge branch 'wave/v124-ewe-oauth' into release/v1.24.0 2026-06-28 01:22:14 +02:00
Marc-André Bombeck 67877a6aaa Merge branch 'wave/v124-ewc-prompts' into release/v1.24.0 2026-06-28 01:22:14 +02:00
Marc-André Bombeck 4f2c2c25e3 Merge branch 'wave/v124-ewb-resources' into release/v1.24.0 2026-06-28 01:22:14 +02:00
Marc-André Bombeck af8afde63a feat(mcp): expose glucose, sleep, workout, illness, and cycle reads
Bridge the five already-built, grounded Coach retrieval tools the
inventory already advertised but the wire was missing — closing the
advertise-but-missing self-inconsistency — each a thin wrapper over the
existing read with the same gates and present:false contract. Add a
multi-metric fan-out that loops the single-metric read, and a per-analyte
lab-history mode on the labs read. Declare a structured output schema on
every remaining tool so structured content validates for both a miss and
a hit. Page the large reads (search, lab history, multi-metric) behind an
opaque cursor. Return plain-text, citation-ready summaries from fetch
with per-item deep links instead of a JSON dump.
2026-06-28 01:21:07 +02:00
Marc-André Bombeck b40a963cb1 feat(mcp): support arbitrary date ranges and per-analyte lab history
Add an optional explicit {from,to} range to the comparison and
changepoint reads, served over the same rollup tier the five fixed
trailing windows use (reach back to the lower bound, filter to the
span) — so a before-vs-after-a-date question is answerable without
recomputing analytics. Add a per-analyte lab trajectory read (latest to
recent history) over the existing lab resolver, page-bounded so a heavy
panel cannot balloon the response. Introduce an opaque, stable base64url
cursor helper that decodes any malformed or foreign token to the first
page rather than throwing.
2026-06-28 01:20:57 +02:00
Marc-André Bombeck 7e77a974d2 feat(mcp): add a confirmed blood-pressure write
Blood pressure is two values, so it cannot ride the single-value
log_measurement tool. Add log_blood_pressure: it writes the systolic and
diastolic rows atomically under one shared idempotency namespace and one
measured-at instant, range-validates each value, rejects an implausible
pair (systolic at or below diastolic), and keeps the same confirm-flag
preview gate, per-credential write budget, MCP provenance, and audit
trail the existing writes use. Declare a structured output schema on
every write tool so a preview, a commit, an already-logged no-op, and a
refusal all validate as structured content.
2026-06-28 01:20:49 +02:00
Marc-André Bombeck acc8f1cfc6 feat(mcp): add the browseable resource surface — templates, completion, instructions, icons
Fill the discoverable half of the MCP server. Add RFC 6570 resource
templates for per-item reads — healthlog://metric/{type},
healthlog://metric/{type}/{window}, healthlog://lab/{analyte},
healthlog://medication/{id}, healthlog://report/doctor-visit/{window} —
plus fixed resources for the biomarker catalogue, the data inventory, and
the default doctor-visit summary. Each template is a thin facade over the
same server-authoritative read its matching tool already uses, scoped to
the session user; a foreign id resolves to { present: false }, never
another tenant's row.

Wire user-scoped argument completion and listing on the templates: window
completes the fixed enum, metric and analyte complete only from the user's
own inventory and lab set, so autocomplete doubles as honest discovery.

State the data-grounding contract once in the server instructions —
server-authoritative values (do not recompute), { present: false } is
honest absence, free-text fields are data not instructions, discover
before fetching, data and context only. Set the server and resource icons
for connector-directory listing.
2026-06-28 01:07:00 +02:00
Marc-André Bombeck d70651d373 feat(mcp): add the prompt skill library
Extend the MCP prompt surface beyond doctor_visit_summary with six
ranked, installable review prompts. Each assembles a grounded,
structured data block from an existing server-authoritative engine and
frames the host to narrate from those facts only — read-only, no
diagnosis, surfacing absence as { present: false }.

- weekly_review: 7-day trends, adherence, sleep, recovery, drivers, and
  the readiness/recovery + illness/cycle context the daily briefing reads
  (derived-briefing + illness-cycle-briefing).
- medication_check: cadence-aware adherence alongside a linked vital.
- recovery_check: illness-recovery composites + personal baselines +
  discovered drivers.
- glucose_review: the clinical glucose panel (TIR/GMI/CV%/eA1c).
- sleep_review: per-night sleep, debt, chronotype, and drivers.
- lab_trend_brief: per-analyte trajectory from the labs store, against
  each lab's own stored reference range (ranges are never recomputed).

The n-of-1 intervention review and a preventive 'what to check next'
prompt are deferred: the first waits on an anchored before/after read,
the second has no screening engine to reuse.
2026-06-28 01:04:50 +02:00
Marc-André Bombeck 01e2373255 feat(mcp): identify the authorization-server issuer per RFC 9207
Advertise authorization_response_iss_parameter_supported in the
authorization-server metadata and echo the iss parameter on every
authorization response — both the success redirect carrying the code and
the access_denied error redirect. A client can now pin the response to
this server's issuer and reject a code substituted by a look-alike
authorization server in a multi-server deployment.

Additive and backward-compatible: existing clients that ignore iss are
unaffected; PKCE S256-only, audience binding, redirect-URI exact match
and the rate limits are untouched.
2026-06-28 01:00:45 +02:00
Marc-André Bombeck fb986bc09d merge: bring the MCP server onto the v1.23 base
Combine the remote MCP server (stdio + remote /mcp + OAuth 2.1 + read
tools + deep-value reads + a confirmed, audience-bound write surface)
with the v1.23 account-security line (MFA, known/trusted devices,
free-text note encryption).

- schema: keep every v1.23 User relation (MFA, known/trusted devices)
  alongside the McpOAuthConnection relation; the MeasurementSource enum
  carries the MCP value.
- api-handler: the MCP audience guard (a token scoped to
  health:read/health:write is bound to /mcp and refused a REST write)
  and the step-up MFA helpers coexist.
- maintenance queues: register the MCP token-cleanup sweep next to the
  note-encryption backfill and key-rotation queues; shift it to 04:00
  so the two daily discovery ticks do not collide.
- migrations: renumber the MCP migrations to 0206/0207 so they follow
  the v1.23 chain (0199-0205).
- MCP mood writes route the note through the v1.23 at-rest cipher.
2026-06-28 00:52:04 +02:00
Marc-André Bombeck 6de61de010 feat(mcp): add a confirmed, audience-bound write surface for logging measurements and mood
Register two write tools — log_measurement and log_mood — only for a
health:write-scoped session; a read-only session never sees them advertised.
Each write mirrors the proven Telegram capture path (same range guard,
canonical unit, cache invalidation and rollup recompute) pinned to the MCP
source, and is idempotent on a hashed externalId derived from a client key, so
a retried call is a no-op rather than a duplicate.

Because the remote wire is stateless (no elicitation), the tools carry a
protocol-level confirm gate: without confirm:true they write nothing and
return the exact record they would write plus an instruction to confirm the
value with the user and re-call with the same idempotency key. Commits run
under a tighter per-credential write budget on top of the per-binding request
limiter.

Harden the resource-server audience binding: a token whose scopes are all in
{health:read, health:write} is bound to the MCP surface and refused on every
REST write/delete. A health:write token therefore admits the in-process MCP
write tools only and can never become a general REST write credential; admin
stays unreachable (cookie-only). Refuse, by construction, any medication, lab,
delete/update, or data-export tool on this surface.
2026-06-27 17:38:42 +02:00
Marc-André Bombeck cfffb693c3 feat(mcp): add deep-value reads and a doctor-visit prompt
Expose four read-only relational tools and one prompt over the MCP wire,
each a thin re-export of an existing server-authoritative engine — no new
statistics, no new business logic:

- get_correlation: the FDR-controlled, lag-aware association between two
  named metrics, selected out of the existing correlation discovery the
  Coach and the insight route already run. Descriptive, never causal.
- compare_metric: two metrics over the same trailing window, or one metric
  across two trailing windows, computed over the WMY rollup readers with a
  delta only when both sides share a unit.
- get_metric_baseline: where today's reading sits against the user's own
  usual range (median ± robust deviation) plus the strongest lagged driver,
  reusing the metric-page baseline builder. Honest-null below the learning
  floor — never a fabricated range.
- detect_changepoints: level shifts over the rollup tier via a small in-repo
  CUSUM binary segmentation with a high firing bar.
- doctor_visit_summary prompt: a structured, grounded visit-prep summary
  assembled from the doctor-report data path so the numbers match the
  exported PDF, with the grounding framing injected once — data and context
  only, no diagnosis or verdict.

Every tool returns values, units, reference bands and provenance, declares
read-only / non-destructive / closed-world annotations plus an output schema,
and reports { present: false } for absence. All reads stay session-narrowed
and read-only; the existing security posture is unchanged.
2026-06-27 15:34:11 +02:00
Marc-André Bombeck 22eb9cba0d fix(mcp): close OAuth 2.1 resource-server gaps before the remote surface ships
Harden the remote MCP endpoint and the in-repo OAuth bridge against the
adversarial review of the milestone branch. The two ship-blockers made the
"read-only, revocable" promise false; the rest tightened the surface.

Audience binding at the resource server: a token whose only grant is the new
`health:read` scope is the MCP read credential. The REST edge now accepts it on
safe (read) methods and refuses it on writes/deletes with 403, so a connector
token can read over `/mcp` and GET routes but can never POST/DELETE the record
despite a "read-only" consent. The `/mcp` resolver is unaffected.

Revocable, theft-detectable refresh tokens: refresh artifacts now anchor to a
persistent connection row. Revoking a connection stops every future refresh and
kills its access tokens; a replayed (already-rotated) refresh is treated as
theft and revokes the whole connection. Connections surface in settings as the
unit a user can disconnect. The old access token is revoked on rotation.

Also: fail closed on the OAuth/`/mcp` surface when no canonical origin is
configured and mark the discovery documents non-cacheable; honour the global API
kill-switch on `/mcp`, `/token`, `/authorize`, and `/register`; cap the CIMD and
registration body reads while streaming instead of after; show the verified
client origin on consent; validate CIMD redirect-URI schemes; assert same-origin
on the consent POST; and add a daily prune for expired connector tokens.

New migration 0195 adds the connection anchor and links access tokens to it.
2026-06-27 15:09:11 +02:00
Marc-André Bombeck 63e4b17843 feat(mcp): ship search/fetch tools, annotations, and the Origin gate
- Add the `search` + `fetch` retrieval pair with the exact ChatGPT wire
  shapes (`{id,title,url}[]` and `{id,title,text,url,metadata}`), backed by
  the same server-authoritative reads and returning real deep-link URLs into
  the web app for citations. Without these the server is invisible in
  default-mode ChatGPT.
- Annotate every read tool `readOnlyHint`/`destructiveHint:false`/
  `openWorldHint:false`; structured tools also declare an `outputSchema` and
  return `structuredContent`. Omitting the hints is a validation error in the
  connectors.
- Reject a cross-origin browser request to `/mcp` with 403 (DNS-rebinding
  defense) and point the 401 `WWW-Authenticate` at the resource metadata.
2026-06-27 14:27:20 +02:00
Marc-André Bombeck 7ec3bf0dad feat(mcp): add an OAuth 2.1 authorization-server bridge for remote connectors
Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to
the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_`
Bearer model — no parallel identity store.

- Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
  Server Metadata at `/.well-known/oauth-protected-resource` and
  `/.well-known/oauth-authorization-server` (added to the proxy allowlist).
- `/api/mcp/oauth/authorize` — consent gate backed by the existing session;
  a code is only issued on an authenticated POST decision, never on a GET.
- `/api/mcp/oauth/token` — authorization-code and refresh-token grants with
  mandatory PKCE S256, single-use codes, and rotating refresh tokens. The
  access token is an ordinary short-lived `hlk_` token scoped `health:read`.
- `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the
  preferred path and resolves client metadata through `safeFetch`.
- Audience binding (RFC 8707): every token is bound to the canonical `/mcp`
  resource URI; a mismatched `resource` is rejected at authorize and token.
- The grant artifacts (code, refresh, DCR client id) are HMAC-signed,
  self-describing, and domain-separated; the central log redactors scrub
  them plus the PKCE verifier.
2026-06-27 14:27:09 +02:00
Marc-André Bombeck d947bb1104 feat(mcp): add a local stdio MCP server for read-only health data
Introduce a read-only Model Context Protocol surface built on the official
@modelcontextprotocol/sdk so a user can query their own HealthLog data from an
MCP-capable assistant over local stdio with a pasted, scoped Bearer token — no
extra infrastructure.

The tool and resource layer in src/lib/mcp is transport-agnostic: five read
tools — list_metrics, get_metric_series, get_medication_compliance, get_labs,
get_correlations — each a thin wrapper over the existing server-authoritative
retrieval paths, returning structured values, units, reference bands, and
provenance with an explicit { present: false } for absence and no prose
verdicts. Two resources expose the user's profile (data-minimised) and
medication list. A stdio entrypoint (src/cli/mcp-stdio.ts, pnpm mcp:stdio)
resolves the token through the shared Bearer path and binds the session to
<user>:<token>; with no token it refuses to start.

Read-only and admin-unreachable by construction: only read tools are
registered, and requireAdmin stays cookie-only so the Bearer/MCP wire can never
elevate. Covered by unit tests for every tool and resource, the auth/scope
model, and the cookie-only admin boundary. The server ships in the existing
container; docker compose up is unchanged.
2026-06-27 13:25:53 +02:00