# Caddie **The AI caddie that has actually seen every shot you've hit.** Caddie is an MCP server that connects Claude (or any MCP-capable LLM) to your Garmin golf data — every round, every GPS shot, strokes gained, real club distances, plays-like yardages, and the sleep/recovery context behind each score. Ask anything: *"why do I blow up on par 5s?"*, *"map my drives at my home course"*, *"does bad sleep actually cost me strokes?"* - **Local-first and private**: your data syncs once into a DuckDB file on your machine; credentials and health data never leave it. - **Unofficial**: Caddie uses the same private API as the Garmin Connect app (via [python-garminconnect](https://github.com/cyberjunky/python-garminconnect)). It reads your own account only — and it can break whenever Garmin changes things, until the ecosystem catches up. Know that going in. - **Support**: best-effort, issues and PRs welcome. By the maker of OneClub. ## How it works ``` Garmin Connect ──sync──► local DuckDB cache ──MCP tools──► your LLM ``` Your data is synced once into a local database (`~/.caddiemcp/golf.duckdb`) and every question is answered from the cache — fast, private, and kind to Garmin's servers. Credentials never leave your machine. ## Setup **From source** (this directory): ```bash python3 -m venv .venv && .venv/bin/pip install -e . .venv/bin/caddie-mcp login # one-time; MFA supported .venv/bin/caddie-mcp sync --max-rounds 50 ``` Prefer a browser to a terminal? `caddie-mcp login --web` opens a local page (127.0.0.1 only) with proper email/password/MFA fields — same token store, friendlier surface. **From the built wheel** (no checkout needed — `dist/` ships both): ```bash pipx install ./dist/caddie_mcp-1.0.0-py3-none-any.whl # or pip install caddie-mcp login && caddie-mcp sync ``` Once published to PyPI, this becomes `uvx caddie-mcp` / `pipx install caddie-mcp`. ### Claude Desktop / Claude Code Add to `claude_desktop_config.json` (or `.mcp.json` for Claude Code): ```json { "mcpServers": { "caddie-mcp": { "command": "/ABSOLUTE/PATH/TO/caddie-mcp", "args": ["serve"] } } } ``` Use `which caddie-mcp` (pipx) or `/path/to/caddie-mcp/.venv/bin/caddie-mcp` (source install) for the command path. This repo's `.mcp.json` already wires it up for Claude Code sessions started in this folder. ### Remote server (multi-user, HTTP) The same server runs over streamable-HTTP with bearer-token auth and full per-user isolation — each user gets their own database and Garmin token store under `~/.caddiemcp/users//` (or `$CADDIEMCP_HOME`). ```bash caddie-mcp user add marcos # prints the access token (shown once) caddie-mcp serve-http --host 0.0.0.0 --port 8724 ``` Users link their own Garmin account in a browser — no admin terminal needed: send them `https://your-host/garmin/login`, where they enter their access token plus Garmin credentials (two-step MFA supported); tokens land in their private server directory. Admin-assisted `caddie-mcp user login ` still works as a fallback. Clients connect to `http://host:8724/mcp` with header `Authorization: Bearer ` — e.g. for Claude Code: ```bash claude mcp add --transport http golf http://host:8724/mcp \ --header "Authorization: Bearer " ``` Docker: ```bash python -m build # wheel must exist in dist/ docker build -t caddie-mcp . docker run -d -v caddie_data:/data -p 8724:8724 caddie-mcp docker exec -it caddie-mcp user add marcos docker exec -it caddie-mcp user login marcos ``` Security notes: tokens are 256-bit secrets stored only as SHA-256 hashes and compared in constant time; rotate with `caddie-mcp user rotate-token `. Run behind TLS (Caddy/nginx/Cloudflare Tunnel) — tokens travel in headers. `--no-auth` mode exists for localhost-only single-user use and refuses to bind to non-loopback addresses. `/health` is the only unauthenticated route. The `/data` volume contains Garmin credentials — treat backups accordingly. ### claude.ai custom connectors (OAuth) claude.ai connects to remote MCP servers via OAuth (dynamic client registration + PKCE), not bearer headers. Enable it with: ```bash caddie-mcp serve-http --host 0.0.0.0 --oauth \ --public-url https://golf.example.com ``` Then add `https://golf.example.com/mcp` as a custom connector in claude.ai. On first connect the user lands on a consent page and pastes their caddie-mcp access token once; after that, OAuth tokens (1 h access + rotating refresh, stored hashed) keep the connection alive, scoped to that user's data. Static bearer tokens keep working alongside OAuth. A real domain with TLS is required — claude.ai will not talk to plain HTTP. ### Background sync In server mode, keep every linked user fresh automatically: ```bash caddie-mcp serve-http --host 0.0.0.0 --sync-every 6h ``` Users are synced sequentially (incremental, with jitter and per-user error isolation) so the Garmin API never sees a thundering herd. ### Claude Desktop Extension `python scripts/build_mcpb.py` produces `dist/caddie-mcp.mcpb` — a double-click install bundle for Claude Desktop that launches the published package via `uvx`. Requires [uv](https://docs.astral.sh/uv/) on the machine and the package on PyPI; the one-time `uvx caddie-mcp login` still happens in a terminal (credentials never pass through the desktop app). ### Publishing to PyPI (maintainer) ```bash .venv/bin/python -m build # sdist + wheel into dist/ pipx run twine upload dist/* # needs a PyPI API token ``` ### Data quality & repair `caddie-mcp repair` re-derives every computed field across the cache with no Garmin calls (club names, lie/shot-type normalization, GPS-to-pin distances, GIR/fairway inference). It runs automatically at the end of every sync, so you only need it manually after upgrading the connector. Known Garmin quirks handled: watches report `TEEBOX`/`TEE`/`UNKNOWN` enums; putts are often a per-hole count with no green shots; the end position of green-reaching shots is snapped to the pin (so post-shot proximity on green hits is honestly NULL, and strokes-gained putting uses a flagged estimate). ## Tools | Tool | What it answers | |---|---| | `garmin_status` | Am I connected? How much is cached vs still on Garmin? | | `sync_garmin_data` | Pull rounds/shots/clubs from Garmin — chunked (25/call, reports `rounds_remaining`) so full multi-year histories load safely from chat | | `list_rounds` | "Show my recent rounds" — compact summaries | | `get_round` | One round in depth: scorecard, holes, shots | | `get_shots` | Shot-level GPS/club/lie data; `format="geojson"` for maps | | `get_club_stats` | Real measured distances per club vs Garmin's estimates | | `get_strokes_gained` | Broadie strokes gained per round: off-tee / approach / short game / putting, vs your handicap level or scratch | | `get_performance_stats` | ~40 PGA-style stats (driving, GIR by distance, scrambling, putting, scoring) with optional handicap benchmarks | | `get_handicap` | WHS-style index, counting differentials, and trend | | `get_course_intel` | Per-hole difficulty and usual tee club at one course | | `get_health_context` | Sleep, HRV, Body Battery, readiness, and stress for a round's date | | `correlate_health_performance` | "Do I score worse on bad sleep?" — paired data + honest correlation | | `query_golf_data` | Any question, as read-only SQL over the cache | Sync also fills `daily_health` (4 extra Garmin calls per round date): sleep score/duration, resting HR, HRV status, Body Battery range, training readiness, stress, steps — **plus round-window enrichment** on each round: Body Battery at tee time and drain during play, average/max heart rate and stress *during the round*, and the round's weather (temperature, wind, humidity, conditions) via its matched Garmin activity. When Garmin sends no dispersion data, shot miss offsets (dx/dy) are derived from GPS geometry: drives laterally vs the tee→pin line, approaches/chips relative to the pin — powering miss-direction and miss-tendency stats on any watch with shot GPS. Every shot also gets **plays-like distance**: terrain elevations for shot and pin positions come from the free Open-Meteo elevation API (cached forever in the local DB), and `plays_like_yds` applies the standard 1:1 uphill/downhill heuristic — so "my 150-yd shots" can be analyzed by what they actually played as. **Prompts** (packaged workflows): `post_round_report`, `practice_plan`, `course_strategy`, `health_check_in`, `season_review`. Resources: `schema://data-dictionary` (columns, units, enums), `docs://metric-definitions` (how every stat is computed), and `docs://getting-started` (recommended tool flow). ## Conventions - Distances in **yards**, coordinates in **decimal degrees**, times in ISO 8601. - Lies: `tee | fairway | rough | sand | green | recovery | penalty`. - Shot types: `drive | approach | chip | putt | penalty`. - Unknown values are `NULL`, never fabricated — the last shot of a hole has no measured end-distance, and tools say so. ## Security notes - Garmin tokens live at `~/.garminconnect` (mode 0600) — treat like a password. - The SQL tool is read-only, single-statement, SELECT-only, and capped. - This is an unofficial connector for **your own** Garmin data. ## Development ```bash .venv/bin/python -m pytest tests/ -q ``` --- `mcp-name: io.github.mbrionesalvarez/caddie-mcp`