livetrack-mcp
Autonomous MCP server that polls Garmin LiveTrack, stores time-series in SQLite, and triggers Claude analysis every 10 minutes via claude-runner.
Architecture
Garmin LiveTrack URL
│
│ (poll every 60 s)
▼
livetrack-mcp (port 38100)
├── poller.py — fetch trackpoints from Garmin API
├── store.py — SQLite time-series persistence (/data/livetrack.db)
├── tracker.py — asyncio scheduling + race-end detection
└── analyzer.py — build prompt, call claude-runner
│
│ POST /run (fire-and-forget)
▼
claude-runner (port 38095)
│
│ claude -p <analysis prompt>
▼
Claude (sonnet)
├── analyze timeseries
├── curl POST /control if thresholds need adjustment
└── mcp__telegram__send_message → coaching push
Key design: livetrack-mcp is fully autonomous — no Claude session needs to stay alive during the race. Claude is called as a stateless analysis function every 10 minutes. If claude-runner is temporarily unavailable, the next analysis cycle retries automatically.
MCP Tools
| Tool | Description |
|---|---|
start_tracking(url, race_config) |
Start polling a LiveTrack share URL |
stop_tracking() |
Stop polling (also auto-stops at race end) |
get_tracking_status() |
Active state, elapsed time, stale time, poll errors |
get_timeseries(minutes=10) |
Recent data from SQLite |
update_thresholds(updates) |
Update HR/power thresholds mid-race |
trigger_analysis() |
Manual on-demand analysis, bypassing schedule |
Custom HTTP Endpoints
| Endpoint | Method | Description |
|---|---|---|
/control |
POST | Update thresholds mid-race (called by Claude via curl in Bash tool) |
/health |
GET | Health check — tracking state + store stats |
/control usage (from Claude's analysis prompt)
curl -sf -X POST http://localhost:38100/control \
-H 'Content-Type: application/json' \
-d '{"power_max": 150}'
Allowed fields: hr_max, hr_min, power_max, power_min, cadence_min, run_hr_max, run_hr_min, run_cadence_min
race_config Fields
| Field | Type | Default | Description |
|---|---|---|---|
hr_max |
int | — | Cycling HR ceiling (bpm) |
hr_min |
int | — | Cycling HR floor |
power_max |
int | — | ERG power ceiling (watts) |
power_min |
int | — | ERG power floor |
cadence_min |
int | — | Minimum cycling cadence (rpm) |
run_hr_max |
int | — | Run HR ceiling |
run_hr_min |
int | — | Run HR floor |
run_cadence_min |
int | — | Minimum run cadence (spm) |
poll_interval_secs |
int | 60 | How often to poll LiveTrack |
analyze_interval_secs |
int | 600 | How often to trigger Claude analysis |
analyze_window_min |
int | 10 | Data window passed to Claude (minutes) |
Example race_config for a full triathlon
{
"race_name": "CT2026",
"race_type": "triathlon",
"hr_max": 144,
"hr_min": 115,
"power_max": 165,
"cadence_min": 82,
"run_hr_max": 152,
"run_hr_min": 125,
"run_cadence_min": 165,
"poll_interval_secs": 60,
"analyze_interval_secs": 600,
"analyze_window_min": 10
}
Race-End Detection
The server auto-stops when:
- No new trackpoints for ≥ 15 minutes (
STALE_STOP_MIN) - AND total elapsed time ≥ 30 minutes (
MIN_ELAPSED_MIN)
This handles the Garmin 24-hour URL delay: the URL remains valid after the race, but new trackpoints stop arriving when the athlete finishes. The 30-minute minimum prevents false stops at the start when GPS data is sparse.
Configuration (Environment Variables)
| Variable | Default | Description |
|---|---|---|
PORT |
38100 |
Server port |
HOST |
0.0.0.0 |
Bind address |
MCP_PATH |
/mcp |
MCP endpoint path |
DB_PATH |
/data/livetrack.db |
SQLite database path |
CLAUDE_RUNNER_URL |
http://localhost:38095 |
claude-runner base URL |
RUNNER_WORKSPACE |
training |
Workspace for claude-runner tasks |
LOG_LEVEL |
INFO |
Logging level |
OTEL_EXPORTER_OTLP_ENDPOINT |
— | OpenTelemetry collector URL (optional) |
Deployment
cd ~/ai-platform/mcps
# Build and start
docker compose up -d --build livetrack-mcp
# Logs
docker compose logs -f livetrack-mcp
# Restart
docker compose restart livetrack-mcp
# Health check
curl http://localhost:38100/health
Typical Session (via Claude in training workspace)
# Start tracking
use_mcp_tool livetrack-mcp start_tracking \
url="https://livetrack.garmin.com/session/.../token/..." \
race_config={"hr_max": 144, "power_max": 165, "run_hr_max": 152}
# Check status
use_mcp_tool livetrack-mcp get_tracking_status
# Manual analysis trigger
use_mcp_tool livetrack-mcp trigger_analysis
# Stop (or let it auto-stop)
use_mcp_tool livetrack-mcp stop_tracking
Project Structure
livetrack_mcp/
├── Dockerfile
├── pyproject.toml
├── README.md
└── src/livetrack_mcp/
├── __init__.py
├── __main__.py
├── otel.py # OpenTelemetry setup
├── poller.py # Garmin LiveTrack URL parsing + HTTP fetch
├── store.py # SQLite time-series (sqlite3 + asyncio.to_thread)
├── tracker.py # Scheduling (asyncio.create_task) + race-end detection
├── analyzer.py # Prompt builder + claude-runner caller
└── server.py # FastMCP tools + /control + /health