Skip to main content
Glama
garmin-mcp
by barcia

garmin-mcp

A remote MCP server that gives Claude (web, desktop, mobile and Claude Code), or any MCP client, read-only access to your Garmin Connect data. It uses Garmin's official Personal API: no Garmin password, no scraping. It runs on Cloudflare Workers with the Agents SDK and is protected by OAuth 2.1.

Everyone deploys it to their own Cloudflare account with their own Garmin key: it is a single-user server (see Deployment).

Contents

Related MCP server: garmin-mcp

How it works

MCP client  ──OAuth 2.1──▶ garmin-mcp Worker ─┬─ /authorize       consent page, behind Cloudflare Access
(Claude, Cursor…)                              ├─ /token, /register, /.well-known/*   OAuth server (OAuthProvider)
                                               └─ /mcp            MCP server (Streamable HTTP, stateless)
                                                                   └─▶ personal.connectapi.garmin.com
  1. The client connects to /mcp. Without a token it gets a 401 pointing to the OAuth metadata (/.well-known/oauth-protected-resource/mcp).

  2. The client registers itself, through dynamic client registration (/register) or a Client ID Metadata Document, and opens /authorize in the browser.

  3. You sign in and approve. Cloudflare Access protects /authorize: it asks for a one-time code sent to your email before the page loads, and only your address is allowed. The consent page then shows which application is asking and where the token will be sent, and you click Allow. The server has exactly one user: you.

  4. The client gets its tokens: a 1-hour access token and a refresh token. The refresh token lasts as long as you use the client at least once a month, for up to a year.

  5. The server calls Garmin. On each tool call it exchanges your Personal API key for a 24-hour Garmin token, caches it in memory and in KV, and calls the API.

The Garmin key never leaves the Worker: MCP clients only receive OAuth tokens issued by this server.

On top of forwarding API calls, the server shapes the responses so a model reads them well:

  • Long ranges: date ranges are split into the windows each endpoint allows (28 days for steps and stress, 31 for sleep or Body Battery, 1 year for daily summaries) and the results are merged.

  • Compact views: activity search and the daily and sleep histories return one row of key metrics by default; detail=full returns Garmin's complete response.

  • Aggregation:

    • Daily, sleep and weigh-in histories average by week or month (period).

    • Activity search totals by period and sport (groupBy), merging subtypes: street, trail and treadmill running all count as running.

  • Clean output:

    • Nulls and social noise are dropped.

    • Epoch timestamps become ISO 8601 and decimals are rounded.

    • Sample arrays are left out unless requested (includeSamples), and each result is capped at about 100,000 characters.

  • Fixed units: Garmin sends weight and body composition in grams although its reference says kilograms; the server returns kilograms.

  • Decoded FIT files: garmin_get_activity_fit downloads the original ZIP and decodes it with Garmin's official FIT SDK. It returns sessions, laps, per-kilometre splits computed from every record, Garmin's own splits, time in zones and, on request, the timeline as a table.

  • Actionable errors: Garmin failures (expired key, invalid range, 429…) reach the model as tool errors that say how to fix them.

  • "Today" in your time zone: set by TIMEZONE. The server instructions include today's date and weekday.

Tools and prompts

There is one tool per Personal API endpoint (26), with every documented parameter. All of them are read-only (readOnlyHint).

Tool

Returns

Garmin endpoint

Daily

garmin_get_daily_summary

One whole day: steps, calories, floors, intensity minutes, heart rate, stress, Body Battery, SpO2, respiration, hydration

usersummary/daily

garmin_get_daily_summaries

A summary per day for a range, or weekly or monthly averages

v2/dailysummaries

garmin_get_steps

Steps, distance and goal per day

stats/steps/daily

garmin_get_stress

Daily stress (0-100)

stats/stress/daily

garmin_get_hydration

Logged water, goal and estimated sweat loss

stats/hydration/daily

garmin_get_heart_rate

One day's heart rate: resting, min/max, 7-day average and, optionally, the full timeline

wellness/dailyHeartRate

garmin_get_spo2

One day's pulse oximetry and hourly averages

wellness/daily/spo2acclimation

garmin_get_body_battery

Body Battery per day, with chunks for min and max per interval (e.g. hourly)

wellness/bodyBattery/reports/daily

Sleep and HRV

garmin_get_sleep

One night: stages, score, sleep need, heart rate, stress, SpO2

sleep/dailySleep

garmin_get_sleep_history

The nights in a range, or weekly or monthly averages

sleep/dailySleepSummariesByDate

garmin_get_hrv

Overnight HRV: average, baseline and status

hrv-service/hrv/{date}

garmin_get_hrv_history

HRV per day for a range

hrv-service/hrv/daily

Activities

garmin_search_activities

Search with every Garmin filter; with groupBy, totals per week, month or range and sport

activitylist-service/activities/search

garmin_get_activity

Full summary of one activity and its splits

activity-service/activity/{id}

garmin_get_activity_workouts

Structured workout performed during the activity

activity-service/activity/{id}/workouts

garmin_get_activity_fit

The decoded FIT file: sessions, laps, per-km splits, zones and timeline

download-service/files/activity/{id}

garmin_get_wellness_activities

Wellness activities in a range

wellnessactivity-service/activity/summary/range

Body

garmin_get_weight

Weight summary per day, range average and neighbouring weigh-ins

weight-service/weight/range

garmin_get_weigh_ins

Every weigh-in with body composition, or weekly or monthly averages with min and max

weight-service/weight/all

garmin_get_weight_goals

Weight goals

goal-service/goal/user/effective/weightgoal

garmin_get_fitness_age

Fitness age and its components

fitnessage-service/fitnessage/v2

garmin_get_heart_rate_zones

Heart rate zones configured per sport

biometric-service/heartRateZones/v2

Other

garmin_get_nutrition

Logged meals, goals and totals

nutrition-service/food/logs

garmin_get_menstrual_cycle

Menstrual cycle summary

periodichealth-service/menstrualcycle/summary

garmin_get_menstrual_cycle_day

Menstrual cycle detail for one day

periodichealth-service/menstrualcycle/dayview

garmin_get_profile_settings

Units, time zone and profile preferences

userprofile-service/userprofile/settings

Prompts (shortcuts that clients show as commands):

  • readiness_check: how ready am I to train today? Combines sleep, HRV, Body Battery, resting heart rate, stress and recent load.

  • training_review: a review of recent weeks of training (volume, intensity, recovery).

Repository layout

src/
  index.ts              Entry point: OAuthProvider protects /mcp and serves /authorize
  env.d.ts              Types for the bindings OAuthProvider adds at runtime
  auth/authorize.ts     Consent page, served only to requests that passed Cloudflare Access
  garmin/
    client.ts           Personal API client: key exchange, token cache, errors
    dates.ts            Dates: "today" per time zone, range windows
    fit.ts              FIT unzipping and decoding, distance splits
  mcp/
    server.ts           MCP server: instructions, tool and prompt registration
    context.ts          Shared schemas (dates, ranges) and range splitting
    result.ts           Response formatting: cleanup, timestamps, size cap, errors
    summaries.ts        Compact views and aggregations (period, groupBy)
    tools/              Tools by area: daily, sleep, activities, body, profile
test/                   Vitest suite running inside workerd (Garmin mocked)
evals/                  Synthetic prompts and a runner to test with real Claude
.github/workflows/      CI: typecheck and tests on every push and PR
wrangler.jsonc          Worker configuration: bindings, variables, required secrets
worker-configuration.d.ts  Types generated with `pnpm cf-typegen`

Configuration

Secrets

They live in .env locally and as Worker secrets in production. They never go to git.

Name

What it is

Where to get it

GARMIN_API_KEY

The Personal API key of your Garmin Connect account. The server exchanges it for 24-hour tokens.

In Garmin Connect → Personal API, signed in on the web: Create Key, accept the terms and pick an expiry (30, 90, 180 days or 1 year). It is shown only once. Only one key can be active: creating another requires deleting the current one.

ACCESS_AUD

Audience (AUD) tag of the Cloudflare Access application that protects /authorize. The Worker only serves the consent page to requests Access authenticated for this application, so nothing else can approve clients. It is not secret, but it is specific to your account.

Zero Trust → Access → Applications → your application → Overview (see Cloudflare Access). Locally, keep local-dev.

Variables

Set in wrangler.jsonc, under vars. They are not secret.

Name

Value

Purpose

GARMIN_REGION

global

global uses personal.connectapi.garmin.com; cn uses the Garmin Connect China endpoint.

TIMEZONE

Europe/Madrid

IANA time zone used to resolve "today" when a tool gets no date.

Bindings

Binding

Type

Purpose

OAUTH_KV

KV namespace (garmin-mcp-oauth-kv)

OAuth state: registered clients, granted consents and tokens (stored only as hashes, with their data encrypted). It also caches the Garmin token so every Worker instance shares it.

Cloudflare Access

/authorize is protected by a self-hosted Access application, so only you can approve clients. It has no password to leak or brute-force: Access signs you in with a one-time code sent to your email.

  1. In the Cloudflare dashboard, open Zero Trust. The first time, it asks you to pick a team name.

  2. Go to Access → Applications → Add an application → Self-hosted:

    • Domain: garmin-mcp.<your-subdomain>.workers.dev, path authorize. Only that path is protected; /mcp, /token, /register and the metadata must stay reachable for MCP clients.

    • Login method: One-time PIN, the only one enabled, with instant auth so you skip the provider screen.

    • Policy: Allow, include Emails → your email address.

    • Session duration: 24 hours is a good default.

  3. Copy the application's Application Audience (AUD) tag and store it as ACCESS_AUD.

wrangler dev simulates Access with the access.dev block in wrangler.jsonc, so locally ACCESS_AUD=local-dev and no sign-in is needed.

Environment files

File

In git

Contents

.env.example

yes

Template with the keys documented and empty

.env

no

Your local secrets. wrangler dev loads them and a deploy can upload them (--secrets-file .env).

Local development

You need Node 26 (fnm use reads .node-version) and pnpm, at the version pinned by packageManager.

pnpm install
cp .env.example .env

Fill in GARMIN_API_KEY in .env (ACCESS_AUD stays local-dev) and start the server. It runs at http://localhost:8787/mcp:

pnpm dev

To try it by hand you have two options:

  • MCP Inspector: start it and connect to http://localhost:8787/mcp. It takes you to the consent page.

    pnpm dlx @modelcontextprotocol/inspector
  • Claude Code, on the same machine:

    claude mcp add --transport http garmin-local http://localhost:8787/mcp

Locally, KV is simulated and stored in .wrangler/state, and Access is simulated by access.dev in wrangler.jsonc.

Scripts:

Script

What it does

pnpm dev

Local server with hot reload

pnpm check

Typecheck and tests

pnpm typecheck

TypeScript for src/ and test/

pnpm test

Vitest suite inside workerd

pnpm run deploy

Deploy to Cloudflare (plain pnpm deploy is a different pnpm command)

pnpm cf-typegen

Regenerate worker-configuration.d.ts after changing wrangler.jsonc

pnpm evals

Run the synthetic prompts against a running server

Tests, CI and evals

  • Tests (test/) run inside the Workers runtime with @cloudflare/vitest-plugin, mocking Garmin's responses. They cover:

    • the Garmin client: key exchange, token cache, retry on 401 and errors;

    • the full OAuth flow, and that the consent page refuses requests without this Access application's identity;

    • range splitting and search filters;

    • compact views, aggregations and the kilogram conversion;

    • a real FIT file generated with the SDK's encoder.

  • CI: .github/workflows/ci.yml runs pnpm typecheck and pnpm test on every push to main and every pull request. It does not deploy.

  • Evals: evals/prompts.json holds 25 realistic questions: sleep, recovery, trends, training, totals, and cases with no data or out of scope. They are in Spanish on purpose, plus one in English, to test a non-English user. evals/run.mjs runs them through Claude Code in headless mode against a running server, with no tools other than this MCP. It records which tools the model chose, errors, sizes and the answer. It needs the Claude CLI signed in. Results contain your data and go to evals/results/, which is git-ignored.

    pnpm evals --base http://localhost:8787 --model sonnet

Deployment

What gets deployed

Worker

garmin-mcp, in your Cloudflare account

MCP URL

https://garmin-mcp.<your-subdomain>.workers.dev/mcp (the workers.dev subdomain of your account)

Root (/)

JSON with the name, version and MCP URL, handy as a health check

KV

garmin-mcp-oauth-kv, created by the first deploy and reused by later ones

Secrets

GARMIN_API_KEY, ACCESS_AUD (type secret_text)

Access

A self-hosted Access application on /authorize that only allows your email

Observability

Workers logs on (dashboard → Workers & Pages → garmin-mcp → Logs)

Preview URLs

Off: the production origin is the only OAuth issuer

How it's deployed

By hand from your machine with pnpm run deploy. CI only checks.

First deploy

  1. Sign in to wrangler (if it hangs, see Troubleshooting):

    pnpm wrangler login
  2. Create the Cloudflare Access application for /authorize and put its AUD tag in .env as ACCESS_AUD, next to GARMIN_API_KEY. If you don't know your workers.dev subdomain yet, deploy first: until ACCESS_AUD matches, /authorize answers 403.

  3. Deploy, uploading both secrets at the same time:

    pnpm wrangler deploy --secrets-file .env
  4. Wrangler prints the Worker URL: add /mcp and connect a client.

Deploying changes

Check and deploy. Secrets and KV are kept between deploys:

pnpm check && pnpm run deploy

Connecting a client

  • Claude (web, desktop, mobile): on claude.ai, Settings → Connectors → Add custom connector, with the MCP URL. A connector added there is also available in the desktop and mobile apps.

  • Claude Code:

    claude mcp add --transport http garmin https://garmin-mcp.<your-subdomain>.workers.dev/mcp

    Then authenticate it from /mcp.

  • Other clients (Cursor, VS Code…): any MCP client with OAuth and Streamable HTTP transport. Add it with the same URL.

The client opens /authorize in your browser: sign in with the code Access emails you, check the application name and destination on the consent page, and click Allow. This flow has been tested end to end with Claude on the web.

Operations

  • The Garmin key expires: Garmin emails you a week before. Create a new key (deleting the old one) and update the secret. The cached Garmin token stops being used on its own, because the cache is tied to the key.

    pnpm wrangler secret put GARMIN_API_KEY
  • Change who can approve clients: edit the policy of the Access application (Zero Trust → Access → Applications). Clients already connected keep working until their tokens expire.

  • Live logs:

    pnpm wrangler tail garmin-mcp
  • Disconnect every client: clear the garmin-mcp-oauth-kv namespace from the dashboard. Clients will have to authorize again.

Security

  • Access to /mcp: only with an OAuth token issued by this server for its own URL, with the garmin:read scope.

  • Consent page:

    • It shows the application's name, whether it is verified and the host the token will be sent to, and warns when the destination is a local app.

    • The form carries a single-use handle bound to the browser and cannot be embedded in another site.

    • Only you reach it: Cloudflare Access signs you in with a one-time code to your email, and the Worker fails closed (403) unless the request carries this Access application's identity (ctx.access), so it can't be bypassed through another route.

  • Secrets: the Garmin key only lives in the Worker's secrets. MCP clients never see it, and it never appears in logs or responses.

  • Read-only: the Personal API cannot write, and the server only sends GET requests.

  • Personal use: the Personal API is for the key's owner. This server is meant for a single user and should not be shared with others.

Limits

  • What the Personal API doesn't offer: Training Readiness, VO2 max, race predictions, devices, gear, courses, or uploading workouts. It is read-only and on demand, with no webhooks.

  • Rate limits: Garmin publishes none. The server makes the requests for a range one after another, and groupBy scans at most 1,000 activities (10 requests).

  • CPU: decoding a long FIT file takes more than the 10 ms of CPU per request of the Workers Free plan. The heaviest FIT file tested in production used 157 ms and worked.

  • Cost: a single user's KV and Workers usage fits comfortably in Cloudflare's free tier.

Troubleshooting

  • wrangler login waits and times out: use the device code flow. It doesn't need the browser to call back to localhost, and here it only asks for the permissions this project needs:

    pnpm wrangler login --device --scopes account:read user:read workers:write workers_kv:write workers_scripts:write workers_tail:read
  • /authorize answers 403 "Access required": the request didn't come through the Access application, or ACCESS_AUD doesn't match its AUD tag. Check the application's domain and path and the secret.

  • Tools answer "Garmin rejected the Personal API key": the key expired or was deleted. Create another and update GARMIN_API_KEY.

  • A client can't connect or asks to authorize again: its refresh token expired (over a month without use) or the KV was cleared. Authorize it again from the client.

  • Two wrangler dev at once fail with SQLITE_BUSY: they share .wrangler/state. Start the second one with its own state:

    pnpm dev --port 8788 --persist-to /tmp/garmin-mcp-state

License

MIT © Iván Barcia

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Exposes personal Garmin wellness data through MCP tools for accessing summary, sleep, HRV, heart rate, stress, body battery, and historical data.
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Exposes Garmin Connect health and activity data (steps, sleep, heart rate, etc.) via MCP tools, with built-in login and MFA support.
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    Exposes Garmin Connect data through MCP, enabling retrieval of activities, health metrics, training status, and workouts, with OAuth authentication and confirmed write operations.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables MCP clients to read and update Garmin Connect data, including activities, health metrics, training status, and more, with per-account multi-tenant authentication.
    3
    MIT