Skip to main content
Glama
Dtsiakas

Running Companion MCP Server

by Dtsiakas

Running Companion

Personal running and health dashboard built on Garmin's official, read-only Personal API. Everything runs locally: the API key and the database stay on this machine. The exception is the AI chat integration (see Privacy). It runs only on what the watch records: there is nothing to type in. Races, race predictions and training plans stay in Garmin Connect, which remains the coach.

Stack

Part

Tech

Status

Garmin API client

TypeScript, native fetch

done

FIT file decoding

@garmin/fitsdk (Garmin's official JS SDK)

done

Database

SQLite via Node's built-in node:sqlite

done

Sync (backfill + incremental)

TypeScript

done

Metrics engine

TypeScript

done

MCP server (chat with an AI assistant about the data)

@modelcontextprotocol/sdk

done

Web server (API, remote MCP with OAuth, scheduled sync)

Express 5, MCP SDK auth

done

Dashboard (PWA)

React 19, Vite, React Router, TanStack Query, Recharts, CSS Modules

done

Related MCP server: garmin-mcp

Layout

packages/
  garmin-client/   API key → bearer token exchange, token cache, typed endpoints, throttling
  db/              SQLite connection, migrations (db/migrations/NNN_*.sql), upsert helpers
  sync/            raw JSON → rows (mappers), FIT parsing, date-range walker, sync orchestration
  metrics/         derived metrics: run type, load, intensity, efficiency, best efforts, recovery status
  mcp/             MCP server for Claude Desktop: read-only tools over the database, plus an on-demand sync
  insights/        signals, personal patterns, reports, sleep, body, progress (statistics without dependencies)
  weather/         Open-Meteo client, heat model, weather backfill and forecast
  queries/         read-only queries shared by the dashboard API and the MCP tools
  jobs/            single-flight Garmin sync + metrics, database preparation, read-only connections
  server/          web server: login, OAuth for Claude, /api, /mcp over HTTP, scheduled sync
  web/             dashboard PWA (React); `npm run build` writes packages/web/dist, which the server serves
  config/          project paths, .env loading, the settings file
  demo/            generated demo runner, written into the same tables a sync fills
  cli/             command line entry points: sync, metrics, discover, auth:check, demo
data/              your data (gitignored)
  companion.db       the database
  settings.json      password hash, session secret, Garmin key (owner-only; written by the setup page and Settings)
  fit/             original FIT file of every activity
.cache/            bearer token cache (gitignored)

Setup

Requires Node 26 or newer (see .nvmrc). With nvm-windows: nvm use 26.13.0. Node 26 runs the server's TypeScript sources directly (type stripping), so the server has no build step; only the dashboard is built (npm run build). erasableSyntaxOnly in tsconfig.json rejects the few TypeScript features Node can't strip (enums, parameter properties, namespaces).

npm ci
npm run build
npm start

The server's log prints a setup link (/setup?code=…). Open it, choose a password and paste your Garmin key, which you create at https://connect.garmin.com/app/settings/personalApiToken (Garmin shows it only once; note the expiry date it shows too, and the app will remind you two weeks before). The first sync starts straight away. Everything set there can be changed later under Settings. No config file is needed; .env.example lists what the server optionally reads.

Demo

npm run build, then npm run demo, then open http://localhost:8788. It's the whole app on a made-up runner with three years of runs (per-second records, laps, workouts), sleep, HRV, stress, Body Battery, weigh-ins and weather, generated the same way every time. Nothing syncs, nothing can be changed, and there's no login, so it's safe to show anyone.

The same generator checks how the app copes with less data, as from other watches or setups:

npm run demo -- --missing hrv,power,dynamics   # an older watch
npm run demo -- --missing scale,sleep           # no smart scale, watch off at night
npm run demo -- --days 42                       # six weeks in

Streams that can be left out: dynamics, power, hrv, scale, battery-events, sleep, gps, fitness-age. Each variant lives in its own folder under .demo/ (gitignored) and is made again when the day changes.

Commands

Command

What it does

npm run up

Turns the app on, on this laptop: connects Tailscale, pulls and builds new commits, starts the server (with a sync), opens the public address. Ctrl+C or closing the window turns all of it off

npm run down

Turns everything off from any terminal, including leftovers if the up window was killed

npm run demo

Runs the app on generated data on http://localhost:8788: no Garmin key, no login, nothing of yours (see Demo)

npm start

Starts the web server: dashboard, /api, remote /mcp, scheduled sync (default http://localhost:8787)

npm run build

Builds the dashboard into packages/web/dist (run after every pull that changes packages/web)

npm run dev:web

Dashboard with instant reload on http://localhost:5173, proxying /api to a running npm start

npm run set-password

Sets a new login password from the server's terminal (forgot it?); logs out every browser

npm run sync

First run: downloads all available history. Later runs: only what's new (plus the last 3 days, which Garmin finalises late)

npm run sync -- --since 2023-01-01

Backfill down to a date, even through months without data

npm run sync -- --only sleep,hrv

Sync some streams only: activities, daily_wellness, sleep, hrv, weigh_ins

npm run sync -- --max-chunks 5

Limit backfill per stream in this run; the next run continues

npm run sync -- --delay 500

Milliseconds between API requests (default 250)

npm run metrics

Recomputes metrics (sync already does this at the end)

npm run metrics -- --full

Recomputes every activity, e.g. after changing a formula

npm run mcp:install

Registers the MCP server with Claude Desktop (backs up the existing config)

npm run mcp:install -- --print

Prints the config entry instead of writing it

npm run mcp:doctor

Checks the setup step by step: config, Node, server start, a tool call, and the end of the server log

npm run auth:check

Confirms the key works. Prints nothing secret

npm run discover

Calls every endpoint, decodes your latest run's FIT file, writes data/discovery/REPORT.md

npm test

Unit tests (synthetic data only)

npm run typecheck

Type-checks all packages

How the data is stored

Three layers:

  1. raw_responses: every API response exactly as received. New columns can be filled from it later without re-downloading.

  2. Typed tables: activities, activity_laps, activity_records (one row per second), daily_wellness, sleep, hrv, weigh_ins, profile_snapshots.

  3. Derived metrics: activity_metrics, activity_best_efforts, daily_metrics and the weekly_summary view. Always recomputable from layers 1–2; formulas are documented in packages/metrics/src.

Conventions: units in column names in base units (distance_m, weight_kg, gct_ms), calendar_date as local YYYY-MM-DD, timestamps as UTC milliseconds, STRICT tables. Schema changes are new numbered files in packages/db/migrations/; never edit an applied one.

Metrics and their assumptions

Metric

Formula

Main assumption

Run type

Planned workout name, else Garmin training effect, else duration (75+ min = long)

Intent, not what happened; compare with the intensity split

Load (TRIMP)

Banister: Σ minutes × HRr × 0.64 × e^(1.92·HRr)

Max HR and resting HR are right

Intensity split

Time below LT1 / between LT1 and LTHR / above LTHR

LT1 ≈ 88% of Garmin's LTHR (no lab test)

Efficiency

Speed ÷ HR after the first 10 min, as metres per heartbeat

Comparable only between similar runs (use easy runs)

Pace at reference HR

Median speed while HR is within ±3 bpm of 85% LTHR

Fixed reference across all history

Decoupling

Efficiency drop from first to second half, steady runs of 30+ min

Flat routes; heat inflates it

Best efforts

Fastest window per distance inside each run

GPS distance; short distances can be flattered by a second or two

Fitness / fatigue / form

42-day and 7-day exponentially weighted load; form = yesterday's fitness − fatigue

Population constants, not fitted to you

Recovery status

HRV, resting HR, sleep, load compared with your own 60/28-day baselines; red needs 2 systems

Rules of thumb; complements Garmin, doesn't replace it

Thresholds come from the latest Garmin profile and are applied to all history so trends share one yardstick.

Chatting about the data (MCP)

npm run mcp:install, then fully quit and restart Claude Desktop. Claude Desktop starts packages/mcp/src/server.ts itself when needed.

Tool

What it answers

get_today

Recovery status with reasons, HRV, resting HR, sleep, fitness/fatigue/form, weight trend, fitness age, last run, data freshness

get_weekly_summary

Week-by-week volume, load, intensity split, efficiency, sleep, HRV, weight

list_activities, get_activity

Runs with formatted pace and metrics; one run in full with planned workout steps, laps and best efforts

get_best_efforts

Fastest 400 m to marathon inside any run

get_trend

One metric per week or month (fitness, HRV, sleep, weight, pace at reference HR, efficiency, …)

describe_data, query

Schema, units, formulas and caveats; read-only SQL for anything else

get_weekly_report

The computed report for a week; Claude's weekly review starts from it

get_sleep_stress_report

Four weeks of nights, tonight's suggested bedtime, sleep debt against Garmin's need, timing regularity, stages; Body Battery charged vs drained and what drives it, stress minutes, workdays vs weekends

get_run_forecast

Today's and tomorrow's hourly weather where you usually run, the heat cost of each hour, the best window and the hour to avoid

get_progress

Totals, milestones, next milestones, records, streaks, personal bests; a year in review; running form by month

get_body_report

Weight trend against the goal and whether the pace is healthy, composition, energy by day type, your sweat rate, daily protein and carbohydrate ranges

get_insights

Today's signals and your personal patterns, with sample sizes and p-values

sync_garmin

Pulls new data from Garmin and recomputes metrics

Every tool reads through a read-only database connection (the weather forecast cache is the one write), so no tool can change your data, and query stays SELECT-only. Claude's answers stay in the Claude chat; the app doesn't store them.

Web server

npm run build, then npm start, then open the setup link from its log (first time) or http://localhost:8787.

Path

Access

Purpose

/healthz

public

Up or not, last sync time

/setup

setup code

First start only: password and Garmin key; every other page waits for it

/api/settings

session

Garmin key status and replacement, key expiry date, password change (the key is never sent back)

/login, /logout

public

Dashboard login (password, 10 attempts per 15 min)

/.well-known/*, /authorize, /token, /register, /revoke

public

OAuth 2.1 for Claude's connector (MCP SDK)

/oauth/consent

password

Login + "Allow" step of the OAuth flow

/mcp

OAuth token

The MCP tools over HTTP (stateless)

/api/*

session cookie

Dashboard JSON: today, week, weeks, daily, baselines, activities (type, limit, offset), activities/:id, activities/:id/records, best-efforts, trend, sync

everything else

public

The dashboard's static files (code only, no data), so the browser can install and update the app before you log in

Security model: one user. The password is stored only as a scrypt hash; sessions are HMAC-signed HttpOnly SameSite=Lax cookies; OAuth codes and tokens are stored as SHA-256 hashes, access tokens last 1 hour, refresh tokens 30 days and rotate on use; every read uses a read-only database connection. Plain HTTP is only accepted for localhost: anything public must be HTTPS.

Dashboard

An installable web app (PWA): open it in Chrome on Android and choose "Install app", or the install icon in the address bar on a laptop. It gets its own icon and window, and the screens you opened recently stay readable offline.

  • Today: the recovery verdict, HRV, resting HR and sleep drawn as a dot on your own typical range, fitness, fatigue and form; last night in detail (score, stages, bedtime, breathing rate and SpO2 against your normal); Body Battery, yesterday's stress, steps and weekly intensity minutes; when to run; last run; body with fitness age.

  • Week: "The week in short" first: a headline and short lines on training, recovery and sleep, life and body (✓ good, ▲ worth attention, compared with the four weeks before), key sessions with planned vs actual, new personal bests, the signals raised that week. Then totals and the change from last week, intensity split, distance per week, a day-by-day table, recovery and body against last week, the runs.

  • Runs: every run by month. A run's page has pace, heart rate, power and elevation over the distance (one chart each, one shared cursor), planned vs actual per workout step, laps, effort, efficiency, running form and the fastest efforts inside it.

  • Worth knowing (on Today): signals from rules over your recent data, each with its numbers and what to do: possible illness (resting HR, HRV and breathing rate shifting together), Body Battery running down (draining more than it charges on most days while mornings start low), possible under-fuelling (weight dropping fast while recovery slips), overreaching, load spikes, a big jump in long-run distance, easy runs drifting above the first threshold, intensity balance, sleep debt, irregular bedtimes, stress streaks, and strong recovery.

  • What works for you (on Trends): two years of your own data compared with a statistical test (Welch's t-test, effect size): late bedtimes, short nights, evening runs, hard sessions, stressful days, running after a short night, on a low Body Battery or in the heat. Findings, "no clear effect" and "waiting for data" are kept apart, each with a caveat.

  • Ask Claude: buttons on Today, Week and each run open Claude with a ready request (train today?, health check, plan my week, fuel my long run, weekly review, run debrief). Claude works on your subscription through the connector.

  • Sleep (sleep and stress): tonight's bedtime, from Garmin's sleep need counted back from your usual wake-up for tomorrow's kind of day; when that's far from your usual bedtime, a realistic first step of 30 minutes earlier instead. Sleep against need over two weeks and the week's debt; a four-week sleep diary (each night from falling asleep to waking, weekends marked); how much bedtime and wake-up vary and the weekday-to-weekend shift; stages against Garmin's ideal ranges. Body Battery charged against drained each day, what charges and drains it (sleep, naps, workouts, stress, everyday), stress minutes now against before, workdays against weekends, and the patterns that affect your sleep.

  • Body: the weight trend against your Garmin goal (trend line, each weigh-in as a dot, goal as a dashed line), whether the pace is healthy for someone training (no faster than 0.5–1% of body weight a week) and when you'd reach the goal; body fat and muscle against a month ago, with a flag if muscle drops; fitness age and its biggest lever; calories on rest, training and big days; daily protein and carbohydrate ranges for your weight; a fuel planner (run length, hard, warm) for before, during and after. Ranges follow the sports-nutrition position stands (ACSM/AND/DC 2016, ISSN 2017); fluid amounts use your own sweat rate from Garmin's estimates. Guidance, not a diet.

  • Route and conditions (on each run): the route on a map, coloured by pace (strong for your fastest stretches); the air temperature, dew point, humidity and wind at the time and place of the run (Open-Meteo, not the wrist sensor), the heat cost, and the pace the same effort is worth on a cool day. Heat cost follows Hadley's rule: temperature plus dew point in °F, no cost up to 100, rising to 8–10% at 171–180.

  • When to run (on Today): the coolest dry window today and tomorrow where you usually run, and the hour to avoid.

  • Where you've run (on Runs): every place you've started runs, on a map, sized by how many.

  • Fuel and fluids (on each run): energy burned, sweat lost and its share of body weight, the run's sweat rate, and how to fuel a run like it.

  • Progress: the next milestones with a rough date, totals, records (longest run, biggest week and month, weekly streaks), personal bests per distance, every milestone passed (distance, run counts, first times under round times), running form by month and a year in review (volume against the year before, kilometres per month, personal bests set, how resting heart rate, HRV, sleep and weight moved, when you ran, the hottest and coldest run). Running form compares easy outdoor runs at the same pace: each measure's slope against speed is estimated inside each month, then every month is moved to your typical easy pace; the summary compares the last three months with the same three a year earlier, and power gets no verdict because Garmin's power model changes with firmware.

  • Trends: 27 metrics in six groups (training, aerobic fitness, recovery, sleep, daily life, body) over 6 months to all time. Weekly levels are drawn as a 4-week average with each week as a dot, because one or two runs a week make a single week noisy.

Updates are automatic: the server sends index.html and the service worker with no-cache, so the app checks for a new version on every start and switches to it; the hashed files in /assets/ are cached for a year since their name changes when their content does. Libraries are built into their own vendor file, which only changes when a dependency is upgraded, so an ordinary update downloads about 15 KB. Data requests go to the network first and fall back to the last answer only when offline. Logging out also deletes that offline copy.

Types run end to end: the dashboard imports the response types of packages/queries (type-only, erased at build), so a change in what the server returns fails npm run typecheck instead of breaking a screen.

Deploying to a server

deploy/ puts the app on a fresh Ubuntu 24.04 server (an OVHcloud VPS-1 here), reachable at a free https://<name>.<tailnet>.ts.net address through Tailscale Funnel. From the project folder on the laptop:

scp deploy/setup.sh ubuntu@<server IP>:
ssh -t ubuntu@<server IP> "sudo bash setup.sh"

The script is safe to run again, and every step checks what's already done. In order:

  1. SSH accepts keys only, with passwords and root login off.

  2. System updates, automatic security updates (reboot at 04:00 when one needs it), and a firewall with only SSH open.

  3. An unprivileged companion user that runs the app.

  4. A read-only GitHub deploy key, added once by hand.

  5. The code in /opt/running-companion.

  6. Node from .nvmrc, then install, typecheck, tests and build.

  7. Tailscale and Funnel: the public HTTPS address, with no web port opened on the server.

  8. The Garmin key and dashboard password, typed hidden on the server, into data/settings.json (mode 600).

  9. The first sync, then the systemd service (sandboxed: it can write only data/ and .cache/).

Updates: running-companion-update.timer runs deploy/update.sh every 5 minutes. When main has a new commit, it installs, typechecks, tests, builds and restarts, then checks /healthz. If any step fails, it goes back to the last commit that worked and skips the broken one until the next push. The server pulls, so GitHub holds no key to the server.

On the server

journalctl -u running-companion -f

the app's log, live

journalctl -u running-companion-update

what each update did

sudo systemctl restart running-companion

restart the app

sudo bash /opt/running-companion/deploy/ssh-tailscale-only.sh

optional: close SSH to the internet, allow it only through Tailscale

Weather and maps

Weather comes from Open-Meteo (free, no key): the archive for runs older than about five days, the forecast model for recent runs and the coming days. The sync adds weather to new outdoor runs after the Garmin sync, grouped by place and year so the first backfill takes about a dozen requests; if Open-Meteo is unreachable, the sync finishes without it and tries again next time. The forecast for Today is cached for an hour. What Open-Meteo receives: coordinates rounded to about 100 m and dates, nothing else.

Map tiles: OpenStreetMap's own by default (no key; their usage policy asks browsers to name the site, so tile requests send the app's address, and dark mode inverts the tiles). With a free CARTO basemaps key in .env as CARTO_API_KEY (personal use, no account, https://carto.com/basemaps/apikey/), CARTO's light and dark maps instead; CARTO has required a key since late August 2026 and shows an "API key required" watermark without one. Like any online map, the tile server sees which area you look at; the routes are drawn in your browser and never sent anywhere. Leaflet draws the map and loads only on pages that show one.

Privacy

The sync, the database and the FIT files stay on this machine. When you ask Claude about your data, the tool results it reads (for example a run's laps or a week of sleep) are sent to Anthropic as part of that conversation, like anything else you type. Only what a question needs is sent, never the whole database.

Security notes

  • .env, data/ and .cache/ are gitignored. Keep it that way.

  • The token cache stores a SHA-256 hash of the key, never the key itself.

  • Revoke the key in Garmin Connect at any time to cut off access.

  • Garmin's terms: the key is personal and must not be shared.

License

Copyright (C) 2026 Dimitris Tsiakas.

Running Companion is free software: you can redistribute it and modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3 or (at your option) any later version. See LICENSE. In short: use it, change it, run it for yourself; if you run a modified version as a service for others, publish your changes under the same license.

Disclaimers

  • Not affiliated with, endorsed by or connected to Garmin. Garmin, Garmin Connect and Forerunner are trademarks of Garmin Ltd. or its subsidiaries. The app uses Garmin's Personal API, which is for your own data: run your own copy with your own key, don't use it to serve other people's data.

  • Not medical advice. The app explains numbers from a consumer watch with rules of thumb and statistics, and says so where it does; it can't diagnose anything. See a doctor about health concerns.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    B
    maintenance
    Connects Claude Desktop to your Garmin Connect running data stored locally in SQLite, enabling querying, syncing, and AI analysis of fitness activities.
    1
    -
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables reading and analyzing Garmin Connect data—activities, health metrics, FIT files, and challenges—through natural language, for AI assistants like Claude and ChatGPT.
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Provides Claude Code and Claude Desktop with safe, read-only access to your local Garmin training data, readiness decisions, run details, and trends so you can get coaching advice in natural language.
    15
    7
    MIT