# My watch talks to Claude, on conditions

Language: en

Identifiant : article:serveur-mcp-garmin

Noa Matout

I wrote an MCP server to query my Garmin training data. Real data broke three things my tests could not see.

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](https://github.com/NoaMatout/garmin-mcp).

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](/fr/articles/agent-que-les-sysadmins-accepteraient/)
(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](/en/articles/auditing-my-own-mcp-server/).

## Sources

- [Garmin forum, "TLS fingerprinting blocks third-party clients"](https://forums.garmin.com/apps-software/mobile-apps-web/f/garmin-connect-web/433892/tls-fingerprinting-blocks-third-party-clients/2022045),
  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

tools : 16 typed tools. Tools exposed by the MCP server, with no free-form SQL access.

testsPassed : 214 passing tests. Hermetic suite, with no real data and no network, rerun on October 2, 2026.

corpusFiles : 42 real recordings. Real FIT files used to validate the reader, outside the hermetic suite.

corpusDevices : 19 devices. Different devices represented in the validation corpus.

corpusMakers : 8 manufacturers. Manufacturers represented in the validation corpus.

corpusParsed : 30 files read. Corpus files read as activities.

corpusRejected : 12 correct rejections. Corpus files that are not activities or are truncated, and that the reader rightly refuses.

streamSamples : 1,513 samples. Heart rate points from a real ride, used to measure the effect of downsampling.

overviewPoints : 10 points. Resolution of a coarse overview of the same ride.

hrMaxRead : 160.2 beats per minute. Maximum heart rate read from the ten-point overview, before the fix.

hrMaxTrue : 174 beats per minute. Actual maximum heart rate of the same ride.

hrMinRead : 138 beats per minute. Minimum heart rate read from the ten-point overview, before the fix.

hrMinTrue : 111 beats per minute. Actual minimum heart rate of the same ride.

loadSessions : 6 sessions. Sessions of a real week added up by the old weekly summary.

loadSports : 4 sports. Sports represented in that week.

loadValue : 17 load points. Meaningless sum produced by the old summary, displayed as a training load.

titledActivities : 66 activities. Garmin activities in my database when I noticed the titles were lost.

survivingTitles : 1 title. Title still present after the full reread of the files.

recoveredTitles : 91 titles. Titles recovered after the fix, by widening the list read from Garmin.

missedActivities : 25 activities. Older activities never imported, discovered during the same fix.

exposureBefore : 9.2 out of 10. Exposure score of my MCP server before hardening, as reported by systemd's analysis tool. The lower, the better.
