Ivan Ficko 建立 garminconnect-js,並以 MCP 伺服器讓 Claude 存取 Garmin 數據
I gave Claude access to my Garmin data: a free MCP server and TypeScript library
Ivan Ficko 開源 garminconnect-js TypeScript 客戶端及 Garmin MCP 伺服器,讓 Claude 等 MCP 客戶端以自然語言讀取和管理 Garmin Connect 數據。
I wear a Garmin watch every day, and for a long time my data lived in one place: the Garmin Connect app. I wanted it in my own code, and lately I wanted to ask an AI about it: "How did I sleep this week?", "Am I ready for intervals today?", "Build me a 6x400 session and put it on my watch."
Garmin has no public API for personal Connect data, so I built two things:
- garminconnect-js, a TypeScript client for Garmin Connect with 200 typed methods and zero runtime dependencies.
- A Garmin MCP server on top of it, so Claude (or any MCP client) can read and manage your Garmin data in plain language.
Everything is free and open source, and the home for both is garmin.ficdev.xyz.
Try it in a minute: add https://garmin.ficdev.xyz/mcp as a custom connector in Claude, then ask how you slept this week.
Part 1: Garmin in Claude, in about a minute
If you just want to talk to your training data, you don't need to install anything. A hosted copy of the MCP server runs at:
https://garmin.ficdev.xyz/mcp
claude.ai (web, Desktop and the phone apps): Settings, Connectors, Add custom connector. Paste the URL, sign in to Garmin in the window that opens (with your MFA code if you use one), and you're done. It follows your account to Claude Desktop and mobile.
Claude Code:
claude mcp add --transport http garmin https://garmin.ficdev.xyz/mcp
Any other MCP client: add a streamable-HTTP server with that URL.
The hosted server never stores your Garmin tokens. They're sealed inside the OAuth token your MCP client holds, so the server keeps no copy of them. If you'd rather run everything locally, there's a one-click Claude Desktop Extension (.mcpb) on the releases page and an npm package, @dynamicsninja/garminconnect-mcp.
What you can ask
Plain questions work. Claude picks the tools and shows you which ones it used:
- "How did I sleep this week compared with last week?"
- "What has my HRV done over the last month?"
- "Am I ready to train hard today?"
- "Find my runs over 10 km since June, longest first, and compare their average heart rate."
- "What are my race predictions?"
- "Log 72.4 kg for this morning."
And the one I built it for:
"Make me a 6x400 m session at 5k pace with 2-minute jog recoveries, put it on Thursday, and send it to my watch."
Claude previews the workout in readable form (steps, targets, paces in /km and /mi) and saves nothing until you confirm. Then it saves it to your workout library, schedules it, and pushes it to your device. Strength, swim, HIIT, yoga, pilates, mobility, cycling and multi-sport work too, and exercise names are checked against Garmin's catalogue so a typo can't slip through.
Tools that delete or overwrite data (activities, workouts, gear, weigh-ins) are marked destructive, so Claude asks you first.
Part 2: Garmin in your code
npm install garminconnect-js
import { GarminClient, Garmin, FileTokenStore } from "garminconnect-js";
const client = new GarminClient({ tokenStore: new FileTokenStore("./tokens") });
const result = await client.login(process.env.GARMIN_EMAIL!, process.env.GARMIN_PASSWORD!);
if (result.state === "mfa_required") {
await client.resumeLogin(result.mfaState, await promptForCode());
}
const garmin = new Garmin(client);
const sleep = await garmin.getSleepData("2026-10-22");
const hrv = await garmin.getHrvData("2026-10-22");
What you get:
-
Garmin's SSO and OAuth flow handled for you, including MFA.
login()doesn't block on MFA. It returns a plain-JSON state you can carry across two HTTP requests, which is what you need in a serverless Next.js route. -
Automatic token refresh. Sessions roll forward as long as you make a call at least once every 30 days. Tokens go wherever you want: memory, disk, or your own database through a three-method
TokenStoreinterface. - 200 typed methods: sleep, HRV, stress, Body Battery, daily summaries, activities (list, search, details, splits, download, upload), training readiness and status, race predictions, workouts, gear, courses, calendar events, devices, badges, weigh-ins, women's health, golf, nutrition and training plans.
-
An escape hatch.
client.connectapi(path)calls any Garmin Connect endpoint with the same auth, for the rare thing the library doesn't wrap. - Zero runtime dependencies, Node 18+. It's server-only on purpose: your Garmin credentials should never reach a browser.
Want a running app first? The Next.js starter template signs in (MFA included) and charts your sleep and HRV, and there's a live demo.
The workout builder, or: Garmin's API is quietly hostile
Writing workouts was the hardest part, because Garmin's workout JSON has traps that don't fail loudly. They succeed and store the wrong thing:
-
stepOrderis global. Steps inside a repeat block don't restart at 1. They continue the outer sequence. Number them per block and the upload is rejected; number them per segment in a multi-sport workout and it's rejected again. - Pace targets are speeds in metres per second, fastest first. Send "4:00 to 4:12 min/km" the obvious way and Garmin happily stores a nonsense target.
-
A rest in seconds isn't a
timestep. It's a different end condition entirely. - Every sport, step and condition is an id/key pair that must agree.
So the library has a fluent builder that makes all four unreachable:
import { buildWorkout } from "garminconnect-js";
const workout = buildWorkout("6x400", { sport: "running" })
.warmup({ lapButton: true })
.repeat(6, (set) =>
set
.interval({ distance: 400, target: { pace: { minPerKm: [4, 4.2] } } })
.recovery({ time: 120 }),
)
.cooldown({ time: 600 })
.build();
await garmin.uploadWorkout(workout);
The pace converts to descending m/s for you, step orders are counted across the whole tree, and the sport/step ids come from Garmin's own enums.
Exercise names fail silently, so they're type-checked
My favourite trap: if you send a strength exercise name Garmin doesn't recognise, it stores an empty string and returns success. Your "Barbell Back Squat" quietly becomes a blank step on your watch.
So garminconnect-js/exercises ships Garmin's catalogue as types: 1,830 names across 51 categories, every one uploaded to a live account and kept only if Garmin echoed it back unchanged. Once you pick a category, name autocompletes to that category's exercises, and anything else is a compile error:
buildWorkout("Legs", { sport: "strength_training" })
.interval({ reps: 8, exercise: { category: "SQUAT", name: "BARBELL_BACK_SQUAT" } })
.interval({ reps: 8, exercise: { category: "SQUAT", name: "BARBELL_BENCH_PRESS" } }); // tsc error
It lives in a separate entry point, so the root bundle doesn't carry the catalogue.
For input that can't hold callbacks (a database row, a form, an LLM tool call), workoutFromSpec takes the same workout as plain JSON and comes with a JSON Schema. That's exactly what the MCP server hands to Claude, and when Claude gets something wrong, the error comes back with the JSON path and the closest valid exercise names, so it can fix its own mistake.
"Verified" means I read it back
Since the endpoints are undocumented, a passing test suite against mocks proves very little. A mock written from an assumption agrees with the code written from the same assumption. Three real bugs shipped that way early on, including one that stored a 93 kg weigh-in as 93,000 kg.
So every method carries a live-verification note, and a write only counts as verified once the stored value has been read back. A 2xx on its own has hidden real defects more than once:
- A "walking workout" helper got a 2xx from Garmin every time. Reading it back showed the workout was stored with no sport at all, because Garmin has no walking workout type. The helper was removed.
- Pushing a workout to a watch was declared to return a workout. The first time it ran against a real device, it returned an array of device messages.
- Goal lists are 1-indexed, and
start=0returns[]with no error, indistinguishable from "you have no goals". - Perceived effort (RPE) is stored times ten.
All of these are written down in AGENTS.md, which doubles as a briefing for AI coding agents, so Copilot or Claude Code doesn't confidently invent a method that doesn't exist.
How the MCP server stays in sync
The MCP server holds no Garmin knowledge of its own. A script reads the library's source with the TypeScript compiler and generates a manifest of every method: parameters as JSON Schema, plus a safety class (read, write or destructive). Each tool is generated from that manifest, so when a method is added to the library, the tool appears in Claude after the next build, already annotated. Arguments are validated against the schema before any request reaches Garmin.
The fine print
This is unofficial and not affiliated with Garmin. It uses the same undocumented endpoints as the Garmin Connect web and mobile apps, so Garmin can change them at any time. Don't build anything safety-critical on it. One gotcha worth knowing up front: on an EU account, every write returns a 412 until you've granted upload consent in Garmin Connect's settings.
Try it
-
Talk to your data in Claude: add
https://garmin.ficdev.xyz/mcpas a custom connector -
Use it in code:
npm install garminconnect-js - Docs, setup for every client, and the demo: garmin.ficdev.xyz
- Source (MIT, a star helps): github.com/DynamicsNinja/garminconnect-js
If you build something with it, or find an endpoint that behaves differently on your account, I'd love to hear about it in the comments or in a GitHub issue. And if you try the connector, tell me the most useful question you asked Claude about your training.
來源:DEV Community · dev.to
