garmin-mcp

garmin-mcp

MCP server that lets you ask questions about your Garmin training history in plain language. It ingests FIT files into DuckDB locally and exposes typed tools for analyzing activities, streams, and summaries.

Category
访问服务器

README

garmin-mcp

CI Python 3.12+ License: MIT

Ask questions about your Garmin training history in plain language, from Claude — and send structured sessions back to your watch. Raw FIT files in, DuckDB out, fifteen typed MCP tools on top.

> compare my last two runs

           Trail du Corbier        Treadmill
Distance   16.35 km                8.52 km
Duration   2:41:15                 35:02
Pace       9:52/km                 4:07/km
Avg HR     176 bpm                 165 bpm
Ascent     1264 m                  —

No exporting files by hand, no spreadsheets, no third-party service holding your data. Everything runs on your machine.


Why this exists

Garmin Connect holds years of training data and offers no practical way to ask it a question. The web UI answers what its designers anticipated; anything else means exporting CSVs and opening a spreadsheet.

This project pulls the original FIT files, parses them properly, stores them in a local analytical database, and exposes a small set of typed tools over MCP so a model can answer questions against real data instead of guessing.

Architecture

        Garmin Connect                     data/inbox/
              │                          (drop FIT files)
              ▼                                 │
   ┌──────────────────────┐                     │
   │   ActivitySource     │  one narrow         │
   ├──────────┬───────────┤  interface          │
   │  cffi    │ playwright│                     │
   └──────────┴───────────┘                     │
              │                                 │
              └────────────┬────────────────────┘
                           ▼
              ┌─────────────────────────┐
              │    ingest pipeline      │
              │  dedupe → parse → store │
              │       → write           │
              └────────────┬────────────┘
                           │  single writer, short transactions
                           ▼
              ┌─────────────────────────┐
              │        DuckDB           │
              │  files · activities     │
              │  laps  · records        │
              └────────────┬────────────┘
                           │  read-only, one connection per query
                           ▼
              ┌─────────────────────────┐
              │      MCP server         │
              │  15 typed tools, no     │
              │  generic SQL            │
              └────────────┬────────────┘
                           │  stdio  (or streamable HTTP)
                           ▼
                        Claude

Raw FIT files are kept forever under data/raw/. About 150 kB per activity — a decade of triathlon fits in well under a gigabyte — which means the entire history can be re-parsed whenever the parser learns a new field, with no network involved. garmin-mcp reparse does exactly that, and it works on an account that no longer authenticates.

Quick start

Requires Python 3.12+ and uv.

uv run garmin-mcp setup

That asks two questions, writes an owner-readable .env, creates the database, logs in to Garmin, pulls your recent activities and prints the command to connect it to Claude. Choose manual if you would rather not hand it your Garmin password — the import path needs no account at all.

Then register the server:

claude mcp add garmin -- uv run --directory "$(pwd)" garmin-mcp serve

Restart Claude Code and ask it something.

<details> <summary>Manual setup instead of the wizard</summary>

cp .env.example .env        # fill in GARMIN_EMAIL and GARMIN_PASSWORD
uv sync
uv run garmin-mcp init-db
uv run garmin-mcp auth      # interactive, handles MFA
uv run garmin-mcp sync --limit 50

</details>

Commands

Command Purpose
setup Interactive first run — credentials, database, login, verification
init-db Create the database and apply the schema (setup does this for you)
auth Log in to Garmin and save the session (the only command that sees a password)
sync Download and ingest new activities, incrementally
import Ingest FIT files from data/inbox/ — no network, no credentials
serve Run the MCP server
worker Background sync loop — the only process that writes
reparse Re-read every stored FIT file after a parser change — no network
status Report whether Garmin is currently reachable
info Show what the database holds

Tools exposed to Claude

Tool Returns Bound
list_activities Compact record per activity 20 by default, 200 max
get_activity_detail Summary, laps, multisport legs 50 laps
get_activity_streams Columnar time series + true peaks 200 points, 2000 max
weekly_summary Per-sport totals for one week one week
compare_activities Two activities with deltas computed fixed
compare_to_plan A session against the workout it was run from fixed
database_status What is stored, worker health, reachability fixed
sync_now Pull new activities without leaving the chat needs the worker
list_workouts Saved sessions in your Garmin library, with their ids 20 by default, 100 max
create_workout Put a structured session on your Garmin account off by default, two-step
delete_workout Undo the above off by default, two-step
list_scheduled_workouts What is planned this month, with both ids one month
schedule_workout Put a saved session on a date in your calendar off by default, two-step
unschedule_workout Drop a planned session, keeping the workout off by default, two-step
set_activity_notes Write an analysis into an activity's Notes field off by default, two-step

Design decisions

The parts that were not obvious, and why they went the way they did.

No generic SQL tool

Giving a language model arbitrary query access to a personal training database is a liability rather than a feature. It can be talked into reading anything the file holds, and it will occasionally write a query that scans a million rows to answer a question about last Tuesday.

Every statement lives in db/queries.py, fully parameterised. Stream field names go through an allow-list — they arrive from a model, and that list is what keeps them out of the SQL text. One module to audit, rather than a promise to trust.

Manual import is a pillar, not a fallback

In March 2026 Garmin deployed Cloudflare TLS fingerprinting, which blocks clients by the shape of their TLS handshake before authentication even begins. garth — the library this project was originally specified to use — was deprecated within days, and every plain HTTP client stopped working. It will happen again.

So data/inbox/ is a first-class ingestion path, tested as such. Drop FIT files exported from Garmin Connect into it and run garmin-mcp import. No network, no credentials, nothing that can be revoked. It is the only route that can honestly be promised to still work in a year.

Two backends behind one interface

ActivitySource is deliberately narrow — list what exists, fetch one file, report health. Two implementations sit behind it: a lightweight HTTP client impersonating Chrome's TLS handshake, and a real headless Chromium for when that stops being enough. An official-API backend drops in the day Garmin reopens its developer programme.

auto falls back to the browser only for failures a browser can actually fix. An expired token is not one of them: no backend can invent a login you have not performed, and falling back there would replace a clear run garmin-mcp auth with a slow, confusing browser failure.

Authentication cannot happen in the server

The backends are constructed without credentials, so they are structurally incapable of starting a fresh login — they can only resume a saved token. That is what lets the unattended ingest path fail loudly instead of hanging on an MFA prompt nobody will answer, and it is why a dead Garmin session degrades into "the history stops at last Tuesday" rather than a server that will not start.

Passwords are never written to disk by this project, never logged, and never stored in the database. Only the OAuth token is persisted, chmod 600. Once you have run auth, you can delete GARMIN_PASSWORD from .env entirely.

A triathlon is one activity and six

A FIT file is a message stream, not "an activity". A normal run holds one session message; a multisport recording holds several — swim, T1, bike, T2, run — with transitions being real sessions of their own.

Stored as a parent row plus one leg per discipline. Lists show the parent, so a triathlon reads as one line. Filtering by sport reveals the legs, so my running volume this month correctly includes the 10 km inside a triathlon. Weekly totals count top-level rows only, so 51.5 km is counted once rather than once per leg.

More than one session does not imply multisport, incidentally: a file can chain independent recordings, or repeat one twice. That is decided from the activity message, with temporal contiguity and transition legs as fallback.

Output is a budget

Every byte a tool returns is spent from a context window. Nulls are dropped; units are resolved ("4:42/km" costs less than avg_speed_mps: 3.5432 plus the arithmetic to read it); runners get pace and cyclists get km/h but never both; and series come back columnar rather than as objects, for roughly a third of the tokens.

Streams are averaged into buckets — a three-hour ride holds ~11 000 samples per channel. Because averaging flattens extremes, every stream response also carries true_range: minimum, maximum and mean computed over every raw sample. Without it, a coarse 10-point overview of a real ride reports a maximum heart rate of 160 against an actual 174, and states it with complete confidence.

Writing is held to a different standard than reading

Reading someone's training history and modifying their account are different acts, and the second one arrives on a wrist. Three tools can write — create_workout, delete_workout and the listing that makes them usable — and they are built accordingly.

Off by default. GARMIN_ENABLE_WRITES=false. Cloning a repository must not hand a language model the ability to change someone's Garmin account.

Never on a single call. The first call to create_workout validates the session locally, sends nothing, and returns it written out in full. Only a second call carrying confirm=true creates it. Nothing leaves the machine during the preview — no credential is even loaded — which is what makes the confirmation real rather than ceremonial.

Reachable undo. delete_workout takes an id, and list_workouts is what makes ids discoverable. Without it the undo existed only for whoever still had the conversation that created the workout, which is not an undo.

Not scheduled unless asked. A created workout lands in the library and syncs to the watch from there. Scheduling it on a date is a separate tool with its own confirmation, because a workout in the library is a suggestion and one on tomorrow's calendar is a plan.

Still no credentials in the server. Every call that needs an authenticated session — including reading the workout library — goes through the ingest worker. The MCP server cannot authenticate, which is what keeps a dead Garmin session degrading into stale history rather than a broken server.

Device compatibility

The parser targets the FIT protocol, not one watch. It is validated against a corpus of 42 real recordings spanning 19 devices from 8 manufacturers — Garmin (fr70 through fēnix 5, Edge 200/500/800/810/820, fr920xt, vívoactive), Wahoo ELEMNT and BOLT, Coros Pace 2, Stryd, Zwift, SigmaSport and the Strava mobile app.

30 of the 42 parse. The other 12 are correct rejections: 11 are not activity files (settings, workouts, weight scales, daily monitoring) and one is truncated before its first session survived.

Quirks that only real hardware reveals, all handled:

  • devices that log for 45 minutes before you press start (a fēnix 2 does), which would otherwise produce negative elapsed times;
  • writers that record heart rate 0 instead of the "missing" sentinel, dragging every average down — while 0 cadence and 0 power are real readings from a coasting cyclist and are left alone;
  • firmware writing start_time as an unresolvable integer, reconstructed from the next best anchor and flagged as such;
  • files with no activity message at all, where the timezone would silently become UTC and file a Sunday evening run under Monday;
  • cadence, which FIT stores in three incompatible units depending on sport.

Reconstructed values carry a provenance marker, so an inferred number is never mistaken for a measured one.

make test-all      # fetches the corpus, then runs the deep suite

Data model

Table Contents
files One row per ingested FIT, keyed by content hash
activities One row per session, plus a parent row for multisport
laps Intervals — what makes a structured session legible
workout_steps The prescription, when a session came from a structured workout
records One sample per second: HR, pace, altitude, power, running dynamics

Wide tables rather than key/value: DuckDB is columnar, so unused columns cost almost nothing and SELECT heart_rate reads exactly one column. An extra JSON column absorbs rare fields, so a new device never silently loses data.

Ingestion is idempotent. Identity is the content hash, so the same ride pulled from Garmin and later dropped into the inbox by hand is recognised as one file whatever it is named. Re-ingesting replaces rather than merges, inside a single transaction.

Testing

make test        # 199 tests, hermetic — no data, no network
make test-all    # 243 tests, adds validation against real recordings

The committed suite is entirely synthetic. fitdecode only reads FIT files, so testing the parser would normally mean committing real recordings — but a GPS trace starts at someone's front door, and that has no place in a public repository. tests/fit_builder.py is a minimal FIT encoder written for the purpose: the suite runs anywhere after a clone, and it can fabricate a multisport triathlon that the author never actually records.

The real-device corpus is third-party licensed and gitignored. Every quirk it revealed is reproduced synthetically, so regressions are caught without it.

Docker

docker compose up -d ingest          # background sync, the only writer
docker compose run --rm auth         # log in once (interactive)
docker compose logs -f ingest

One writer, enforced by the compose file: DuckDB grants exclusive access to a single writer and blocks readers while it is held, so only ingest may write. It stops on SIGTERM with a 30-second grace period rather than being killed mid-transaction.

The image runs as a non-root user. /data is the only mutable path and the only one worth persisting; the build context excludes it entirely, so no database, FIT file or token can end up in a layer.

For the stdio transport the MCP client owns the process lifecycle, so register the command with the client rather than starting it with compose:

claude mcp add garmin -- docker compose -f /abs/path/docker-compose.yml \
  run --rm -T mcp-stdio

-T matters: without it compose allocates a TTY and corrupts the JSON-RPC stream on stdout.

Profile What it adds
(default) ingest — the sync worker
tools auth, import — one-shot commands
http mcp-http — the server over streamable HTTP, bound to localhost
playwright ingest-playwright — Chromium fallback, ~1 GB

Continuous integration

GitHub Actions runs, on every push and pull request: ruff (lint and format), mypy in strict mode, pytest on Python 3.12 and 3.13, a Docker build with a smoke test that the image actually starts, the corpus suite as a job of its own, and a scan of every commit in history for credentials, databases and FIT files.

That last job exists because this repository is built around personal data: a secret committed by accident stays recoverable long after it is deleted from the working tree, so checking the current state is not enough.

Configuration

Two values matter, and only if you want automatic sync:

GARMIN_EMAIL=
GARMIN_PASSWORD=

One more is worth knowing about:

GARMIN_ENABLE_WRITES=false

Off by default. It gates the three tools that touch your Garmin account rather than just reading it. Everything else in .env.example already has a working default.

Privacy

This repository is built on the assumption that training data is personal. GPS traces start where you live.

  • .gitignore was in the first commit, before any data existed: .env, tokens, data/, *.duckdb, *.fit.
  • Nothing is sent anywhere. The database, the raw files and the tokens all stay on your machine.
  • The MCP server has no authentication of its own. Under stdio that is fine — only the client that launched it can talk to it. If you switch to streamable HTTP, keep it bound to localhost.
  • The server holds no Garmin credentials at all, not even for reading your workout library. Every call that needs a session goes through the ingest worker.

Limitations

  • The cffi backend is an arms race. It works today. Garmin can change its fingerprinting at any time, and that is what the manual inbox is for.
  • The Playwright backend is unverified against a live account. Its structure, error mapping and interface conformance are tested; its network calls are not. Expect to adjust the endpoint paths on first run.
  • Activities only. HRV, sleep, Body Battery and training status are not ingested. The schema leaves room for them.
  • One user per database. Multi-tenancy would be one database file per user rather than a user_id column.
  • Writing covers workouts, scheduling and activity notes. Pushing a workout directly to a device, and editing an existing session, are not exposed, though the library supports both.

License

MIT. See LICENSE.

Not affiliated with or endorsed by Garmin. "Garmin" and "Garmin Connect" are trademarks of Garmin Ltd.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选