# garmin-grafana-mcp-server
An optional, self-hosted [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that exposes your Garmin training data to AI assistants. It is designed as a companion add-on to the [arpanghosh8453/garmin-grafana](https://github.com/arpanghosh8453/garmin-grafana) project and reads directly from the InfluxDB instance that garmin-grafana already manages.
**This server is a pure data access layer.** It returns raw numbers from InfluxDB. All interpretation, analysis, and planning is delegated to the AI model consuming the tools — this server makes no assumptions about your fitness goals.
---
## Table of Contents
- [How it fits into the ecosystem](#how-it-fits-into-the-ecosystem)
- [🪄 See it in action: The AI Coach](#-see-it-in-action-the-ai-coach)
- [Prerequisites](#prerequisites)
- [Quick Start (Docker Compose)](#quick-start-docker-compose)
- [Deployment (Docker — recommended)](#deployment-docker--recommended)
- [Local Development](#local-development)
- [Connecting to an MCP Client](#connecting-to-an-mcp-client)
- [MCP Tools](#mcp-tools)
- [Example prompts](#example-prompts)
- [Configuration reference](#configuration-reference)
- [Project structure](#project-structure)
- [Schema compatibility](#schema-compatibility)
- [Troubleshooting](#troubleshooting)
- [License](#license)
---
## How it fits into the ecosystem
```
Garmin Device
│
▼
garmin-grafana ← you already have this running
├─ Garmin API sync
├─ InfluxDB (stores your data)
└─ Grafana (dashboards)
garmin-grafana-mcp-server ← this repo adds this
└─ Reads from the same InfluxDB
└─ Exposes MCP tools to AI clients (Claude, Copilot, etc.)
```
---
## 🪄 See it in action: The AI Coach
Because this MCP server provides structured, context-aware data to LLMs like Claude, it acts as a world-class personal coach.
Here is an actual, unedited response from Claude (via Copilot) summarizing a ride by cross-referencing **six different MCP tools** simultaneously (Activity Details, Training Status, Sleep/Recovery, Training Zones, and Stress):
> **Prompt:** *"I just came back from my long Z2 ride. Please assess it in detail against our training plan!"*
View Claude's Analysis (Click to expand)
### 1. SCORECARD vs PLAN
| Metric | Target | Actual | Grade |
|---|---|---|---|
| Duration | 120–150 min | **137.5 min** | ✅ Perfect |
| Time in Z1+Z2 | — | **99.4%** (130.1 min) | ✅ Excellent |
| Max HR | ≤130 (ceiling) | **135 bpm** | ⚠️ Brief breach |
| HR drift | <5% | **-4.5%** | ✅ Excellent |
| Avg cadence | 85–95 rpm | **74 rpm** | ❌ Low |
### 2. LAP-BY-LAP ANALYSIS
- **Laps 3–6 were textbook Z2**: HR locked at 121–122 bpm. This is exactly the metabolic zone we wanted.
- **No cardiac drift**: HR drift = -4.5%. This means your aerobic system handled the load comfortably.
- **Cadence averaged 74 rpm** — below target (85–95). Next time, focus on spinning a lighter gear to reduce muscular fatigue.
### 3. TRAINING STATUS & LOAD
- Status remains **Productive** (code 7).
- ACWR 1.4 is on the higher end of the sweet spot. Be cautious not to spike it further; the next session should be easy.
- Aerobic TE 2.4 with zero anaerobic — exactly the prescribed stimulus.
### 4. POLARIZATION SHIFT — THE BIG WIN
| Metric | Before today | After today |
|---|---|---|
| Low intensity (Z1+Z2) | 49.4% | **56.7%** |
**This single ride shifted your 30-day polarization by +7.3% toward low intensity.** Two more clean Z2 rides like this and you'll cross the 65% threshold.
### 5. RECOMMENDATIONS
**Next session:**
- If HRV/recovery is good: another 90–120 min strict Z2. Same HR targets.
- **Cadence drill**: consciously target 85+ rpm in the main block. Use a lighter gear and focus on pedal circles.
---
## Prerequisites
| Requirement | Notes |
|---|---|
| [garmin-grafana](https://github.com/arpanghosh8453/garmin-grafana) | Must already be running and populated |
| Docker & Docker Compose | For the recommended deployment path |
| Python 3.11+ | For local development only |
---
## Quick Start (Docker Compose)
If you just want to run the pre-built image alongside your existing garmin-grafana stack, create a `docker-compose.yml` with the snippet below. No need to clone this repository.
```yaml
services:
garmin-grafana-mcp-server:
image: ghcr.io/ghighi3f/garmin-grafana-mcp-server:latest
container_name: garmin-grafana-mcp-server
restart: unless-stopped
ports:
- "8765:8765"
environment:
# ── Required ─────────────────────────────────────────────
# Connection to the InfluxDB instance managed by garmin-grafana.
INFLUXDB_HOST: influxdb # container name on the shared Docker network
INFLUXDB_PORT: 8086
INFLUXDB_DATABASE: GarminStats # database name from your garmin-grafana .env
INFLUXDB_USERNAME: admin # InfluxDB v1 credentials
INFLUXDB_PASSWORD: adminpassword # ← change to your actual password
# ── InfluxDB v2 only (uncomment if you run v2) ──────────
# INFLUXDB_VERSION: 2
# INFLUXDB_TOKEN: "your-influxdb-token"
# INFLUXDB_ORG: "your-org"
# ── Optional: override schema names ─────────────────────
# Only needed if your garmin-grafana uses non-default measurement
# or field names. The defaults match the standard garmin-grafana schema.
#
# Measurement names:
# MEASUREMENT_ACTIVITIES: ActivitySummary
# MEASUREMENT_DAILY_STATS: DailyStats
# MEASUREMENT_SLEEP_SUMMARY: SleepSummary
# MEASUREMENT_ACTIVITY_SESSION: ActivitySession
# MEASUREMENT_ACTIVITY_LAP: ActivityLap
# MEASUREMENT_RESTING_HR: DailyStats
# MEASUREMENT_HRV: HRV_Intraday
# MEASUREMENT_VO2_MAX: VO2_Max
# MEASUREMENT_RACE_PREDICTIONS: RacePredictions
# MEASUREMENT_BODY_COMPOSITION: BodyComposition
#
# Field names:
# FIELD_RESTING_HR: restingHeartRate
# FIELD_HRV: hrvValue
# FIELD_VO2_MAX_RUNNING: VO2_max_value
# FIELD_VO2_MAX_CYCLING: VO2_max_value_cycling
# FIELD_RACE_5K: time5K
# FIELD_RACE_10K: time10K
# FIELD_RACE_HALF: timeHalfMarathon
# FIELD_RACE_MARATHON: timeMarathon
# FIELD_WEIGHT: weight
# FIELD_HR_ZONE_1: hrTimeInZone_1
# FIELD_HR_ZONE_2: hrTimeInZone_2
# FIELD_HR_ZONE_3: hrTimeInZone_3
# FIELD_HR_ZONE_4: hrTimeInZone_4
# FIELD_HR_ZONE_5: hrTimeInZone_5
#
# Training Status & Readiness (garmin-grafana v0.4.0+):
# MEASUREMENT_TRAINING_STATUS: TrainingStatus
# MEASUREMENT_TRAINING_READINESS: TrainingReadiness
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8765/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
default:
external: true
name: garmin-grafana_default # ← must match your garmin-grafana network
```
Then start it:
```bash
docker compose up -d
curl http://localhost:8765/health
```
> **Tip:** The only variables most users need to change are `INFLUXDB_PASSWORD` and possibly `INFLUXDB_DATABASE`. All schema variables have sensible defaults that match the standard garmin-grafana InfluxDB schema out of the box.
---
## Deployment (Docker — recommended)
This is the recommended way to run the server alongside your existing garmin-grafana stack.
### 1. Clone this repository
```bash
git clone https://github.com/ghighi3f/garmin-grafana-mcp-server.git
cd garmin-grafana-mcp-server
```
### 2. Configure environment variables
```bash
cp .env.example .env
```
Edit `.env` to match your garmin-grafana setup. The most important values:
| Variable | Default | Description |
|---|---|---|
| `INFLUXDB_HOST` | `influxdb` | Container name of the InfluxDB service inside the garmin-grafana network. Override in `.env` only if your service is named differently. |
| `INFLUXDB_PORT` | `8086` | InfluxDB port (usually unchanged) |
| `INFLUXDB_DATABASE` | `GarminStats` | Database name set in your garmin-grafana config |
| `INFLUXDB_USERNAME` | `admin` | InfluxDB username from your garmin-grafana `.env` |
| `INFLUXDB_PASSWORD` | *(see .env.example)* | InfluxDB password from your garmin-grafana `.env` |
| `INFLUXDB_VERSION` | `1` | `1` for InfluxDB v1; `2` for InfluxDB v2 |
| `MCP_PORT` | `8765` | Port the MCP server listens on |
| `QUERY_TIMEZONE` | `UTC` | IANA timezone — must match `USER_TIMEZONE` in garmin-grafana (see [Timezone](#timezone)) |
> **Note:** `INFLUXDB_HOST` is overridden to `influxdb` directly in `docker-compose.yml` so the container resolves the InfluxDB service by its Docker network name. You do not need to set it in `.env` for the Docker deployment.
### 3. Identify your garmin-grafana Docker network
The `docker-compose.yml` in this repo connects to the same Docker network that garmin-grafana creates. By default that network is named `garmin-grafana_default`.
Verify the network name:
```bash
docker network ls | grep garmin
```
If the name differs from `garmin-grafana_default`, edit the `networks` section at the bottom of `docker-compose.yml`:
```yaml
networks:
default:
external: true
name: your-actual-network-name # ← change this
```
### 4. Start the server
```bash
docker compose up -d
```
Verify it is running:
```bash
curl http://localhost:8765/health
```
Expected response:
```json
{
"influxdb": "connected",
"last_activity_timestamp": "2026-03-17T07:30:00+00:00",
"measurements_found": ["ActivitySummary", "DailyStats", "SleepSummary", "..."],
"mcp_endpoint": "http://localhost:8765/mcp",
"sse_endpoint": "http://localhost:8765/sse"
}
```
---
## Local Development
If you want to run the MCP server directly on your host machine (outside Docker) during development, the server process needs to reach InfluxDB on port `8086`. Since InfluxDB is inside a Docker network, you must expose its port to the host.
**Step 1: Expose InfluxDB port in your garmin-grafana `docker-compose.yml`**
Open the garmin-grafana `docker-compose.yml` and add a `ports` mapping to the `influxdb` service:
```yaml
# In your garmin-grafana docker-compose.yml:
services:
influxdb:
image: influxdb:1.8
ports:
- "8086:8086" # ← add this line
# ... rest of your config
```
Then restart the garmin-grafana stack:
```bash
docker compose down && docker compose up -d
```
**Step 2: Set up the Python environment**
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
For InfluxDB v2, also install:
```bash
pip install influxdb-client
```
**Step 3: Configure for local access**
```bash
cp .env.example .env
```
Edit `.env` and set:
```env
INFLUXDB_HOST=localhost # reach InfluxDB via the exposed host port
INFLUXDB_PORT=8086
```
**Step 4: Run the server**
```bash
python server.py
```
You should see:
```
============================================================
Garmin MCP Server
============================================================
InfluxDB : localhost:8086/GarminStats
Measurements: ['ActivitySummary', 'DailyStats', ...]
Transports : HTTP + SSE (always active)
/mcp (Streamable HTTP) : http://localhost:8765/mcp
/sse (SSE, deprecated) : http://localhost:8765/sse
/health : http://localhost:8765/health
============================================================
```
---
## Connecting to an MCP Client
All HTTP transports are always active simultaneously — no `MCP_TRANSPORT` configuration needed for HTTP deployments. Point your client at the right URL and go.
---
### Perplexity Mac / Legacy SSE clients
> **Note:** SSE transport is deprecated in the MCP specification. Kept for backward compatibility — prefer Streamable HTTP for new integrations.
```json
{
"mcpServers": {
"garmin": {
"type": "sse",
"url": "http://:8765/sse"
}
}
}
```
---
### ChatGPT / VS Code / Modern clients (Streamable HTTP)
```json
{
"mcpServers": {
"garmin": {
"type": "http",
"url": "http://:8765/mcp"
}
}
}
```
Replace `` with the IP or hostname of the machine running the server (e.g. `192.168.1.100`, `pi5.local`, or `localhost`).
---
### Claude Desktop / Cursor / Windsurf / Claude Code (Local stdio)
**With Docker** (server reads InfluxDB via the shared garmin-grafana network):
```json
{
"mcpServers": {
"garmin": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"--network", "garmin-grafana_default",
"--env-file", "/path/to/.env",
"ghcr.io/ghighi3f/garmin-grafana-mcp-server:latest",
"python", "server.py"
],
"env": { "MCP_TRANSPORT": "stdio" }
}
}
}
```
**Without Docker** (local Python, `INFLUXDB_HOST=localhost` in `.env`):
```json
{
"mcpServers": {
"garmin": {
"command": "python",
"args": ["/path/to/garmin-grafana-mcp-server/server.py"],
"env": {
"MCP_TRANSPORT": "stdio",
"INFLUXDB_HOST": "localhost",
"INFLUXDB_PORT": "8086",
"INFLUXDB_DATABASE": "GarminStats",
"INFLUXDB_USERNAME": "admin",
"INFLUXDB_PASSWORD": "your-password"
}
}
}
}
```
Or from the command line:
```bash
MCP_TRANSPORT=stdio python server.py
```
> **Tip:** The startup banner is written to stderr in stdio mode so it never interferes with the MCP protocol on stdout.
---
### No configuration needed
All HTTP transports (SSE + Streamable HTTP) are always active on the same port. `MCP_TRANSPORT` only needs to be set for stdio (subprocess) clients.
---
## MCP Tools
### `get_last_activity`
Returns the single most recent Garmin activity with all available fields. No input parameters.
### `get_recent_activities`
Returns activities from the last N days with a summary.
| Parameter | Type | Default | Range / Values |
|---|---|---|---|
| `days` | int | `7` | 1–90 |
| `sport_type` | str | `"all"` | Any Garmin sport type (e.g. `"running"`, `"cycling"`, `"hiking"`, `"trail_running"`, `"strength_training"`). Supports partial matching — `"cycling"` also matches `"indoor_cycling"`. `"all"` = no filter. |
| `limit` | int | `20` | 1–100 |
### `get_weekly_load_summary`
Groups activities into ISO calendar weeks with resting HR and HRV.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `weeks` | int | `4` | 1–16 |
### `get_daily_recovery`
Merges SleepSummary + DailyStats per date for a holistic recovery view.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `days` | int | `7` | 1–14 |
### `get_activity_details`
Detailed breakdown of a single activity: HR zones, training effect, per-lap splits, and analysis hints (fastest/slowest lap, HR drift). For activities with a power meter, also returns `max_power`, `avg_power_pedaling_only`, and `standing_duration_seconds` per lap.
| Parameter | Type | Description |
|---|---|---|
| `activity_id` | str | Activity ID from `get_recent_activities` |
### `get_fitness_trend`
Long-term fitness trajectory: VO2max, race predictions, weight, resting HR — sampled weekly.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `weeks` | int | `12` | 4–52 |
### `get_training_zones`
HR zone distribution and polarization analysis (low/moderate/high intensity breakdown).
| Parameter | Type | Default | Range / Values |
|---|---|---|---|
| `days` | int | `30` | 7–180 |
| `sport_type` | str | `"all"` | Any Garmin sport type (e.g. `"running"`, `"cycling"`, `"swimming"`, `"hiking"`). Supports partial matching. `"all"` = no filter. |
### `explore_schema`
Discover available InfluxDB measurements, fields, and tags at runtime. Useful for verifying field names before querying or debugging measurement mismatches.
| Parameter | Type | Optional | Description |
|---|---|---|---|
| `measurement_name` | str | Yes | Inspect a specific measurement (e.g. `"ActivitySummary"`). Omit to list all measurements. |
**Returns (no measurement specified):**
```json
{"measurements": ["ActivitySummary", "DailyStats", "SleepSummary", "..."]}
```
**Returns (measurement specified):**
```json
{
"measurement": "ActivitySummary",
"fields": [
{"field": "distance", "type": "float"},
{"field": "elapsedDuration", "type": "integer"},
"..."
],
"tags": ["userId", "deviceId", "..."]
}
```
### `get_stress_body_battery`
Daily stress breakdown and body battery trend over 7–30 days. Surfaces systemic fatigue patterns that `get_daily_recovery` buries inside a per-day blob.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `days` | int | `7` | 7–30 |
**Returns:**
- Per-day: stress minutes (high/medium/low/rest) + body battery (at wake, high, low, drained, charged)
- Summary: period averages + trend direction (`"improving"` / `"declining"` / `"stable"` for body battery; `"improving"` / `"worsening"` / `"stable"` for stress)
### `get_personal_records`
All-time personal records grouped by sport type. For each record, returns the value, unit, and the **activity_id, date, and activity_name** of the record-setting activity.
| Parameter | Type | Default | Range / Values |
|---|---|---|---|
| `sport_type` | str | `"all"` | Any Garmin sport type (e.g. `"running"`, `"cycling"`). Supports partial matching. `"all"` returns records for every sport. |
**Records tracked per sport:**
| Record | Unit | Notes |
|---|---|---|
| `longest_distance` | km | |
| `longest_duration` | minutes | Uses moving time (excludes pauses) |
| `top_speed` | km/h | |
| `fastest_avg_pace` | min/km | Pace sports only (running, swimming, walking, hiking) |
| `fastest_avg_speed` | km/h | Speed sports only (cycling, rowing, etc.) |
| `highest_max_hr` | bpm | |
| `highest_avg_hr` | bpm | |
| `most_calories` | kcal | |
| `highest_avg_power` | watts | Duration-weighted average from lap data |
| `highest_max_power` | watts | Peak instantaneous watt from ActivityGPS (server-side MAX aggregate) |
---
### `get_training_status`
Fetches the latest Training Status and Training Readiness entries from InfluxDB. No input parameters required.
**Returns:**
- **`training_status`** — most recent entry from the `TrainingStatus` measurement:
| Field | Type | Description |
|---|---|---|
| `status_code` | int | Garmin training status enum value |
| `status_label` | str | Raw FIT SDK phrase code (e.g. `"PRODUCTIVE_6"`, `"MAINTAINING_1"`) |
| `garmin_coaching_advice` | str\|null | Human-readable coaching text decoded from the FIT SDK `training_status_feedback_phrase` enum (e.g. `"Primarily aerobic training"`) |
| `acute_load` | int | 7-day acute training load |
| `chronic_load` | int | 28-day chronic training load (CTL) |
| `load_balance_ratio` | float | Acute / chronic workload ratio (ACWR) |
| `acwr_percent` | int | ACWR expressed as a percentage |
| `fitness_trend` | int | Garmin fitness trend indicator |
| `max_chronic_load` | float | Upper bound of the optimal chronic load range |
| `min_chronic_load` | float | Lower bound of the optimal chronic load range |
| `timestamp` | str | ISO timestamp of the record |
- **`training_readiness`** — most recent entry from the `TrainingReadiness` measurement (requires garmin-grafana v0.4.0+ and a compatible device). Returns `null` with a `training_readiness_note` if the measurement is not present.
| Field | Type | Description |
|---|---|---|
| `score` | int | Readiness score 0–100 |
| `description` | str | Readiness label (e.g. `"Good"`, `"Fair"`, `"Poor"`) |
| `sleep_score` | int | Sleep component score |
| `hrv_ratio` | float | HRV component ratio |
| `recovery_time_h` | int | Recommended recovery time in hours |
| `stress_history` | float | Stress history component |
| `activity_history` | float | Activity history component |
> **Graceful degradation:** if either measurement is missing (e.g. `TrainingReadiness` is only available with certain garmin-grafana versions and Garmin devices), the corresponding field is `null` and a `data_note` or `training_readiness_note` key explains why. The server never crashes.
### `get_sleep_physiology`
Returns nightly autonomic physiology from SleepIntraday epoch data, merged with enriched SleepSummary.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `days` | int | `7` | 1–14 |
**Returns per night:**
- `intraday` — HR, HRV, respiration, SpO2 (min/max/mean), stress (mean), body battery (first/last/min/max), restlessness (mean), epoch count
- `summary` — sleep score, stage durations, avg overnight HRV, respiration range, SpO2 range
**Summary:** avg minimum HR, avg mean HRV, avg mean respiration, avg min SpO2, avg body battery charged. Trends: `min_hr_trend`, `hrv_trend`, `respiration_trend`.
### `get_activity_load_history`
Returns per-activity training load and training effect scores — shows which sessions drive acute load.
| Parameter | Type | Default | Range / Values |
|---|---|---|---|
| `days` | int | `14` | 1–90 |
| `sport_type` | str | `"all"` | Any Garmin sport type or `"all"` |
| `limit` | int | `30` | 1–100 |
**Returns per activity:** activity ID, sport, distance, duration, `training_load` (Garmin EPOC), `aerobic_training_effect` (0–5), `anaerobic_training_effect` (0–5), avg/max HR.
**Summary:** `total_load`, `avg_load_per_session`, `load_by_sport`, `highest_load_activity`, `avg_aerobic_te`, `avg_anaerobic_te`.
### `get_daily_energy_balance`
Returns daily time-use breakdown, caloric data, movement, and stress attribution — reveals what happens between workouts and sleep.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `days` | int | `7` | 1–14 |
**Returns per day:**
- `time_use` — sedentary, active, highly active, sleeping (hours)
- `energy` — BMR kcal, active kcal
- `movement` — steps, distance, floors ascended/descended + meters
- `recovery_context` — body battery during sleep, body battery at wake, resting HR
- `stress_attribution` — activity stress min/pct, total stress, uncategorized stress
**Summary:** period averages + `sedentary_trend`, `bb_during_sleep_trend`.
### `get_fitness_age`
Returns weekly-sampled fitness age trajectory — a single metric for long-term base-building progress.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `weeks` | int | `12` | 4–52 |
**Returns per week:** `fitness_age`, `chronological_age`, `achievable_fitness_age`, `fitness_age_gap` (fitness – chronological, negative = younger), `improvement_potential` (fitness – achievable).
**Trends:** `fitness_age_change`, `fitness_age_gap_change`, `improvement_potential_change` (delta oldest → newest).
---
### `get_peak_power`
Returns rolling-window peak power efforts for a single activity, computed from per-second `ActivityGPS.Power` data. Requires a power meter.
| Parameter | Type | Description |
|---|---|---|
| `activity_id` | str | Activity ID from `get_recent_activities` |
**Returns:**
| Field | Description |
|---|---|
| `peak_powers` | Best average watts over 1s / 5s / 10s / 30s / 60s / 5min / 20min windows |
| `avg_power_total` | Mean watts including coasting (0 W) — matches Garmin display |
| `avg_power_pedaling_only` | Mean watts when Power > 0 (excludes coasting) |
| `coasting_pct` | Percentage of samples at 0 W |
| `total_work_kj` | Total mechanical work in kilojoules (from `Accumulated_Power`) |
| `data_points` | Number of 1-second GPS samples |
> The 20-minute peak is the standard input for FTP estimation (multiply by 0.95).
### `get_power_zones`
Returns time-in-zone distribution using the Coggan 7-zone model, computed from per-second `ActivityGPS.Power` data. Coasting (0 W) is excluded from zone percentages and reported separately.
| Parameter | Type | Default | Description |
|---|---|---|---|
| `activity_id` | str | — | Activity ID |
| `ftp` | float | `211.0` | Functional Threshold Power in watts |
**Zone boundaries:**
| Zone | Label | Range |
|---|---|---|
| Zone 1 | Active Recovery | < 55% FTP |
| Zone 2 | Endurance | 55–75% FTP |
| Zone 3 | Tempo | 75–90% FTP |
| Zone 4 | Threshold | 90–105% FTP |
| Zone 5 | VO2 Max | 105–120% FTP |
| Zone 6 | Anaerobic | 120–150% FTP |
| Zone 7 | Neuromuscular | > 150% FTP |
**Returns per zone:** `minutes`, `pct_of_power_time`, `min_watts`, `max_watts`. Also returns `coasting_minutes`, `total_power_minutes`, and `ftp_used`.
### `get_power_history`
Returns per-session power summary for activities in the last N days that have power data. Uses bulk queries only — no per-activity round-trips.
| Parameter | Type | Default | Range / Values |
|---|---|---|---|
| `days` | int | `30` | 1–90 |
| `sport_type` | str | `"all"` | Any Garmin sport type or `"all"` |
**Returns per activity:** activity ID, timestamp, sport, distance, duration, `avg_power`, `total_work_kj`, training load, aerobic TE, anaerobic TE.
**Summary:** `avg_power_trend` ("improving"/"declining"/"stable"), `best_avg_power_session`, `total_work_kj`, `period_avg_power`.
### `get_cycling_dynamics`
Returns advanced pedaling efficiency metrics for a single activity. Requires the [garmin-grafana CyclingDynamics patch](https://github.com/arpanghosh8453/garmin-grafana) and a compatible pedal-based power meter (Garmin Rally, Vector, or similar). Returns a graceful `data_note` when data is unavailable — safe to call for any activity.
| Parameter | Type | Description |
|---|---|---|
| `activity_id` | str | Activity ID from `get_recent_activities` |
**Returns:**
- `power` — `normalized_power` (W), `training_stress_score`, `intensity_factor`, `left_right_balance` (`{left_pct, right_pct}`)
- `left_pedal` / `right_pedal` — `torque_effectiveness` (%), `pedal_smoothness` (%), `platform_center_offset_mm`, `power_phase {start_deg, end_deg}`, `power_phase_peak {start_deg, end_deg}`
- `data_note` — present instead of the above when no CyclingDynamics data is found
### `get_lactate_threshold_trend`
Returns weekly-sampled lactate threshold heart rate (LTHR) and its trend. LTHR is the highest average HR you can sustain for ~60 minutes and is the key anchor for defining training zones (Z2 ceiling, Z4 floor). A rising LTHR indicates cardiovascular adaptation.
| Parameter | Type | Default | Range |
|---|---|---|---|
| `weeks` | int | `12` | 4–52 |
**Returns:**
- `current` — most recent LTHR reading: `hr_threshold_running` (bpm) and `week_label`
- `weeks` — list (newest first) of `{ week_label, hr_threshold_running }` entries
- `trend` — `"improving"` | `"declining"` | `"stable"`
- `change_bpm` — change over the period (positive = LTHR risen = improving)
- `data_note` — present instead of the above when no `LactateThreshold` data is found (e.g. older firmware)
> **Graceful degradation:** if the `LactateThreshold` measurement is absent from your InfluxDB (requires Garmin device firmware that estimates LTHR automatically), the tool returns a `data_note` explaining why rather than an error.
---
## Example prompts
```
"What was my last ride? How did my HR compare to the previous 3?"
"Break down everything I did in the last 2 weeks by sport."
"Given my last 4 weeks of training, suggest a 7-day plan —
my goal is a sprint triathlon in 6 weeks, I can train Mon/Wed/Thu/Sat/Sun."
"Was my training load balanced across swim/bike/run last month?"
"How has my VO2max trended over the past 3 months?"
"Compare my sleep and recovery over the last 7 days."
"What's my HR zone distribution for the past month?
Am I spending too much time in zone 3?"
"Show my stress and body battery trend for the last 2 weeks.
Am I recovering well between hard sessions?"
"What are my all-time personal records for cycling and running?"
"What is my current training status? Am I overloading or undertraining?"
"What were my peak 5-minute and 20-minute power numbers on today's ride?"
"Show me my Coggan power zone distribution for the last ride. How much time in Z2 vs Z4+?"
"Has my average power been improving or declining over the last month of rides?"
"What is my lactate threshold HR and has it improved over the last 3 months?"
```
---
## Configuration reference
All settings are in `.env`. Copy `.env.example` to get started.
### Measurement names
Override these if your garmin-grafana schema uses different measurement names:
| Variable | Default |
|---|---|
| `MEASUREMENT_ACTIVITIES` | `ActivitySummary` |
| `MEASUREMENT_DAILY_STATS` | `DailyStats` |
| `MEASUREMENT_SLEEP_SUMMARY` | `SleepSummary` |
| `MEASUREMENT_ACTIVITY_SESSION` | `ActivitySession` |
| `MEASUREMENT_ACTIVITY_LAP` | `ActivityLap` |
| `MEASUREMENT_VO2_MAX` | `VO2_Max` |
| `MEASUREMENT_RACE_PREDICTIONS` | `RacePredictions` |
| `MEASUREMENT_BODY_COMPOSITION` | `BodyComposition` |
| `MEASUREMENT_RESTING_HR` | `DailyStats` |
| `MEASUREMENT_HRV` | `HRV_Intraday` |
| `MEASUREMENT_TRAINING_STATUS` | `TrainingStatus` |
| `MEASUREMENT_TRAINING_READINESS` | `TrainingReadiness` |
| `MEASUREMENT_SLEEP_INTRADAY` | `SleepIntraday` |
| `MEASUREMENT_FITNESS_AGE` | `FitnessAge` |
| `MEASUREMENT_CYCLING_DYNAMICS` | `CyclingDynamics` |
| `MEASUREMENT_LACTATE_THRESHOLD` | `LactateThreshold` |
### Field names
| Variable | Default | Used in |
|---|---|---|
| `FIELD_RESTING_HR` | `restingHeartRate` | Weekly load |
| `FIELD_HRV` | `hrvValue` | Weekly load |
| `FIELD_VO2_MAX_RUNNING` | `VO2_max_value` | Fitness trend |
| `FIELD_VO2_MAX_CYCLING` | `VO2_max_value_cycling` | Fitness trend |
| `FIELD_RACE_5K` | `time5K` | Fitness trend |
| `FIELD_RACE_10K` | `time10K` | Fitness trend |
| `FIELD_RACE_HALF` | `timeHalfMarathon` | Fitness trend |
| `FIELD_RACE_MARATHON` | `timeMarathon` | Fitness trend |
| `FIELD_WEIGHT` | `weight` | Fitness trend |
| `FIELD_HR_ZONE_1`–`5` | `hrTimeInZone_1`–`5` | Training zones, activity details |
| `FIELD_TRAINING_STATUS_CODE` | `trainingStatus` | Training status (int enum) |
| `FIELD_TRAINING_STATUS_LABEL` | `trainingStatusFeedbackPhrase` | Training status label (string) |
| `FIELD_ACUTE_LOAD` | `dailyTrainingLoadAcute` | 7-day acute training load |
| `FIELD_CHRONIC_LOAD` | `dailyTrainingLoadChronic` | 28-day chronic training load |
| `FIELD_LOAD_BALANCE_RATIO` | `dailyAcuteChronicWorkloadRatio` | Acute/chronic ratio |
| `FIELD_ACWR_PERCENT` | `acwrPercent` | ACWR as percentage |
| `FIELD_FITNESS_TREND` | `fitnessTrend` | Fitness trend indicator |
| `FIELD_READINESS_SCORE` | `trainingReadinessScore` | Readiness score 0–100 |
| `FIELD_READINESS_LABEL` | `trainingReadinessDescription` | Readiness label string |
| `FIELD_SLEEP_HR` | `heartRate` | Sleep HR epochs |
| `FIELD_SLEEP_HRV` | `hrvData` | Sleep HRV epochs |
| `FIELD_SLEEP_RESPIRATION` | `respirationValue` | Sleep respiration epochs |
| `FIELD_SLEEP_SPO2` | `spo2Reading` | Sleep SpO2 epochs |
| `FIELD_SLEEP_STRESS` | `stressValue` | Sleep stress epochs |
| `FIELD_SLEEP_BODY_BATTERY` | `bodyBattery` | Sleep body battery epochs |
| `FIELD_SLEEP_RESTLESS` | `sleepRestlessValue` | Sleep restlessness epochs |
| `FIELD_TRAINING_LOAD` | `activityTrainingLoad` | Per-activity EPOC load |
| `FIELD_AEROBIC_TE` | `aerobicTrainingEffect` | Aerobic training effect (0–5) |
| `FIELD_ANAEROBIC_TE` | `anaerobicTrainingEffect` | Anaerobic training effect (0–5) |
| `FIELD_SEDENTARY_SECONDS` | `sedentarySeconds` | Daily sedentary time |
| `FIELD_ACTIVE_SECONDS` | `activeSeconds` | Daily active time |
| `FIELD_HIGHLY_ACTIVE_SECONDS` | `highlyActiveSeconds` | Daily vigorous movement time |
| `FIELD_SLEEPING_SECONDS` | `sleepingSeconds` | Daily sleeping time |
| `FIELD_BMR_KCAL` | `bmrKilocalories` | Basal metabolic rate calories |
| `FIELD_BB_DURING_SLEEP` | `bodyBatteryDuringSleep` | Body battery charged during sleep |
| `FIELD_ACTIVITY_STRESS_DUR` | `activityStressDuration` | Activity-caused stress seconds |
| `FIELD_ACTIVITY_STRESS_PCT` | `activityStressPercentage` | Activity stress % of total |
| `FIELD_FITNESS_AGE` | `fitnessAge` | Current fitness age |
| `FIELD_CHRONOLOGICAL_AGE` | `chronologicalAge` | Actual age |
| `FIELD_ACHIEVABLE_FITNESS_AGE` | `achievableFitnessAge` | Optimal achievable fitness age |
| `FIELD_LACTATE_THRESHOLD_HR_RUNNING` | `HeartRateThreshold_RUNNING` | Lactate threshold HR (running) |
### Server
| Variable | Default | Description |
|---|---|---|
| `MCP_HOST` | `0.0.0.0` | Bind address |
| `MCP_PORT` | `8765` | Bind port |
| `MCP_TRANSPORT` | `http` | Set to `stdio` for subprocess clients (Claude Desktop, Cursor, Windsurf). Leave unset for all HTTP deployments — both SSE and Streamable HTTP are always active. |
| `ALLOWED_HOSTS` | *(empty)* | DNS-rebinding allow-list (e.g. `pi5.local:*,localhost:*`) |
### Timezone
| Variable | Default | Description |
|---|---|---|
| `QUERY_TIMEZONE` | `UTC` | IANA timezone for daily-aggregate queries. Must match `USER_TIMEZONE` in garmin-grafana. |
garmin-grafana stores daily measurements (DailyStats, SleepSummary, etc.) with timestamps anchored to **local midnight**. For a UTC+3 user, local midnight is `21:00:00Z` — without timezone correction the MCP server assigns these records to the previous UTC date, shifting every daily metric one day into the past.
Set `QUERY_TIMEZONE` to the same value as `USER_TIMEZONE` in your garmin-grafana `.env`:
```env
# garmin-grafana .env
USER_TIMEZONE=Europe/Athens
# garmin-grafana-mcp-server .env
QUERY_TIMEZONE=Europe/Athens
```
This injects InfluxQL's `tz()` clause into daily and weekly aggregate queries and converts timestamps to local dates in the normalizer layer. Activity queries (which use actual event timestamps) are not affected.
> **UTC users:** No action needed — the default `UTC` preserves existing behaviour.
---
## Project structure
```
garmin-grafana-mcp-server/
├── server.py — Starlette app + MCP tool registration + startup banner
├── influx.py — InfluxDB client, all queries, normalizer functions
├── utils.py — Shared helpers (pick, safe_float, iso_week_label, etc.)
├── tools/
│ ├── __init__.py
│ ├── activity.py — get_last_activity, get_recent_activities
│ ├── load.py — get_weekly_load_summary
│ ├── recovery.py — get_daily_recovery
│ ├── detail.py — get_activity_details
│ ├── fitness.py — get_fitness_trend, get_training_zones
│ ├── records.py — get_personal_records
│ ├── stress.py — get_stress_body_battery
│ ├── schema.py — explore_schema
│ ├── training_status.py — get_training_status
│ ├── sleep_physiology.py — get_sleep_physiology
│ ├── activity_load.py — get_activity_load_history
│ ├── energy_balance.py — get_daily_energy_balance
│ ├── fitness_age.py — get_fitness_age
│ ├── lactate.py — get_lactate_threshold_trend
│ └── cycling_dynamics.py — get_cycling_dynamics
│ └── power.py — get_peak_power, get_power_zones, get_power_history
├── Dockerfile
├── docker-compose.yml — Docker deployment (external garmin-grafana network)
├── .env.example — Configuration template
├── requirements.txt — Python dependencies
├── CHANGELOG.md
└── README.md
```
---
## Schema compatibility
This server is designed to be resilient to garmin-grafana schema variations across versions. All measurement names and the key field names used in queries are configurable via environment variables (see the configuration tables above). If a field is missing from a given activity record, it is silently set to `null` — no errors are raised.
If you see empty results after setup, verify your measurement names against InfluxDB:
```bash
# InfluxDB v1 — run from inside the influxdb container or via an exposed port
influx -database GarminStats -execute 'SHOW MEASUREMENTS'
```
Then update the corresponding `MEASUREMENT_*` variables in your `.env`.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `"influxdb": "unreachable"` in health check | Network misconfiguration | Verify `docker network ls` and that the `name:` in `docker-compose.yml` matches |
| `"InfluxDB connection failed"` from a tool | garmin-grafana not running | `docker ps`, restart containers |
| Empty `activities` list | Measurement name mismatch | Run `SHOW MEASUREMENTS` and update `MEASUREMENT_ACTIVITIES` in `.env` |
| `AttributeError: streamable_http_app` | `mcp` < 1.6 | `pip install --upgrade mcp` |
| stdio mode not working | Missing `MCP_TRANSPORT=stdio` | Run `MCP_TRANSPORT=stdio python server.py` — uvicorn is not used in stdio mode |
| `/sse` returns 404 | Old deployment cached in container | Rebuild and redeploy: `docker compose up -d --build` |
| Tools not appearing in client | Client connected before server started | Restart the MCP client |
---
## License
MIT