01 / My articles / Published
My watch talks to Claude, on conditions
I wrote an MCP server to query my Garmin training data. Real data broke three things my tests could not see.
Written on
Garmin Connect keeps years of my training and can only answer the questions its designers anticipated. For anything else, you export files and open a spreadsheet.
I wrote an MCP server that fetches the original FIT files, reads them, stores them in a local DuckDB database and exposes 16 typed tools to Claude. I can ask it to compare my last two rides, or to check whether my week followed the plan. Everything runs at home: no third-party service keeps my data. See the code of my server for training data.
What interests me here is not the feature. It is what you have to deny a model so that it can touch personal data, and what real data taught me that my tests ignored.
No free-form SQL
Giving a model arbitrary SQL access to a personal database means giving it everything the file contains. It can be talked into reading all of it, and it will sometimes write a query that scans a million rows to talk about last Tuesday.
Every query the server runs is written in advance and parameterised, in a single module. The field names a model asks for go through an allow list, because they come from the model. I have one file to review, rather than a promise to believe.
Writing to my watch takes my consent twice
Reading a history and changing an account are two different acts. The second one ends up on my wrist.
Writing is disabled by default. Cloning the repository does not give a model the right to change a Garmin account. Once enabled, no write happens in a single call. The first call validates the workout locally, sends nothing and returns it in full. Only a second call, with an explicit confirmation, creates it. During the preview, no credential is even loaded: that is what makes the confirmation real.
Every creation has its undo, and the tool that lists workouts exists so that the undo is reachable. A saved workout is not a scheduled one: putting it on the calendar is another tool, with its own confirmation. It is the rule I apply to my agents, described in my article on what administrators accept (in French): it proposes, I decide.
The server cannot log in
The components that talk to Garmin are built without credentials. They can only resume a session that is already open. Only a command run by hand ever sees a password, and the password is never written to disk.
An intended consequence: when the Garmin session expires, the server does not hang on a two-factor prompt nobody will see. My history simply stops at the last sync, and the server keeps answering.
When Garmin closed the door
In March 2026, Garmin began filtering clients by the shape of their TLS connection. Ordinary HTTP clients stopped working, and the library I planned to use was abandoned.
I drew a rule from it: manual import is not a fallback, it is a pillar. I drop exported FIT files into a folder, a command imports them, with no network and no credentials. It is the only path I can promise will still work in a year. For the rest, two access methods sit behind a narrow interface, and a third can plug in the day Garmin reopens an official API.
An average that lied about my heart rate
A three-hour ride contains thousands of points per measure. To avoid drowning the model, the server groups them and averages them. On a real ride of 1,513 samples, an overview of 10 points reported a maximum heart rate of 160.2. The real value was 174. The minimum was worse: 138 reported, 111 in reality.
A coarse overview is exactly what a model asks for when it wants the general shape. So this was not an edge case, it was the common path. And "your maximum heart rate was 160.2" is a wrong answer, given with confidence, about a number athletes take seriously.
Every answer now carries the real extremes, computed over all the samples, next to the smoothed curve.
A training load that did not exist
The weekly summary added up a Garmin score that describes a single session, rated from one to five, and called the total "training load". On a real week of six sessions across four sports, it showed 17. That number means nothing, and it looks serious.
A training log is precisely the place where that kind of number does damage. I deleted it rather than renaming it.
Titles erased without a single error
Looking at the data rather than the code, I saw 66 Garmin activities and one single title. The command that rereads the stored files had erased the others, without any error.
Titles are typed in Garmin Connect and arrive with the activity list. They are not in the FIT file. Rebuilding a row from the file alone therefore lost everything the file does not carry. A command whose job is to reread the stored files was certain to lose them.
The reread now keeps existing titles, and every sync refreshes them. The fix brought back 91 titles and revealed 25 older activities that had never been imported, because the list read from Garmin was truncated.
None of these three failures was visible in the test suite. All of them showed up while looking at real sessions.
What I did to see them sooner
The reader targets the FIT format, not one watch. I validate it on 42 real recordings from 19 devices and eight manufacturers. Of these, 30 are read as activities. The other 12 are rightly refused: settings, weigh-ins, daily tracking, or a file truncated before its first session.
Real devices have quirks no synthetic file invents: a watch that records before the start, a heart rate of zero instead of "missing", an unreadable start time. Every reconstructed value carries a mark, so that a derived number never passes for a measured one.
The hermetic suite, with no data and no network, counts 214 passing tests. I reran it before writing this article.
What I could not verify
Automatic access to Garmin relies on unofficial methods. It has already broken once, and it will break again. Only manual import is guaranteed.
The corpus covers 19 devices, not every one on the market, and most of my use concerns my own data.
Finally, I had taken care of the code and forgotten the system. Launched without any confinement, this same server got an exposure score of 9.2 out of 10. I explain how I measured and fixed it in my article on auditing my own MCP server.
Sources
- Garmin forum, "TLS fingerprinting blocks third-party clients", on the blocking of non-browser clients in 2026.
The measurements are mine, taken from the repository, its history and its test suite.
Scope of the measurements
16 typed tools. Tools exposed by the MCP server, with no free-form SQL access.
214 passing tests. Hermetic suite, with no real data and no network, rerun on October 2, 2026.
42 real recordings. Real FIT files used to validate the reader, outside the hermetic suite.
19 devices. Different devices represented in the validation corpus.
8 manufacturers. Manufacturers represented in the validation corpus.
30 files read. Corpus files read as activities.
12 correct rejections. Corpus files that are not activities or are truncated, and that the reader rightly refuses.
1,513 samples. Heart rate points from a real ride, used to measure the effect of downsampling.
10 points. Resolution of a coarse overview of the same ride.
160.2 beats per minute. Maximum heart rate read from the ten-point overview, before the fix.
174 beats per minute. Actual maximum heart rate of the same ride.
138 beats per minute. Minimum heart rate read from the ten-point overview, before the fix.
111 beats per minute. Actual minimum heart rate of the same ride.
6 sessions. Sessions of a real week added up by the old weekly summary.
4 sports. Sports represented in that week.
17 load points. Meaningless sum produced by the old summary, displayed as a training load.
66 activities. Garmin activities in my database when I noticed the titles were lost.
1 title. Title still present after the full reread of the files.
91 titles. Titles recovered after the fix, by widening the list read from Garmin.
25 activities. Older activities never imported, discovered during the same fix.
9.2 out of 10. Exposure score of my MCP server before hardening, as reported by systemd's analysis tool. The lower, the better.