Ask an AI assistant for a glucose reading through most integrations and you'll get a number back. 132 mg/dL.
Great. From when?
Thirty seconds ago? Two hours ago, before the phone lost signal? Is "the last 7 days" actually seven days of data, or the twelve hours the API happened to return? When a language model gets a bare number, it does the natural thing: it treats the number as current and complete, and it sounds confident about it.
For most data that's an annoyance. For continuous glucose monitor (CGM) data, it's the whole problem.
So I built librelink-mcp-rs: a local Model Context Protocol server, in Rust, that reads FreeStyle Libre data from a LibreLinkUp follower account. Its one design rule is that every answer says how fresh, how complete, and how trustworthy it is. It's MIT licensed and at 1.0 now.
johnohhh1 / librelink-mcp-rs
Privacy-conscious Rust MCP server for LibreLinkUp sharing data, with eight compatible tools and honest data freshness.
librelink-mcp-rs
Give your AI assistant honest, private access to FreeStyle Libre glucose data.
A local Model Context Protocol server, written in Rust, that reads a LibreLinkUp follower account and answers with readings that say exactly how fresh, how complete, and how trustworthy they are.
Important
Unofficial and not affiliated with Abbott. It describes data; it does not diagnose or give treatment advice. Use a LibreLinkUp follower account, and enter credentials only in the local setup prompt, never in a chat.
Why this one
- It never pretends. A stale reading is labeled
stale, not served as live. A 12-hour graph is never passed off as a 7-day summary. A gap stays a gap, with a cause attached. - It gets the math right. Time in range is weighted by time, not by sample count, and GMI appears only when the consensus 14-day, 70%-coverage bar is met. On the validated setup, 14-day…
Here are the five problems that shaped it, and how each one ended up enforced by a test rather than by good intentions.
Problem 1: the timestamps don't have timezones
LibreLinkUp is an unofficial, reverse-engineered API. Every reading comes with two clock strings, and neither has an offset:
"Timestamp": "11/5/2026 9:54:32 PM", "FactoryTimestamp": "11/6/2026 2:54:32 AM" The community consensus is that Timestamp is the sharer's phone wall clock and FactoryTimestamp is UTC. But "community consensus" isn't something I wanted to hard-code silently, because if it's wrong, every freshness calculation is wrong.
So the server only infers UTC when the two clocks are consistent with that story. The gap between them has to look like a real timezone offset, and the factory time can't be in the future:
fn infer_factory_utc( display: NaiveDateTime, factory: NaiveDateTime, now: DateTime<Utc>, ) -> Option<DateTime<Utc>> { let gap_seconds = display.signed_duration_since(factory).num_seconds(); // Real timezone offsets are quarter-hour aligned and within this range. // This is a consistency check, not a timezone assertion from the API. if gap_seconds % (15 * 60) != 0 || gap_seconds.abs() > 14 * 60 * 60 { return None; } let observed = DateTime::<Utc>::from_naive_utc_and_offset(factory, Utc); (observed <= now).then_some(observed) } When it passes, the reply says so: time_basis: "factory_utc_inferred". When it doesn't, freshness is unknown, never a guess. I checked the inference against a live account (a −300 minute gap, matching the phone's Eastern time) and against public captures from a UTC+2 summer account, so it holds across two timezones and both DST states.
The rule that falls out of this: fetching a reading never makes it current. Retrieval time and observation time are separate fields, and a cached response keeps its original retrieval time so it can't look fresher than it is.
Problem 2: a careless client can lock the account
The first version used an existing Rust crate for the API. Reading its source, I found it would log in again and retry after any failed request, including a 429 Too Many Requests.
Think about what that means when an agent fires four tool calls in a row while the API is throttling: eight requests and a stream of fresh logins. That's exactly the pattern that gets an account locked. And this account is a caregiver's window into someone's glucose.
I replaced the crate with a small owned client (eight endpoints, reqwest plus rustls) behind a DataSource trait. It logs in only when there's no valid session, or once after a 401. Nothing else triggers a login. The core guarantee is a test that counts logins against a mock server:
#[tokio::test] async fn throttling_never_triggers_a_login() { let server = MockServer::start().await; mount_session(&server, in_one_hour()).await; mount(&server, "GET", GRAPH_PATH, ResponseTemplate::new(429)).await; let client = client(&server); for _ in 0..2 { assert_eq!(client.read().await.unwrap_err().code, "rate_limited"); } assert_eq!(logins(&server).await, 1); } Every failure test in the suite asserts the login count, not just the error code.
Some errors can't be fixed by retrying at all, so retrying them only costs requests. Those latch until restart:
pub(crate) fn latches_until_restart(error: &FetchError) -> bool { matches!( error.code, "authentication_failed" | "client_version_rejected" | "request_rejected" | "patient_selection_changed" ) } The background collector exits with status 78 on a latched error, and the shipped systemd unit sets RestartPreventExitStatus=78. So a wrong password produces one failed login, not one every five minutes until Abbott locks the account.
Problem 3: "the last 7 days" from a 12-hour API
The LibreLinkUp graph covers about twelve hours. Ask the naive version for weekly stats and you get twelve hours of numbers wearing a "7 days" label.
Two fixes.
First, coverage is always reported. Every summary carries its first and last observation, span, cadence, and gaps. If the data doesn't reach the edges of the requested window, the reply says period_status: "partial". Every interior gap gets a cause:
| Cause | Meaning |
|---|---|
no_data_from_api | The collector polled during the gap, and the API had nothing |
no_data_in_export | The device's own exported history has nothing there (a sensor change, say) |
before_collection | Before collection started; the cause is unknown, and the label says so |
collector_not_running | Nobody was watching |
That third one came from a live test. The first version called a gap in the backfilled history collector_not_running, which sounds authoritative but was simply wrong: the collector hadn't started yet, so it couldn't know why that data was missing.
Second, real history. An opt-in collect command polls once a minute (a 60-second floor enforced in code, plus jitter) into a local SQLite database. import-csv backfills older history from a LibreView export, offline. While the collector runs, serve answers every tool from the database:
LibreLinkUp API ──collect (60 s)──▶ ┌──────────────┐ LibreView CSV ───import-csv──────▶ │ local SQLite │──read-only──▶ serve ◀──stdio──▶ MCP client └──────────────┘ ▲ LibreLinkUp API ·········· live fallback when no collector ·········┘ Only one process ever talks to Abbott. I verified that by running serve under strace: across every tool call, zero AF_INET connections. Every reply carries data_source: "local_store" or "live_api", so the model knows which world it's in.
Problem 4: counting samples quietly skews the statistics
With the collector running, the database mixes two densities: graph points every five minutes, and current readings every minute. Compute "percent of readings in range" over that, and the most recent hour, the one with minute-by-minute data, outweighs everything else.
The fix is to weight by time, not by count. Each reading stands for the time until the next one, capped at 15 minutes so a gap is never bridged:
let cap = Duration::minutes(MAX_SAMPLE_MINUTES); (0..timed.len()) .map(|index| { let (at, sample) = timed[index]; let until = timed.get(index + 1).map_or(to, |(next, _)| *next); (sample, (until - at).min(cap).num_seconds()) }) The test that guards it builds twelve hours of in-range readings every 15 minutes, plus one hour of minute-by-minute high readings. By sample count, "high" is over half the data. By time, it's 1/13. Only the second number describes reality.
From there the server reports the five international consensus ranges by time, plus a time-weighted mean, SD and CV. GMI (an estimate of A1C) is gated the way the consensus paper recommends: a window of at least 14 days with at least 70% coverage, or you get null and a reason (window_too_short, coverage_too_low).
The acceptance test I'm proudest of: after importing a real 14-day LibreView export, I compared the server's figures with the AGP report LibreView itself generated from the same data. Every range band agreed within 1 percentage point, the mean within 1 mg/dL, CV within 0.1, and coverage within half a point. The vendor's own report was the test oracle.
Problem 5: privacy you can actually test
"We take privacy seriously" isn't a property. These are:
- Credentials live only in the OS credential store (Keychain, Credential Manager, Secret Service). No MCP tool accepts a password; there's a test that
configure_credentialsrejects a password argument without echoing it. - The patient is stored as a salted SHA-256 pseudonym, with a random salt per database. A test plants a recognizable patient ID and byte-searches the database, including the WAL and shared-memory sidecar files, to prove it never lands on disk. The sidecars are where a leak would actually hide.
- CSV import reads only the timestamp, record type, and glucose columns. Insulin, carbs, notes, and the name line at the top of the export never make it into storage or logs. A fixture fills those columns with marker strings, and the test asserts none of them appear anywhere.
- Errors use fixed messages and public codes. A test harness plants markers in fake credentials and fake server responses, drives every error path, and asserts no marker reaches stdout or stderr. A panic prints a fixed message with no payload unless you opt in.
How it was tested
A few habits did most of the work:
- Synthetic data only, in the repo. No real readings in fixtures, issues, or history. Real data only touched the live checks on my own machine, and those were recorded as "what was checked", never as values.
- Mutation checks for the important tests. For every key test, deliberately break the code it guards, confirm the test fails, and restore. That caught tests that passed for the wrong reason more than once.
- A live check before every release. Each version was smoke-tested against a real account before tagging, and that caught real bugs: a gap mislabeled on backfilled data, and a
servethat would have quietly migrated the database out from under a running older collector. - Boring CI, on purpose. Linux, macOS and Windows, the minimum supported Rust (1.89), and
cargo-denyfor advisories and licenses. A Windows-only test failure turned out to be the test reading the clock twice; the fix was to capture the time once per fixture.
That's 149 tests over about 10,000 lines of Rust.
The elephant in the room
This uses an unofficial API. Abbott's newer LibreLinkUp app encrypts its responses, and the older plain-JSON endpoints this project uses could be shut off.
The project will not try to circumvent that encryption. It will detect a changed API and say so (api_changed), and because history lives in your local database, everything you've already collected or imported keeps working if the live API ever goes away.
Try it
Release binaries are on the releases page for Linux, macOS and Windows, along with a Claude Desktop bundle. Or:
cargo install --locked --git https://github.com/johnohhh1/librelink-mcp-rs --tag v1.0.0 librelink-mcp-rs setup # local prompt; the password never touches the MCP client librelink-mcp-rs check It's validated on one US follower account with a FreeStyle Libre 3. If you're in another region or on another sensor, the compatibility guide shows how to test it and report the result without sharing a single reading. That's the most useful contribution right now.
Unofficial, and not affiliated with or endorsed by Abbott. FreeStyle Libre, LibreLinkUp, and LibreView are Abbott trademarks. This software describes data; it doesn't diagnose or give treatment advice. Thanks to Amandeep Khurana, whose original LibreLink MCP server defined the eight tools this one implements.
