跳到正文
DEV Community· Ivan Ficko·· 2 小時前AI 評分69

Ivan Ficko 建立 garminconnect-js,並以 MCP 伺服器讓 Claude 存取 Garmin 數據

I gave Claude access to my Garmin data: a free MCP server and TypeScript library

AI 導讀

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.

The garmin.ficdev.xyz homepage: the connector URL, and an example of Claude comparing two weeks of sleep

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 TokenStore interface.
  • 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:

  1. stepOrder is 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.
  2. 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.
  3. A rest in seconds isn't a time step. It's a different end condition entirely.
  4. 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=0 returns [] 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

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