Dev.to · 9 min read

I built an MCP server for glucose data that refuses to lie

I built an MCP server for glucose data that refuses to lie

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… View on GitHub 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, ) -> Option { 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::::from_naive_utc_and_offset(factory, Utc); (observed 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_credentials rejects 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 serve that 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-deny for 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News