Compare commits

...

16 Commits

Author SHA1 Message Date
85577c9d26 added ideas 2026-07-25 21:59:56 +02:00
487df1f9b9 fix(garmin): give startup login its own result queue, not the shared one
_startup_login's background thread and _handle_authenticate's shared the
module-level _login_result_queue with no correlation. Since _startup_login
can now time out at 10s while its thread keeps running (from the previous
fix in this wave), a slow-cold-starting subprocess's first explicit
"Connect to Garmin" call could accidentally dequeue the startup thread's
stale result instead of its own fresh one, orphaning the loser's result to
corrupt a later authenticate/complete_mfa call.

_handle_authenticate and _handle_complete_mfa still correctly share
_login_result_queue -- they're two halves of one explicit, MFA-capable
login flow. _startup_login is a background tokenstore resume with no MFA
involved, so it now uses its own private, function-local queue.Queue()
instead, making cross-contamination structurally impossible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:52:48 +02:00
e422f86925 fix: bound startup-login timeout, refresh stale SKILL.md and doc comments
_startup_login() ran garminconnect's login synchronously with no timeout
before main() ever started reading stdin, so a slow/rate-limited Garmin
login could wedge a user's whole subprocess before it became responsive.
Give it the same background-thread + bounded-10s-timeout shape
_handle_authenticate already uses, with tests for both the fast-success
and timeout paths.

Also refreshes .claude/skills/geniusrun-dev/SKILL.md (still describing
the retired mcp-garmin MCP architecture) and three stale doc comments
(garmin.AuthStatus, config.GarminTokenStoreRoot, mock package doc) left
over from the direct-wrapper migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:43:52 +02:00
136567d964 docs: remove stale cmd/mcpspike references 2026-07-25 21:31:24 +02:00
2fad83fe9a chore: drop the mcp-go dependency and remove mcpspike throwaway spike
Nothing in the backend speaks MCP anymore -- internal/garmin talks to its
embedded Python wrapper over plain JSON-lines instead. The cmd/mcpspike
directory was a temporary spike for validating the mcp-go client, which is
no longer needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:27:19 +02:00
f122f28206 docs: fix stale mcp-garmin references in CLAUDE.md's overview
Updates the Project overview section to correctly describe the direct
Python wrapper instead of the retired MCP-based approach, eliminating
the contradiction with the Garmin integration section below.
2026-07-25 21:23:24 +02:00
1b9088bd03 chore: wire up the new garmin.Config shape, refresh start.sh and CLAUDE.md
Removes the last references to garmin.Config.ServerPath and the
MCP_GARMIN_* env vars now that the wrapper script is embedded in the
binary; updates CLAUDE.md's mcp-garmin section to describe the direct
wrapper instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:18:50 +02:00
284ec3142d feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
The wrapper script is embedded in the binary now (internal/garmin), so
there's no script path left to configure. The interpreter path becomes
optional, defaulting to python3 on PATH, matching how other optional
plumbing (e.g. GENIUSRUN_OIDC_REQUIRED_ROLE) is already handled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:13:26 +02:00
8c94c94c3f feat(garmin): implement data-fetch methods via generic call dispatch
GetActivities/GetActivitySplits/GetActivityDetails/GetWorkoutByID now send
{"cmd":"call","params":{"method":...,"args":...}} instead of named MCP
tools. subprocessClient fully implements Client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:02:49 +02:00
9cbcf62aed feat(garmin): implement Authenticate/CompleteMFA on subprocessClient
Structured {status, message} responses replace the old string
pattern-matching (parseAuthResult) -- both sides of the protocol are now
owned by this repo, so there's no need to guess at phrasing anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:54:54 +02:00
df197f45fd fix(garmin): wait for stderr-copy goroutine before cmd.Wait()
ensureStarted's stderr-copy goroutine could still be mid-Read when close()
called cmd.Wait(), which os/exec's StderrPipe docs call out as incorrect
and can truncate/garble trailing stderr diagnostics or surface a spurious
"file already closed" error. close() now waits on a stderrDone channel,
closed by the copy goroutine once it hits EOF (unblocked by killing the
process), before calling Wait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:50:39 +02:00
6c161f2206 feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
follow-up commits; this is the transport + lifecycle plumbing only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:41:44 +02:00
7985000440 feat(garmin): add direct garminconnect wrapper script
Replaces mcp-garmin's server.py: a JSON-lines subprocess protocol
(authenticate/complete_mfa/call) around garminconnect directly, no MCP.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:35:13 +02:00
de48c44858 docs: add implementation plan for the direct Garmin wrapper
Task-by-task TDD plan for docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md:
Python wrapper + tests, Go transport scaffolding, auth methods, data-fetch
methods, config changes, wiring/docs, and final dependency cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:30:05 +02:00
0936d98161 docs: embed the garmin wrapper script instead of configuring its path
Since wrapper.py now lives inside this repo, its location is no longer a
deploy-time concern -- go:embed it into the binary and drop the
GARMIN_WRAPPER_SCRIPT env var entirely. Only the Python interpreter choice
remains configurable, and now optionally so (defaults to "python3").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:14:36 +02:00
961a7d8aca docs: add design spec for dropping MCP in favor of a direct Garmin wrapper
Replaces internal/garmin's MCP-based mcp-garmin integration with a custom
newline-delimited-JSON subprocess protocol around python-garminconnect
directly, folded into this repo. MCP's dynamic tool-discovery value is
unused here (fixed call sites, no LLM choosing tools), and both projects
are owned by the same person, so the extra protocol layer and two SDK
dependencies (mcp-go, mcp[cli]) were pure overhead -- especially given
plans to containerize the backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:04:18 +02:00
22 changed files with 2965 additions and 513 deletions

View File

@@ -1,13 +1,13 @@
--- ---
name: geniusrun-dev name: geniusrun-dev
description: Use when working on the geniusrun repo (backend Go services, classification rule engine, mcp-garmin integration, or frontend) to stay consistent with established conventions. description: Use when working on the geniusrun repo (backend Go services, classification rule engine, Garmin integration, or frontend) to stay consistent with established conventions.
--- ---
# geniusrun-dev # geniusrun-dev
## Project overview ## Project overview
geniusrun is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified. geniusrun is a personal web app that pulls running activities from Garmin Connect (via an embedded Python wrapper around `garminconnect`, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
**MVP scope boundary**: sync + classification + review queue + progression charts. A future training-recommendation engine (analyzing aerobic/anaerobic training-effect balance across kinds to suggest what to train next) is explicitly deferred — the schema stores the metrics it would need, but nothing consumes them yet. **MVP scope boundary**: sync + classification + review queue + progression charts. A future training-recommendation engine (analyzing aerobic/anaerobic training-effect balance across kinds to suggest what to train next) is explicitly deferred — the schema stores the metrics it would need, but nothing consumes them yet.
@@ -17,8 +17,7 @@ geniusrun is a personal web app that pulls running activities from Garmin Connec
backend/ backend/
cmd/geniusrund/ main server entrypoint cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/mcpspike/ throwaway MCP-client spike, safe to delete internal/garmin/ Garmin Connect client: spawns an embedded Python wrapper (pyscript/wrapper.py, garminconnect) over a JSON-lines subprocess protocol (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details)
internal/garmin/mock/ fake Client for tests internal/garmin/mock/ fake Client for tests
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery) internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
internal/store/ SQLite layer + embedded migrations internal/store/ SQLite layer + embedded migrations
@@ -54,16 +53,16 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance — the device already knows which laps were work vs rest when the activity was recorded as a structured workout. - **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance — the device already knows which laps were work vs rest when the activity was recorded as a structured workout.
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap (cardiac drift). Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery. - **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap (cardiac drift). Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
## mcp-garmin integration ## Garmin integration (direct wrapper, no MCP)
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered: `internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these. - **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason). - **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (default `~/.garth`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. - **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). - **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. - **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits()`** (added to mcp-garmin, wraps `garminconnect`'s existing `get_activity_splits`) is the one that returns actual lap/split summaries (`lapDTOs`). - **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
## Data model conventions ## Data model conventions
@@ -75,7 +74,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
## Dev workflow ## Dev workflow
- Backend: `cd backend && go run ./cmd/geniusrund` (needs `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` env vars for the mcp-garmin subprocess paths; see `internal/config/config.go` for all knobs). Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`. - Backend: `cd backend && go run ./cmd/geniusrund`. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed) — see `internal/config/config.go` for all knobs. Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`). - Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`).
- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB. - No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB.
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess. - `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
@@ -87,4 +86,4 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly. - `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly.
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — this is deliberate, not mocked, since the migration/SQL correctness is exactly what needs catching. - `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — this is deliberate, not mocked, since the migration/SQL correctness is exactly what needs catching.
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`. - `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`.
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints. - The Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real `geniusrund` auth endpoints.

View File

@@ -4,16 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project overview ## Project overview
geniusrun (Go module `geniusrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified. geniusrun (Go module `geniusrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via an embedded Python wrapper around `garminconnect`, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a `profile` row scoped to the logged-in user, edited from the frontend's Profile screen. Every table holding synced/tunable data (`profile`, `workout_kinds`/`workout_type_paces`, `activities` and everything under it, `sync_state`/`sync_runs`) is scoped to a `user_id`, so each OIDC account gets its own fully isolated dataset — see Authentication below. There's no admin UI or profile switcher, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, mcp-garmin subprocess paths). All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a `profile` row scoped to the logged-in user, edited from the frontend's Profile screen. Every table holding synced/tunable data (`profile`, `workout_kinds`/`workout_type_paces`, `activities` and everything under it, `sync_state`/`sync_runs`) is scoped to a `user_id`, so each OIDC account gets its own fully isolated dataset — see Authentication below. There's no admin UI or profile switcher, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, the Garmin wrapper's Python interpreter path).
The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open, and `docs/IDEAS.md` for informal, not-yet-formalized ideas for what's next — check it at the start of a session for context on where to pick up. The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open, and `docs/IDEAS.md` for informal, not-yet-formalized ideas for what's next — check it at the start of a session for context on where to pick up.
## Commands ## Commands
Backend (from `backend/`): Backend (from `backend/`):
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the local mcp-garmin subprocess paths and DB path already set) or `go run ./cmd/geniusrund` directly if you export `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` yourself. - Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed).
- Build/vet: `go build ./...` && `go vet ./...` - Build/vet: `go build ./...` && `go vet ./...`
- Format: `gofmt -l .` must report nothing before committing. - Format: `gofmt -l .` must report nothing before committing.
- All tests: `go test ./...` - All tests: `go test ./...`
@@ -45,8 +45,7 @@ backend/
cmd/geniusrund/ main server entrypoint cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql
cmd/mcpspike/ throwaway MCP-client spike, safe to delete internal/garmin/ Garmin Connect client: spawns an embedded Python wrapper (pyscript/wrapper.py, garminconnect) over a JSON-lines subprocess protocol (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details, get_workout_by_id)
internal/garmin/mock/ fake Client for tests internal/garmin/mock/ fake Client for tests
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery) internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
internal/store/ SQLite layer; schema.sql is the whole schema (no migration history), users.go owns account provisioning internal/store/ SQLite layer; schema.sql is the whole schema (no migration history), users.go owns account provisioning
@@ -86,17 +85,17 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring. - Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
- **Race** is special-cased: `is_race` is derived at sync time from Garmin's own `eventType.typeKey == "race"` (see `internal/sync/mapping.go`), not a rule the user tunes. Once an activity is assigned Race (or manually overridden to any kind), `POST /api/reclassify` (`handleReclassifyAll`) skips it — Race is a hard Garmin fact and manual assignments are the user's definitive word, neither is ever silently overwritten by a global reclassify. - **Race** is special-cased: `is_race` is derived at sync time from Garmin's own `eventType.typeKey == "race"` (see `internal/sync/mapping.go`), not a rule the user tunes. Once an activity is assigned Race (or manually overridden to any kind), `POST /api/reclassify` (`handleReclassifyAll`) skips it — Race is a hard Garmin fact and manual assignments are the user's definitive word, neither is ever silently overwritten by a global reclassify.
## mcp-garmin integration ## Garmin integration (direct wrapper, no MCP)
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered: `internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these. - **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason). - **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun became multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` now defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped. - **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). - **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. - **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`). - **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band. - **`get_workout_by_id`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session. - Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session.
## Data model conventions ## Data model conventions
@@ -125,4 +124,4 @@ See `docs/DATABASE.md` for the full, always-current schema (every table/column/i
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since schema/SQL correctness is exactly what needs catching. Since every table is `user_id`-scoped, tests provision a user first (`db.ProvisionUser`) and thread that `userID` into every call. - `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — deliberate, not mocked, since schema/SQL correctness is exactly what needs catching. Since every table is `user_id`-scoped, tests provision a user first (`db.ProvisionUser`) and thread that `userID` into every call.
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`; `newTestServer` auto-provisions a `"test-user"` account matching the session cookie `doJSON` mints, so most handler tests don't need to think about provisioning at all. Tests that specifically need an *unprovisioned* session (e.g. the setup flow, or `resolveUser`'s not-found path) build a bare `NewServer` directly instead of using `newTestServer`. - `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`; `newTestServer` auto-provisions a `"test-user"` account matching the session cookie `doJSON` mints, so most handler tests don't need to think about provisioning at all. Tests that specifically need an *unprovisioned* session (e.g. the setup flow, or `resolveUser`'s not-found path) build a bare `NewServer` directly instead of using `newTestServer`.
- **Cross-user isolation is tested adversarially, not just in parallel** — `internal/store/isolation_test.go` and `internal/api/isolation_test.go` provision two real users and attempt real reads/writes against the *other* user's real row IDs, asserting on not-found/403/empty-result rather than merely checking two separately-created rows don't collide. Any new per-user-scoped feature should get the same treatment, not just a "two users each see their own data" happy-path test. - **Cross-user isolation is tested adversarially, not just in parallel** — `internal/store/isolation_test.go` and `internal/api/isolation_test.go` provision two real users and attempt real reads/writes against the *other* user's real row IDs, asserting on not-found/403/empty-result rather than merely checking two separately-created rows don't collide. Any new per-user-scoped feature should get the same treatment, not just a "two users each see their own data" happy-path test.
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `geniusrund` auth endpoints. - The Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real `geniusrund` auth endpoints.

View File

@@ -44,7 +44,6 @@ func main() {
server := api.NewServer(db, garmin.NewClient, garmin.Config{ server := api.NewServer(db, garmin.NewClient, garmin.Config{
PythonPath: cfg.GarminPythonPath, PythonPath: cfg.GarminPythonPath,
ServerPath: cfg.GarminServerPath,
TokenStorePath: cfg.GarminTokenStoreRoot, TokenStorePath: cfg.GarminTokenStoreRoot,
}, appsync.Config{ }, appsync.Config{
MinConfidence: cfg.MinConfidence, MinConfidence: cfg.MinConfidence,

View File

@@ -1,243 +0,0 @@
// Throwaway spike to validate that mark3labs/mcp-go's stdio client can drive
// mcp-garmin (spawn, initialize handshake, call tools, parse results).
// Not part of the production build — delete once internal/garmin is built.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
func main() {
pythonPath := flag.String("python", "", "path to mcp-garmin .venv python executable")
serverPath := flag.String("server", "", "path to mcp-garmin server.py")
limit := flag.Int("limit", 5, "activity limit for get_activities")
startDate := flag.String("start", "", "start date YYYY-MM-DD for get_activities (default: 365 days ago)")
endDate := flag.String("end", "", "end date YYYY-MM-DD for get_activities (default: today)")
flag.Parse()
if *pythonPath == "" || *serverPath == "" {
log.Fatal("usage: mcpspike -python <path to .venv/bin/python> -server <path to server.py>")
}
email := os.Getenv("GARMIN_EMAIL")
password := os.Getenv("GARMIN_PASSWORD")
if email == "" || password == "" {
log.Fatal("GARMIN_EMAIL and GARMIN_PASSWORD must be set in the environment")
}
env := []string{
"GARMIN_EMAIL=" + email,
"GARMIN_PASSWORD=" + password,
"PYTHONUNBUFFERED=1",
}
c, err := client.NewStdioMCPClient(*pythonPath, env, *serverPath)
if err != nil {
log.Fatalf("spawn subprocess: %v", err)
}
defer c.Close()
if stdio, ok := c.GetTransport().(*transport.Stdio); ok {
go func() {
buf := make([]byte, 4096)
for {
n, err := stdio.Stderr().Read(buf)
if n > 0 {
fmt.Fprint(os.Stderr, string(buf[:n]))
}
if err != nil {
return
}
}
}()
} else {
log.Println("warning: could not get stdio transport to forward subprocess stderr")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{Name: "mcpspike", Version: "0.0.1"}
initRes, err := c.Initialize(ctx, initReq)
if err != nil {
log.Fatalf("initialize handshake failed: %v", err)
}
fmt.Printf("initialized OK: server=%s version=%s protocol=%s\n",
initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion)
tools, err := c.ListTools(ctx, mcp.ListToolsRequest{})
if err != nil {
log.Fatalf("list tools failed: %v", err)
}
fmt.Printf("server exposes %d tools:\n", len(tools.Tools))
for _, t := range tools.Tools {
fmt.Printf(" - %s: %s\n", t.Name, t.Description)
}
fmt.Println("\ncalling authenticate()...")
authRes, err := callTool(ctx, c, "authenticate", nil)
if err != nil {
log.Fatalf("authenticate call failed: %v", err)
}
fmt.Printf("authenticate() -> %s\n", authRes)
if containsMFAPrompt(authRes) {
fmt.Print("MFA required. Enter code: ")
var code string
fmt.Scanln(&code)
mfaRes, err := callTool(ctx, c, "complete_mfa", map[string]any{"code": code})
if err != nil {
log.Fatalf("complete_mfa call failed: %v", err)
}
fmt.Printf("complete_mfa() -> %s\n", mfaRes)
}
start := *startDate
if start == "" {
start = time.Now().AddDate(0, 0, -365).Format("2006-01-02")
}
end := *endDate
if end == "" {
end = time.Now().Format("2006-01-02")
}
fmt.Printf("\ncalling get_activities(start_date=%s, end_date=%s, limit=%d)...\n", start, end, *limit)
actRes, err := callTool(ctx, c, "get_activities", map[string]any{
"start_date": start,
"end_date": end,
"limit": *limit,
})
if err != nil {
log.Fatalf("get_activities call failed: %v", err)
}
var activities []map[string]any
if err := json.Unmarshal([]byte(actRes), &activities); err != nil {
fmt.Printf("get_activities() returned non-JSON (likely an error string): %s\n", actRes)
return
}
pretty, _ := json.MarshalIndent(activities, "", " ")
fmt.Printf("get_activities() parsed OK, %d activities, %d bytes:\n%s\n", len(activities), len(actRes), truncate(string(pretty), 4000))
if len(activities) == 0 {
fmt.Println("\nno activities in range, skipping get_activity_details")
return
}
// Prefer a running activity if one is present, for the most relevant lap/split shape.
chosen := activities[0]
for _, a := range activities {
if at, ok := a["activityType"].(map[string]any); ok {
if tk, _ := at["typeKey"].(string); tk == "running" || tk == "trail_running" || tk == "treadmill_running" {
chosen = a
break
}
}
}
var activityID string
switch v := chosen["activityId"].(type) {
case float64:
activityID = strconv.FormatFloat(v, 'f', -1, 64)
default:
activityID = fmt.Sprint(v)
}
fmt.Printf("\ncalling get_activity_details(activity_id=%s) [activityName=%v]...\n", activityID, chosen["activityName"])
splitsRes, err := callTool(ctx, c, "get_activity_splits", map[string]any{"activity_id": activityID})
if err != nil {
log.Fatalf("get_activity_splits call failed: %v", err)
}
var splits map[string]any
if err := json.Unmarshal([]byte(splitsRes), &splits); err != nil {
fmt.Printf("get_activity_splits() returned non-JSON: %s\n", splitsRes)
return
}
keys := make([]string, 0, len(splits))
for k := range splits {
keys = append(keys, k)
}
fmt.Printf("get_activity_splits() top-level keys: %v\n", keys)
prettySplits, _ := json.MarshalIndent(splits, "", " ")
fmt.Printf("get_activity_splits() parsed OK, %d bytes:\n%s\n", len(splitsRes), truncate(string(prettySplits), 8000))
fmt.Println("\ncalling get_activity_details() again to inspect metricDescriptors + a small metrics sample...")
detailsRes, err := callTool(ctx, c, "get_activity_details", map[string]any{"activity_id": activityID})
if err != nil {
log.Fatalf("get_activity_details call failed: %v", err)
}
var details map[string]any
if err := json.Unmarshal([]byte(detailsRes), &details); err != nil {
fmt.Printf("get_activity_details() returned non-JSON: %s\n", detailsRes)
return
}
if descriptors, ok := details["metricDescriptors"]; ok {
pretty, _ := json.MarshalIndent(descriptors, "", " ")
fmt.Printf("metricDescriptors:\n%s\n", pretty)
} else {
fmt.Println("no metricDescriptors key found")
}
if rows, ok := details["activityDetailMetrics"].([]any); ok && len(rows) > 0 {
sampleN := 5
if len(rows) < sampleN {
sampleN = len(rows)
}
pretty, _ := json.MarshalIndent(rows[:sampleN], "", " ")
fmt.Printf("activityDetailMetrics sample (first %d of %d rows):\n%s\n", sampleN, len(rows), pretty)
}
}
func callTool(ctx context.Context, c *client.Client, name string, args map[string]any) (string, error) {
req := mcp.CallToolRequest{}
req.Params.Name = name
req.Params.Arguments = args
res, err := c.CallTool(ctx, req)
if err != nil {
return "", err
}
if res.IsError {
return "", fmt.Errorf("tool %s returned an error result", name)
}
var out string
for _, content := range res.Content {
if tc, ok := content.(mcp.TextContent); ok {
out += tc.Text
}
}
return out, nil
}
func containsMFAPrompt(s string) bool {
for _, needle := range []string{"MFA required", "MFA code", "complete_mfa"} {
if len(s) >= len(needle) {
for i := 0; i+len(needle) <= len(s); i++ {
if s[i:i+len(needle)] == needle {
return true
}
}
}
}
return false
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}

View File

@@ -6,7 +6,6 @@ require (
github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/chi/v5 v5.3.1
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/mark3labs/mcp-go v0.56.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.53.0 modernc.org/sqlite v1.53.0
) )
@@ -14,16 +13,11 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.14.0 // indirect
modernc.org/libc v1.73.4 // indirect modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

View File

@@ -1,53 +1,25 @@
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8=
github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -57,12 +29,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=

View File

@@ -34,7 +34,7 @@ type Server struct {
// *mock.Client (see newTestServer in api_test.go). // *mock.Client (see newTestServer in api_test.go).
GarminFactory func(garmin.Config) garmin.Client GarminFactory func(garmin.Config) garmin.Client
// GarminBase holds the plumbing shared by every user's garmin.Config // GarminBase holds the plumbing shared by every user's garmin.Config
// (subprocess paths + the token-store root directory); only // (the python interpreter path + the token-store root directory); only
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by // GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
// garminFor. // garminFor.
GarminBase garmin.Config GarminBase garmin.Config

View File

@@ -22,12 +22,12 @@ type Config struct {
// DBPath is the SQLite database file path. // DBPath is the SQLite database file path.
DBPath string DBPath string
// GarminPythonPath is mcp-garmin's venv python executable. // GarminPythonPath is the python3 interpreter used to run the embedded
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
// Defaults to "python3" resolved via PATH if unset.
GarminPythonPath string GarminPythonPath string
// GarminServerPath is mcp-garmin's server.py.
GarminServerPath string
// GarminTokenStoreRoot is the root directory under which each user's // GarminTokenStoreRoot is the root directory under which each user's
// mcp-garmin session cache lives (one subdirectory per user id, e.g. // Garmin session cache lives (one subdirectory per user id, e.g.
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it // "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it
// defaults to a "garmin-tokenstores" directory next to DBPath so every // defaults to a "garmin-tokenstores" directory next to DBPath so every
// deployment gets per-user isolation automatically -- multi-tenant // deployment gets per-user isolation automatically -- multi-tenant
@@ -60,8 +60,7 @@ func Load() (Config, error) {
cfg := Config{ cfg := Config{
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"), Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"),
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"), GarminPythonPath: getEnvDefault("GARMIN_WRAPPER_PYTHON", "python3"),
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"), GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
@@ -78,12 +77,6 @@ func Load() (Config, error) {
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot) log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
} }
if cfg.GarminPythonPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)")
}
if cfg.GarminServerPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)")
}
if cfg.PublicBaseURL == "" { if cfg.PublicBaseURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)") return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)")
} }

View File

@@ -8,8 +8,6 @@ import (
func setRequiredEnv(t *testing.T) { func setRequiredEnv(t *testing.T) {
t.Helper() t.Helper()
t.Setenv("MCP_GARMIN_PYTHON", "/usr/bin/python3")
t.Setenv("MCP_GARMIN_SERVER", "/opt/mcp-garmin/server.py")
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com") t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com")
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm") t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun") t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
@@ -110,6 +108,32 @@ func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
} }
} }
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GARMIN_WRAPPER_PYTHON", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminPythonPath != "python3" {
t.Errorf("GarminPythonPath = %q, want default %q", cfg.GarminPythonPath, "python3")
}
}
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GARMIN_WRAPPER_PYTHON", "/opt/venv/bin/python3")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.GarminPythonPath != "/opt/venv/bin/python3" {
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.GarminPythonPath)
}
}
func TestLoad_CustomRoleAndDuration(t *testing.T) { func TestLoad_CustomRoleAndDuration(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin") t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")

View File

@@ -1,23 +1,33 @@
// Package garmin wraps the mcp-garmin MCP server as a narrow Go client // Package garmin wraps a direct garminconnect subprocess (see
// interface, so the rest of geniusrun never deals with MCP/JSON-RPC directly. // pyscript/wrapper.py) as a narrow Go client interface, so the rest of
// geniusrun never deals with the wire protocol directly.
package garmin package garmin
import ( import (
"bufio"
"context" "context"
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os"
"os/exec"
"strconv" "strconv"
"strings"
"sync" "sync"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
) )
//go:embed pyscript/wrapper.py
var wrapperScript string
// maxWrapperLineBytes bounds one JSON-line response from the wrapper
// subprocess -- well above the default 64KB bufio.Scanner limit, since
// get_activity_details responses (per-second telemetry) can be several MB.
const maxWrapperLineBytes = 16 * 1024 * 1024
// Client is the interface the rest of geniusrun depends on. The real // Client is the interface the rest of geniusrun depends on. The real
// implementation drives mcp-garmin over stdio; internal/garmin/mock provides // implementation drives an embedded Python wrapper subprocess over stdio;
// a fake for tests and frontend-only development. // internal/garmin/mock provides a fake for tests and frontend-only
// development.
type Client interface { type Client interface {
// Authenticate triggers Garmin login using credentials the subprocess // Authenticate triggers Garmin login using credentials the subprocess
// was started with. Spawns the subprocess on first call. // was started with. Spawns the subprocess on first call.
@@ -42,180 +52,296 @@ type Client interface {
Close() error Close() error
} }
// Config configures how the mcp-garmin subprocess is spawned. // Config configures how the Garmin wrapper subprocess is spawned.
type Config struct { type Config struct {
PythonPath string // path to mcp-garmin's venv python executable // PythonPath is the python3 interpreter to run the embedded wrapper
ServerPath string // path to mcp-garmin's server.py // script with. Empty defaults to "python3" resolved via PATH.
PythonPath string
GarminEmail string GarminEmail string
GarminPassword string GarminPassword string
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so mcp-garmin // TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so the wrapper
// persists/resumes a Garmin session there instead of the default ~/.garth. // persists/resumes a Garmin session there instead of the default ~/.garth.
TokenStorePath string TokenStorePath string
} }
// mcpClient is the real Client implementation, backed by an mcp-garmin // wireRequest is one line sent to the wrapper subprocess's stdin.
// subprocess spoken to over stdio MCP. type wireRequest struct {
type mcpClient struct { ID int `json:"id"`
Cmd string `json:"cmd"`
Params any `json:"params,omitempty"`
}
// wireResponse is one line read from the wrapper subprocess's stdout.
type wireResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// callParams is the Params payload for a generic "call" request: dispatches
// to any garminconnect.Garmin method by name.
type callParams struct {
Method string `json:"method"`
Args map[string]any `json:"args,omitempty"`
}
// authResultWire is the Result payload for authenticate/complete_mfa.
type authResultWire struct {
Status string `json:"status"`
Message string `json:"message"`
}
func mapAuthStatus(s string) AuthStatus {
switch s {
case "success":
return AuthSuccess
case "mfa_required":
return AuthMFARequired
case "failed":
return AuthFailed
default:
return AuthUnknown
}
}
// subprocessClient is the real Client implementation, backed by a wrapper
// subprocess spoken to over newline-delimited JSON on stdio.
type subprocessClient struct {
cfg Config cfg Config
mu sync.Mutex // serializes calls; mcp-garmin holds a single shared Garmin client mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
inner *mcpclient.Client cmd *exec.Cmd
stdin io.WriteCloser
enc *json.Encoder
scanner *bufio.Scanner
scriptPath string // temp file holding the embedded wrapper.py, written once
started bool started bool
nextID int
stderrDone chan struct{} // closed once the stderr-copy goroutine has finished reading
} }
// NewClient builds a Client. The subprocess is not spawned until the first // ensureStarted spawns the wrapper subprocess if it isn't already running.
// call that needs it (Authenticate, or any data call once authenticated). // Callers must hold c.mu.
func NewClient(cfg Config) Client { func (c *subprocessClient) ensureStarted() error {
return &mcpClient{cfg: cfg}
}
func (c *mcpClient) ensureStarted(ctx context.Context) error {
if c.started { if c.started {
return nil return nil
} }
env := []string{ if c.scriptPath == "" {
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
if err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err)
}
if _, err := f.WriteString(wrapperScript); err != nil {
f.Close()
return fmt.Errorf("write embedded wrapper script: %w", err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err)
}
c.scriptPath = f.Name()
}
pythonPath := c.cfg.PythonPath
if pythonPath == "" {
pythonPath = "python3"
}
cmd := exec.Command(pythonPath, c.scriptPath)
extraEnv := []string{
"GARMIN_EMAIL=" + c.cfg.GarminEmail, "GARMIN_EMAIL=" + c.cfg.GarminEmail,
"GARMIN_PASSWORD=" + c.cfg.GarminPassword, "GARMIN_PASSWORD=" + c.cfg.GarminPassword,
"PYTHONUNBUFFERED=1", "PYTHONUNBUFFERED=1",
} }
if c.cfg.TokenStorePath != "" { if c.cfg.TokenStorePath != "" {
env = append(env, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath) extraEnv = append(extraEnv, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
} }
cmd.Env = append(os.Environ(), extraEnv...)
inner, err := mcpclient.NewStdioMCPClient(c.cfg.PythonPath, env, c.cfg.ServerPath) stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
return fmt.Errorf("spawn mcp-garmin subprocess: %w", err) return fmt.Errorf("open wrapper stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("open wrapper stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("open wrapper stderr: %w", err)
} }
if stdio, ok := inner.GetTransport().(*transport.Stdio); ok { if err := cmd.Start(); err != nil {
go drainStderr(stdio) return fmt.Errorf("spawn garmin wrapper subprocess: %w", err)
} }
// wrapper.py logs auth/rate-limit diagnostics to stderr. Per os/exec's
// StderrPipe docs, it's incorrect to call Wait before all reads from the
// pipe have completed, so close() waits on stderrDone before Wait-ing.
c.stderrDone = make(chan struct{})
stderrDone := c.stderrDone
go func() {
io.Copy(os.Stderr, stderr)
close(stderrDone)
}()
initReq := mcp.InitializeRequest{} c.cmd = cmd
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION c.stdin = stdin
initReq.Params.ClientInfo = mcp.Implementation{Name: "geniusrund", Version: "0.0.1"} c.enc = json.NewEncoder(stdin)
if _, err := inner.Initialize(ctx, initReq); err != nil { scanner := bufio.NewScanner(stdout)
inner.Close() scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
return fmt.Errorf("mcp initialize handshake: %w", err) c.scanner = scanner
}
c.inner = inner
c.started = true c.started = true
c.nextID = 0
return nil return nil
} }
// roundTrip sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted.
func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessage, error) {
c.nextID++
id := c.nextID
if err := c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
return nil, fmt.Errorf("write %s request: %w", cmdName, err)
}
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
return nil, fmt.Errorf("read %s response: %w", cmdName, err)
}
return nil, fmt.Errorf("read %s response: subprocess closed its output", cmdName)
}
var resp wireResponse
if err := json.Unmarshal(c.scanner.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("parse %s response: %w", cmdName, err)
}
if resp.ID != id {
return nil, fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
}
if resp.Error != "" {
return nil, fmt.Errorf("%s: %s", cmdName, resp.Error)
}
return resp.Result, nil
}
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip("authenticate", nil)
if err != nil {
return AuthResult{}, err
}
var wire authResultWire
if err := json.Unmarshal(raw, &wire); err != nil {
return AuthResult{}, fmt.Errorf("parse authenticate result: %w", err)
}
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
}
func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip("complete_mfa", map[string]any{"code": code})
if err != nil {
return AuthResult{}, err
}
var wire authResultWire
if err := json.Unmarshal(raw, &wire); err != nil {
return AuthResult{}, fmt.Errorf("parse complete_mfa result: %w", err)
}
return AuthResult{Status: mapAuthStatus(wire.Status), Message: wire.Message}, nil
}
// UpdateCredentials implements Client. // UpdateCredentials implements Client.
func (c *mcpClient) UpdateCredentials(email, password string) { func (c *subprocessClient) UpdateCredentials(email, password string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.cfg.GarminEmail = email c.cfg.GarminEmail = email
c.cfg.GarminPassword = password c.cfg.GarminPassword = password
c.close()
if c.started { }
if c.inner != nil {
c.inner.Close() // Close implements Client.
func (c *subprocessClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.close()
}
// close terminates the subprocess, if running. Callers must hold c.mu.
func (c *subprocessClient) close() error {
if !c.started {
return nil
} }
c.inner = nil
c.started = false c.started = false
if c.stdin != nil {
c.stdin.Close()
} }
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
}
if c.stderrDone != nil {
// Killing the process closes its end of the stderr pipe, which
// unblocks the copy goroutine's Read with EOF; wait for it to finish
// before Wait, per os/exec's StderrPipe doc.
<-c.stderrDone
}
var err error
if c.cmd != nil {
err = c.cmd.Wait()
}
c.cmd, c.stdin, c.enc, c.scanner, c.stderrDone = nil, nil, nil, nil, nil
return err
} }
// drainStderr forwards the subprocess's debug/log output so it isn't func truncate(s string, n int) string {
// silently dropped (mcp-garmin logs auth/rate-limit diagnostics there). if len(s) <= n {
func drainStderr(stdio *transport.Stdio) { return s
buf := make([]byte, 4096)
for {
n, err := stdio.Stderr().Read(buf)
if n > 0 {
fmt.Print(string(buf[:n]))
}
if err != nil {
return
}
} }
return s[:n] + "...(truncated)"
} }
func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (string, error) { var _ Client = (*subprocessClient)(nil)
req := mcp.CallToolRequest{}
req.Params.Name = name
req.Params.Arguments = args
res, err := c.inner.CallTool(ctx, req) // NewClient builds a Client. The subprocess is not spawned until the first
if err != nil { // call that needs it (Authenticate, or any data call once authenticated).
return "", fmt.Errorf("call tool %s: %w", name, err) func NewClient(cfg Config) Client {
} return &subprocessClient{cfg: cfg}
var out strings.Builder
for _, content := range res.Content {
if tc, ok := content.(mcp.TextContent); ok {
out.WriteString(tc.Text)
}
}
if res.IsError {
return "", fmt.Errorf("tool %s returned an error result: %s", name, out.String())
}
return out.String(), nil
} }
func (c *mcpClient) Authenticate(ctx context.Context) (AuthResult, error) { func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "authenticate", nil)
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func (c *mcpClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func parseAuthResult(msg string) AuthResult {
switch {
case strings.Contains(msg, "Authenticated successfully") || strings.Contains(msg, "MFA accepted"):
return AuthResult{Status: AuthSuccess, Message: msg}
case strings.Contains(msg, "MFA required"):
return AuthResult{Status: AuthMFARequired, Message: msg}
default:
return AuthResult{Status: AuthFailed, Message: msg}
}
}
func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return nil, err return nil, err
} }
msg, err := c.callTool(ctx, "get_activities", map[string]any{ raw, err := c.roundTrip("call", callParams{
"start_date": startDate, Method: "get_activities_by_date",
"end_date": endDate, Args: map[string]any{"startdate": startDate, "enddate": endDate},
"limit": limit,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
var rawActivities []json.RawMessage var rawActivities []json.RawMessage
if err := json.Unmarshal([]byte(msg), &rawActivities); err != nil { if err := json.Unmarshal(raw, &rawActivities); err != nil {
return nil, fmt.Errorf("get_activities did not return a JSON array (%q): %w", truncate(msg, 200), err) return nil, fmt.Errorf("get_activities_by_date did not return a JSON array (%q): %w", truncate(string(raw), 200), err)
}
if limit > 0 && limit < len(rawActivities) {
rawActivities = rawActivities[:limit]
} }
activities := make([]Activity, 0, len(rawActivities)) activities := make([]Activity, 0, len(rawActivities))
@@ -230,15 +356,16 @@ func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string
return activities, nil return activities, nil
} }
func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) { func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(); err != nil {
return ActivitySplits{}, err return ActivitySplits{}, err
} }
msg, err := c.callTool(ctx, "get_activity_splits", map[string]any{ raw, err := c.roundTrip("call", callParams{
"activity_id": strconv.FormatInt(activityID, 10), Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
if err != nil { if err != nil {
return ActivitySplits{}, err return ActivitySplits{}, err
@@ -248,8 +375,8 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
ActivityID int64 `json:"activityId"` ActivityID int64 `json:"activityId"`
Laps []json.RawMessage `json:"lapDTOs"` Laps []json.RawMessage `json:"lapDTOs"`
} }
if err := json.Unmarshal([]byte(msg), &envelope); err != nil { if err := json.Unmarshal(raw, &envelope); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err) return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
} }
splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))} splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))}
@@ -264,63 +391,48 @@ func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (Ac
return splits, nil return splits, nil
} }
func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) { func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(); err != nil {
return ActivityDetails{}, err return ActivityDetails{}, err
} }
msg, err := c.callTool(ctx, "get_activity_details", map[string]any{ raw, err := c.roundTrip("call", callParams{
"activity_id": strconv.FormatInt(activityID, 10), Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
if err != nil { if err != nil {
return ActivityDetails{}, err return ActivityDetails{}, err
} }
var details ActivityDetails var details ActivityDetails
if err := json.Unmarshal([]byte(msg), &details); err != nil { if err := json.Unmarshal(raw, &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err) return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
} }
details.Raw = json.RawMessage(msg) details.Raw = json.RawMessage(raw)
return details, nil return details, nil
} }
func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) { func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil { if err := c.ensureStarted(); err != nil {
return Workout{}, err return Workout{}, err
} }
msg, err := c.callTool(ctx, "get_workout_by_id", map[string]any{ raw, err := c.roundTrip("call", callParams{
"workout_id": strconv.FormatInt(workoutID, 10), Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
}) })
if err != nil { if err != nil {
return Workout{}, err return Workout{}, err
} }
var workout Workout var workout Workout
if err := json.Unmarshal([]byte(msg), &workout); err != nil { if err := json.Unmarshal(raw, &workout); err != nil {
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err) return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(string(raw), 200), err)
} }
workout.Raw = json.RawMessage(msg) workout.Raw = json.RawMessage(raw)
return workout, nil return workout, nil
} }
func (c *mcpClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.started {
return nil
}
return c.inner.Close()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}

View File

@@ -1,29 +1,138 @@
package garmin package garmin
import "testing" import (
"bufio"
"context"
"encoding/json"
"io"
"os/exec"
"strings"
"testing"
"time"
)
func TestParseAuthResult(t *testing.T) { // wireResponsePayload is what a fake wrapper handler returns for one
cases := []struct { // request; the harness fills in the response ID.
msg string type wireResponsePayload struct {
want AuthStatus result json.RawMessage
}{ err string
{"Authenticated successfully.", AuthSuccess},
{"MFA accepted. Authenticated successfully.", AuthSuccess},
{"MFA required. Garmin has sent a verification code...", AuthMFARequired},
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed},
{"Authentication failed after MFA: bad code", AuthFailed},
} }
for _, c := range cases {
got := parseAuthResult(c.msg) func fakeResult(v any) wireResponsePayload {
if got.Status != c.want { b, err := json.Marshal(v)
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want) if err != nil {
panic(err)
} }
return wireResponsePayload{result: b}
}
func fakeError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg}
}
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine
// that plays the Python wrapper's role, so protocol-level Go logic can be
// tested without python3/garminconnect installed.
func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawMessage) wireResponsePayload) *subprocessClient {
t.Helper()
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
t.Cleanup(func() {
reqW.Close()
respW.Close()
})
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
enc := json.NewEncoder(respW)
for scanner.Scan() {
var req struct {
ID int `json:"id"`
Cmd string `json:"cmd"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
continue
}
payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err}
if err := enc.Encode(resp); err != nil {
return
}
}
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
return &subprocessClient{
started: true,
enc: json.NewEncoder(reqW),
scanner: scanner,
} }
} }
func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) { func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}} reqR, reqW := io.Pipe()
c.started = true // simulate an already-spawned subprocess respR, respW := io.Pipe()
defer reqW.Close()
defer respW.Close()
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
scanner.Scan() // read and discard the one request
json.NewEncoder(respW).Encode(wireResponse{ID: 999, Result: json.RawMessage(`{}`)})
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip("authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "id mismatch") {
t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
}
}
func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) {
reqR, reqW := io.Pipe()
respR, respW := io.Pipe()
defer reqW.Close()
go func() {
scanner := bufio.NewScanner(reqR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
scanner.Scan()
respW.Close() // subprocess "exited": stdout closes with no response
}()
scanner := bufio.NewScanner(respR)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip("authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
}
}
func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
_, err := c.roundTrip("authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
}
}
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
c.UpdateCredentials("new@example.com", "new") c.UpdateCredentials("new@example.com", "new")
@@ -33,7 +142,255 @@ func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
if c.started { if c.started {
t.Error("started should be reset to false so the next call respawns the subprocess") t.Error("started should be reset to false so the next call respawns the subprocess")
} }
if c.inner != nil { }
t.Error("inner should be cleared so ensureStarted spawns a fresh client")
func TestSubprocessClient_Close_NoOpWhenNotStarted(t *testing.T) {
c := &subprocessClient{}
if err := c.Close(); err != nil {
t.Errorf("Close on an unstarted client = %v, want nil", err)
}
}
// TestSubprocessClient_Close_WaitsForStderrCopyGoroutine exercises close()
// against a real subprocess that writes to stderr, mirroring how
// ensureStarted wires up the stderr-copy goroutine. Per os/exec's
// StderrPipe docs it is incorrect to call Wait before all reads from the
// pipe have completed; this guards against close() calling cmd.Wait()
// before the copy goroutine has drained the pipe (which previously risked a
// race / truncated stderr / spurious "file already closed" errors).
func TestSubprocessClient_Close_WaitsForStderrCopyGoroutine(t *testing.T) {
cmd := exec.Command("sh", "-c", "for i in 1 2 3 4 5; do echo line$i 1>&2; done; sleep 5")
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("StderrPipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
stderrDone := make(chan struct{})
go func() {
io.Copy(io.Discard, stderr)
close(stderrDone)
}()
c := &subprocessClient{started: true, cmd: cmd, stderrDone: stderrDone}
done := make(chan error, 1)
go func() { done <- c.close() }()
select {
case <-done:
// close() returned -- since it killed the process and waited on
// stderrDone before Wait-ing, this proves the ordering held without
// deadlocking.
case <-time.After(5 * time.Second):
t.Fatal("close() did not return in time -- likely blocked waiting on stderrDone")
}
if c.stderrDone != nil {
t.Error("stderrDone should be reset to nil after close()")
}
}
func TestSubprocessClient_Authenticate_Success(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
if cmd != "authenticate" {
t.Fatalf("unexpected cmd %q", cmd)
}
return fakeResult(authResultWire{Status: "success", Message: "Authenticated successfully."})
})
res, err := c.Authenticate(context.Background())
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if res.Status != AuthSuccess || res.Message != "Authenticated successfully." {
t.Errorf("Authenticate result = %+v", res)
}
}
func TestSubprocessClient_Authenticate_MFARequired(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required. ..."})
})
res, err := c.Authenticate(context.Background())
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if res.Status != AuthMFARequired {
t.Errorf("Authenticate status = %v, want AuthMFARequired", res.Status)
}
}
func TestSubprocessClient_Authenticate_WrapperErrorPropagates(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("subprocess exploded")
})
_, err := c.Authenticate(context.Background())
if err == nil || !strings.Contains(err.Error(), "subprocess exploded") {
t.Fatalf("Authenticate error = %v, want it to mention the wrapper error", err)
}
}
func TestSubprocessClient_CompleteMFA_SendsCodeAndReturnsStatus(t *testing.T) {
var gotCode string
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
if cmd != "complete_mfa" {
t.Fatalf("unexpected cmd %q", cmd)
}
var p struct {
Code string `json:"code"`
}
if err := json.Unmarshal(params, &p); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
gotCode = p.Code
return fakeResult(authResultWire{Status: "success", Message: "MFA accepted. Authenticated successfully."})
})
res, err := c.CompleteMFA(context.Background(), "123456")
if err != nil {
t.Fatalf("CompleteMFA: %v", err)
}
if gotCode != "123456" {
t.Errorf("code sent to wrapper = %q, want 123456", gotCode)
}
if res.Status != AuthSuccess {
t.Errorf("CompleteMFA status = %v, want AuthSuccess", res.Status)
}
}
func TestSubprocessClient_CompleteMFA_Failed(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "failed", Message: "Authentication failed after MFA: bad code"})
})
res, err := c.CompleteMFA(context.Background(), "000000")
if err != nil {
t.Fatalf("CompleteMFA: %v", err)
}
if res.Status != AuthFailed {
t.Errorf("CompleteMFA status = %v, want AuthFailed", res.Status)
}
}
func TestSubprocessClient_GetActivities_UsesGarminconnectKwargNames(t *testing.T) {
var gotMethod string
var gotArgs map[string]any
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
if err := json.Unmarshal(params, &p); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
gotMethod, gotArgs = p.Method, p.Args
return fakeResult([]map[string]any{
{"activityId": float64(123), "activityType": map[string]any{"typeKey": "running"}},
})
})
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 0)
if err != nil {
t.Fatalf("GetActivities: %v", err)
}
if gotMethod != "get_activities_by_date" {
t.Errorf("method = %q, want get_activities_by_date", gotMethod)
}
if gotArgs["startdate"] != "2026-07-01" || gotArgs["enddate"] != "2026-07-25" {
t.Errorf("args = %+v, want startdate/enddate matching garminconnect's real kwargs", gotArgs)
}
if len(activities) != 1 || activities[0].ActivityID != 123 {
t.Fatalf("activities = %+v", activities)
}
}
func TestSubprocessClient_GetActivities_AppliesLimitClientSide(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
raw := make([]map[string]any, 5)
for i := range raw {
raw[i] = map[string]any{"activityId": float64(i)}
}
return fakeResult(raw)
})
activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 2)
if err != nil {
t.Fatalf("GetActivities: %v", err)
}
if len(activities) != 2 {
t.Fatalf("len(activities) = %d, want 2", len(activities))
}
}
func TestSubprocessClient_GetActivitySplits_ParsesLapDTOsEnvelope(t *testing.T) {
var gotMethod string
var gotArgs map[string]any
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
json.Unmarshal(params, &p)
gotMethod, gotArgs = p.Method, p.Args
return fakeResult(map[string]any{
"activityId": float64(111),
"lapDTOs": []map[string]any{{"lapIndex": float64(1), "distance": float64(1000)}},
})
})
splits, err := c.GetActivitySplits(context.Background(), 111)
if err != nil {
t.Fatalf("GetActivitySplits: %v", err)
}
if gotMethod != "get_activity_splits" || gotArgs["activity_id"] != "111" {
t.Errorf("method/args = %q/%+v, want get_activity_splits with activity_id=\"111\"", gotMethod, gotArgs)
}
if splits.ActivityID != 111 || len(splits.Laps) != 1 || splits.Laps[0].LapIndex != 1 {
t.Fatalf("splits = %+v", splits)
}
}
func TestSubprocessClient_GetActivityDetails_ParsesRawTelemetry(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
json.Unmarshal(params, &p)
if p.Method != "get_activity_details" || p.Args["activity_id"] != "222" {
t.Fatalf("unexpected call: %+v", p)
}
return fakeResult(map[string]any{
"activityId": float64(222),
"metricDescriptors": []map[string]any{{"key": "directHeartRate", "metricsIndex": float64(0)}},
"activityDetailMetrics": []map[string]any{{"metrics": []any{float64(101)}}},
})
})
details, err := c.GetActivityDetails(context.Background(), 222)
if err != nil {
t.Fatalf("GetActivityDetails: %v", err)
}
if details.ActivityID != 222 || len(details.MetricDescriptors) != 1 {
t.Fatalf("details = %+v", details)
}
}
func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
var p callParams
json.Unmarshal(params, &p)
if p.Method != "get_workout_by_id" || p.Args["workout_id"] != "333" {
t.Fatalf("unexpected call: %+v", p)
}
return fakeResult(map[string]any{
"workoutId": float64(333),
"workoutName": "Tempo",
"workoutSegments": []map[string]any{{"segmentOrder": float64(1), "workoutSteps": []any{}}},
})
})
workout, err := c.GetWorkoutByID(context.Background(), 333)
if err != nil {
t.Fatalf("GetWorkoutByID: %v", err)
}
if workout.WorkoutID != 333 || workout.WorkoutName != "Tempo" || len(workout.Segments) != 1 {
t.Fatalf("workout = %+v", workout)
} }
} }

View File

@@ -1,5 +1,5 @@
// Package mock provides a fake garmin.Client for tests and frontend/dev // Package mock provides a fake garmin.Client for tests and frontend/dev
// work without a live Garmin account or the mcp-garmin subprocess. // work without a live Garmin account or the wrapper subprocess.
package mock package mock
import ( import (

View File

@@ -0,0 +1,4 @@
.venv/
__pycache__/
.pytest_cache/
*.pyc

View File

@@ -0,0 +1,18 @@
[project]
name = "geniusrun-garmin-wrapper"
version = "0.1.0"
description = "Subprocess wrapper around garminconnect for geniusrund's internal/garmin package"
requires-python = ">=3.11"
dependencies = [
"garminconnect",
]
[project.optional-dependencies]
dev = ["pytest"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["."]

View File

@@ -0,0 +1,208 @@
import os
import queue
import threading
from unittest.mock import MagicMock, patch
import pytest
import wrapper
@pytest.fixture(autouse=True)
def reset_state():
original_state = wrapper._auth_state
original_client = wrapper._client
for q in (wrapper._mfa_input_queue, wrapper._login_result_queue):
while not q.empty():
try:
q.get_nowait()
except queue.Empty:
break
yield
wrapper._auth_state = original_state
wrapper._client = original_client
def test_authenticate_success():
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
resp = wrapper.dispatch({"id": 1, "cmd": "authenticate"})
assert resp == {"id": 1, "result": {"status": "success", "message": "Authenticated successfully."}}
assert wrapper._auth_state == "authenticated"
def test_authenticate_mfa_required():
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
with patch.object(wrapper._login_result_queue, "get", side_effect=queue.Empty):
resp = wrapper.dispatch({"id": 2, "cmd": "authenticate"})
assert resp["result"]["status"] == "mfa_required"
assert wrapper._auth_state == "mfa_pending"
def test_authenticate_missing_credentials():
with patch.dict("os.environ", {}, clear=False):
os.environ.pop("GARMIN_EMAIL", None)
os.environ.pop("GARMIN_PASSWORD", None)
resp = wrapper.dispatch({"id": 3, "cmd": "authenticate"})
assert resp["result"]["status"] == "failed"
assert "GARMIN_EMAIL" in resp["result"]["message"]
def test_complete_mfa_success():
wrapper._auth_state = "mfa_pending"
wrapper._login_result_queue.put(("success", None))
resp = wrapper.dispatch({"id": 4, "cmd": "complete_mfa", "params": {"code": "123456"}})
assert resp == {
"id": 4,
"result": {"status": "success", "message": "MFA accepted. Authenticated successfully."},
}
assert wrapper._auth_state == "authenticated"
assert wrapper._mfa_input_queue.get_nowait() == "123456"
def test_complete_mfa_not_in_progress():
wrapper._auth_state = "unauthenticated"
resp = wrapper.dispatch({"id": 5, "cmd": "complete_mfa", "params": {"code": "123456"}})
assert resp["result"]["status"] == "failed"
assert "No MFA in progress" in resp["result"]["message"]
def test_call_dispatches_to_named_garminconnect_method():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_activities_by_date.return_value = [{"activityId": "111"}]
resp = wrapper.dispatch({
"id": 6,
"cmd": "call",
"params": {
"method": "get_activities_by_date",
"args": {"startdate": "2026-07-01", "enddate": "2026-07-25"},
},
})
assert resp == {"id": 6, "result": [{"activityId": "111"}]}
wrapper._client.get_activities_by_date.assert_called_once_with(
startdate="2026-07-01", enddate="2026-07-25"
)
def test_call_unauthenticated_is_error():
wrapper._auth_state = "unauthenticated"
resp = wrapper.dispatch({
"id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
})
assert "error" in resp
assert "Not authenticated" in resp["error"]
def test_call_unknown_method_is_error():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock(spec=["get_activities_by_date"])
resp = wrapper.dispatch({
"id": 8, "cmd": "call", "params": {"method": "delete_everything", "args": {}},
})
assert resp["id"] == 8
assert "error" in resp
def test_call_propagates_garminconnect_exception_as_error():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_activity_splits.side_effect = Exception("not found")
resp = wrapper.dispatch({
"id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}},
})
assert resp == {"id": 9, "error": "not found"}
def test_dispatch_unknown_cmd_is_error():
resp = wrapper.dispatch({"id": 10, "cmd": "not_a_real_cmd"})
assert resp["id"] == 10
assert "unknown cmd" in resp["error"]
def test_startup_login_success():
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
wrapper._startup_login()
assert wrapper._auth_state == "authenticated"
def test_startup_login_missing_credentials_returns_immediately():
with patch.dict("os.environ", {}, clear=False):
os.environ.pop("GARMIN_EMAIL", None)
os.environ.pop("GARMIN_PASSWORD", None)
wrapper._auth_state = "unauthenticated"
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
assert wrapper._client is None
def test_startup_login_times_out_without_blocking():
# _startup_login now waits on its own private, function-local queue
# (never the shared wrapper._login_result_queue -- see the
# no-shared-queue-leakage test below), so forcing its timeout path
# means intercepting the queue.Queue() constructor it calls internally
# rather than patching a module-level queue object.
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
# Should return promptly (bounded by the 10s timeout passed to
# queue.get, which is mocked here to raise immediately) rather
# than blocking main()'s stdin dispatch loop from starting.
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
def test_startup_login_does_not_leak_into_shared_authenticate_queue():
"""Regression test: _startup_login used to push its background thread's
result onto the same module-level _login_result_queue that
_handle_authenticate/_handle_complete_mfa share for the MFA handoff. A
startup login that was still in flight when a user's first explicit
'authenticate' call came in could have that call accidentally dequeue
the startup thread's stale result instead of its own fresh one.
_startup_login now uses its own private queue, so a still-in-flight
startup attempt can never interfere with a later authenticate() call."""
release = threading.Event()
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
# A startup login whose underlying garminconnect call is still
# blocked (simulating "slow to resolve") when _startup_login's
# own bounded wait (mocked to time out immediately, so this test
# doesn't need to sleep the real 10s) returns.
slow_client = MagicMock()
slow_client.login.side_effect = lambda **kwargs: release.wait(5)
mock_garmin_cls.return_value = slow_client
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
# Nothing from the still-in-flight startup attempt ever touches the
# shared queue that authenticate()/complete_mfa() rely on.
assert wrapper._login_result_queue.empty()
release.set() # let the stale startup thread finish harmlessly in the background
# A fresh, unrelated authenticate() call must get its own result, not
# anything left over from the startup attempt above.
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls2:
mock_garmin_cls2.return_value = MagicMock()
resp = wrapper.dispatch({"id": 20, "cmd": "authenticate"})
assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}}
assert wrapper._auth_state == "authenticated"

View File

@@ -0,0 +1,202 @@
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
JSON on stdin/stdout by geniusrund's internal/garmin package. See
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
import json
import os
import queue
import sys
import threading
import traceback
from garminconnect import Garmin
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garth"))
_client = None
_auth_state = "unauthenticated"
_mfa_input_queue = queue.Queue()
_login_result_queue = queue.Queue()
def _debug(msg):
print(f"[garmin-wrapper debug] {msg}", file=sys.stderr, flush=True)
def _prompt_mfa():
_debug("garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin")
code = _mfa_input_queue.get(timeout=300)
_debug(f"prompt_mfa() handing code of length {len(code)} back to garminconnect")
return code
def _startup_login():
"""Silently resume a cached tokenstore session at process start, so a
freshly (re)spawned subprocess is already authenticated for background
syncs that never call the explicit authenticate command.
Bounded to the same 10s timeout as _handle_authenticate: the actual
login runs on a background daemon thread, and this function waits up
to 10s for it before returning either way. This runs before main()
enters its stdin dispatch loop, so a slow/rate-limited Garmin login
must never block indefinitely here -- if it times out, the thread
keeps running and will update _auth_state whenever it eventually
finishes (success or failure), same as today, just without wedging
the whole subprocess unresponsive in the meantime.
Deliberately uses its own private, function-local result queue rather
than the module-level _login_result_queue that _handle_authenticate and
_handle_complete_mfa share -- those two are legitimately two halves of
one explicit, MFA-capable login flow and must share a queue for the MFA
handoff to work, but this is a plain background tokenstore resume with
no MFA involved. Sharing the queue here would let this thread's stale
result (arriving after the 10s timeout below) be dequeued by an
unrelated, later authenticate/complete_mfa call instead of that call's
own fresh result."""
global _client, _auth_state
email = os.environ.get("GARMIN_EMAIL")
password = os.environ.get("GARMIN_PASSWORD")
if not email or not password:
return
_client = Garmin(email, password)
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
def _do_login():
try:
_client.login(tokenstore=TOKENSTORE)
result_queue.put(("success", None))
except Exception as exc:
result_queue.put(("error", str(exc)))
threading.Thread(target=_do_login, daemon=True).start()
try:
status, err = result_queue.get(timeout=10)
if status == "success":
_auth_state = "authenticated"
else:
_debug(f"startup tokenstore login failed: {err}")
_auth_state = "unauthenticated"
except queue.Empty:
_debug(
"startup tokenstore login hit the 10s timeout -- still running in the "
"background and will update auth state whenever it finishes"
)
_auth_state = "unauthenticated"
def _handle_authenticate(_params):
global _client, _auth_state
email = os.environ.get("GARMIN_EMAIL", "")
password = os.environ.get("GARMIN_PASSWORD", "")
if not email or not password:
return {
"status": "failed",
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
}
def _do_login():
_debug(f"background login thread starting _client.login(tokenstore={TOKENSTORE})")
try:
_client.login(tokenstore=TOKENSTORE)
_debug("_client.login() returned successfully")
_login_result_queue.put(("success", None))
except Exception as exc:
_debug(f"_client.login() raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
_login_result_queue.put(("error", str(exc)))
_client = Garmin(email, password)
_client.prompt_mfa = _prompt_mfa
threading.Thread(target=_do_login, daemon=True).start()
try:
status, err = _login_result_queue.get(timeout=10)
_debug(f"authenticate got result within 10s timeout: status={status}")
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "Authenticated successfully."}
return {"status": "failed", "message": f"Authentication failed: {err}"}
except queue.Empty:
_debug(
"authenticate hit the 10s timeout with no result yet -- reporting mfa_required, "
"but this does NOT necessarily mean prompt_mfa() was actually invoked; check "
"whether the 'REAL MFA challenge' debug line above appears to tell real MFA "
"apart from a merely slow login."
)
_auth_state = "mfa_pending"
return {
"status": "mfa_required",
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
}
def _handle_complete_mfa(params):
global _auth_state
if _auth_state != "mfa_pending":
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
code = params["code"]
_debug(f"complete_mfa received a code of length {len(code)}, pushing to mfa queue")
_mfa_input_queue.put(code)
try:
status, err = _login_result_queue.get(timeout=30)
_debug(f"complete_mfa got result: status={status} err={err}")
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
except queue.Empty:
_auth_state = "unauthenticated"
return {
"status": "failed",
"message": "Timed out waiting for authentication to complete. Call authenticate again.",
}
def _handle_call(params):
if _auth_state != "authenticated":
raise RuntimeError("Not authenticated. Call authenticate first.")
method = params["method"]
args = params.get("args") or {}
fn = getattr(_client, method)
return fn(**args)
_HANDLERS = {
"authenticate": _handle_authenticate,
"complete_mfa": _handle_complete_mfa,
"call": _handle_call,
}
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
return {"id": req.get("id"), "error": str(exc)}
def main():
_startup_login()
for line in sys.stdin:
line = line.strip()
if not line:
continue
req = json.loads(line)
resp = dispatch(req)
print(json.dumps(resp), flush=True)
if __name__ == "__main__":
main()

View File

@@ -3,8 +3,8 @@ package garmin
import "encoding/json" import "encoding/json"
// AuthStatus is the outcome of an authenticate()/complete_mfa() call. // AuthStatus is the outcome of an authenticate()/complete_mfa() call.
// mcp-garmin's tools return a plain human-readable string rather than a // wrapper.py returns a structured {"status", "message"} JSON object, which
// structured status, so the client pattern-matches known phrases into this. // the client maps directly into this rather than pattern-matching strings.
type AuthStatus int type AuthStatus int
const ( const (

View File

@@ -2,8 +2,9 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
export MCP_GARMIN_PYTHON="${MCP_GARMIN_PYTHON:-/Users/cvila/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/.venv/bin/python3}" # GARMIN_WRAPPER_PYTHON is optional (config.Load() defaults it to "python3"
export MCP_GARMIN_SERVER="${MCP_GARMIN_SERVER:-/Users/cvila/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/server.py}" # on PATH). Export it yourself only if you want a specific interpreter, e.g.
# a local venv with garminconnect installed.
export GENIUSRUN_DB_PATH="${GENIUSRUN_DB_PATH:-geniusrun.db}" export GENIUSRUN_DB_PATH="${GENIUSRUN_DB_PATH:-geniusrun.db}"
# OIDC login gate -- no safe default exists for a client secret or a # OIDC login gate -- no safe default exists for a client secret or a

View File

@@ -8,8 +8,15 @@ for that history) and remove it from here once a spec exists.
## Backlog ## Backlog
- (add ideas here) - add dedicated workouts in database (instead of hard linking them in activities)
- better UX for MFA management
- better UX for activities/workout download (progress bar, error management)
- better UX when backend is not available (instead of "TypeError failed to fetch")
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout)
- add icon to indicate if an activity has an associated workout
## Someday / maybe ## Someday / maybe
- (lower-priority or vaguer ideas here) - dedicated tab for past workouts (or find a way to show them in the "training plan" tab)
- training programs with phases (tapering, easier weeks, post-race recovery)
-

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
# Drop MCP for Garmin integration — direct Python wrapper design
Date: 2026-07-25
## Overview
`internal/garmin` currently talks to `mcp-garmin` (a separate repo) as an MCP client over stdio, spawning `server.py` as a subprocess and driving it through JSON-RPC tool calls (`authenticate`, `complete_mfa`, `get_activities`, `get_activity_splits`, `get_activity_details`, `get_workout_by_id`). MCP's actual value — letting an LLM dynamically discover and choose among tools — is unused here: geniusrun's Go backend calls a fixed, hardcoded set of operations in fixed code paths. The MCP protocol layer (session init, capability negotiation, JSON-RPC envelope, the `mcp-go` client dependency in Go, the `mcp` SDK dependency in Python) buys nothing for this use case.
Both `mcp-garmin` and geniusrun are projects the same person owns, so there's no third-party maintenance benefit to keeping `mcp-garmin` as a separate abstraction either. Given a plan to package the backend in a container, fewer runtime dependencies is a concrete win: dropping the MCP layer means one fewer Python package (`mcp[cli]`) and one fewer Go dependency (`github.com/mark3labs/mcp-go`), and folding the wrapper script into the geniusrun repo itself means the container image doesn't need to vendor/clone a second repo.
This replaces the MCP transport with a small custom Python subprocess wrapper around `garminconnect` directly, speaking a minimal newline-delimited JSON protocol over stdin/stdout. `mcp-garmin` is retired once this ships.
## Goals
- Remove the MCP protocol layer and its two SDK dependencies (`mcp-go` in Go, `mcp[cli]` in Python) entirely.
- Fold the Python wrapper into the geniusrun repo (`backend/garmin-wrapper/wrapper.py`) so the container image only needs a Python runtime + `pip install garminconnect` + this one file — no separate repo to fetch at build time.
- Preserve `internal/garmin.Client`'s existing interface and behavior exactly (lazy subprocess spawn, per-user token store, `UpdateCredentials` tear-down/respawn, stderr draining) so `internal/sync` and `internal/api` require zero changes.
- Replace today's fragile string pattern-matching of auth replies (`parseAuthResult`, documented in CLAUDE.md as a "learned the hard way" wart) with structured JSON status, now that both sides of the protocol are under our control.
- Make every method `python-garminconnect`'s `Garmin` class exposes reachable from Go without per-method Python boilerplate, anticipating future AI-driven features (recovery/HRV context for training-plan recommendations) that may need data beyond what's synced today.
## Non-goals
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `garminFor` caching in `api.Server`.
## Wire protocol
Newline-delimited JSON, one object per line each direction, over the subprocess's stdin/stdout:
```
Go -> Python: {"id": 1, "cmd": "authenticate"}
Python -> Go: {"id": 1, "result": {"status": "success", "message": "Authenticated successfully."}}
Go -> Python: {"id": 2, "cmd": "complete_mfa", "params": {"code": "123456"}}
Python -> Go: {"id": 2, "result": {"status": "mfa_required", "message": "..."}}
Go -> Python: {"id": 3, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {"start_date": "2026-07-01", "end_date": "2026-07-25"}}}
Python -> Go: {"id": 3, "result": [...]}
Go -> Python: {"id": 4, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "123"}}}
Python -> Go: {"id": 4, "error": "Error fetching activity splits: <exception message>"}
```
Three command kinds:
- **`authenticate`** / **`complete_mfa`** — special-cased in Python: same background-thread login + MFA-prompt race + 10-second timeout detection logic `mcp-garmin`'s `server.py` already has (this is genuine custom control flow, not a passthrough to a single `garminconnect` method), but now return a **structured** `{"status": "success" | "mfa_required" | "failed", "message": "..."}` object instead of a string Go has to pattern-match.
- **`call`** — a fully generic dispatcher: `getattr(garmin_client, params["method"])(**params["args"])`, JSON-serializing whatever it returns. This is what makes every `garminconnect` method reachable without new Python code per method — including future needs (sleep, HRV, stress, body battery) and, by the same generic mechanism, mutating/destructive methods (`upload_activity`, `delete_activity`, etc.). No allowlist is enforced in Python; correctness of what Go chooses to call is Go's responsibility, same as calling any other library method directly would be.
`id` lets Go assert a response matches its in-flight request — calls are already serialized by the existing `sync.Mutex` in `mcpClient`/its replacement, so this is a desync-detection assertion, not a concurrency mechanism.
Go's line reader uses a raised buffer size (well above the default 64KB `bufio.Scanner` limit) since `get_activity_details` responses (per-second telemetry for a full run) can be several MB.
## Components
### Python wrapper — `backend/garmin-wrapper/wrapper.py`
- Single file. Same shape as today's `server.py` minus all MCP scaffolding (`FastMCP`, `@mcp.tool()` decorators, `mcp.run()`).
- Embedded into the Go binary at compile time via `//go:embed wrapper.py` (a `[]byte`/`string` constant in `internal/garmin`), rather than referenced by a configurable filesystem path. At process start, `ensureStarted` writes the embedded source to a temp file (`os.CreateTemp`) once per process lifetime and execs the configured Python interpreter against that path. This removes any notion of a "server script location" from configuration entirely — whatever `wrapper.py` shipped inside the running binary is exactly what gets executed, so there's no drift between binary version and script version, and the container image needs no separate `COPY` step for it.
- Reads `GARMIN_EMAIL` / `GARMIN_PASSWORD` / `GARMIN_TOKENSTORE` from the environment at process start — unchanged from today.
- Holds one module-level `Garmin` client instance and an auth-state variable (`unauthenticated` / `mfa_pending` / `authenticated`).
- Main loop: `for line in sys.stdin`, parse `{"id", "cmd", "params"}`, dispatch to the `authenticate` / `complete_mfa` / `call` handler, write exactly one JSON line to stdout per response, flush immediately. Debug/diagnostic output continues to go to stderr only (stdout is reserved for the response protocol) — Go already drains stderr today and keeps doing so unchanged.
- Any exception raised by a `call` dispatch (bad method name via `AttributeError`, `GarminConnectAuthenticationError`, network errors, anything else) is caught at the top-level dispatch loop and turned into `{"id": N, "error": "<message>"}` — it must never let a raw Python traceback hit stdout, since that would corrupt the line-based framing.
- Dependency: `garminconnect` only. Drop `mcp[cli]`.
### Go client — `internal/garmin/client.go`
- Same `mu sync.Mutex` + lazy `ensureStarted`/`started` pattern as today. The mcp-go stdio transport is replaced with `os/exec.Cmd` plus a `bufio.Scanner` (raised buffer) over `Stdout` and a `json.Encoder` over `Stdin`.
- `UpdateCredentials` still tears down (kill subprocess) and lets the next call respawn fresh, unchanged.
- `Close` still terminates the subprocess.
- Each `Client` interface method builds the appropriate request (`authenticate`/`complete_mfa` directly, or a `call` envelope naming the right `garminconnect` method for `GetActivities``get_activities_by_date`, `GetActivitySplits``get_activity_splits`, `GetActivityDetails``get_activity_details`, `GetWorkoutByID``get_workout_by_id`), writes+flushes it, reads one response line, and unmarshals `result` into the existing typed structs (`Activity`, `ActivitySplits`, `ActivityDetails`, `Workout` — unchanged; the wrapper still forwards `garminconnect`'s own JSON-compatible dicts/lists as-is).
- `AuthResult`/`AuthStatus` types are unchanged, but `parseAuthResult`'s string-matching (`client.go:189-198`) is deleted entirely — the wrapper's `status` field maps directly to `AuthSuccess` / `AuthMFARequired` / `AuthFailed`.
- The interface itself gains no new methods yet (YAGNI) — the generic `call` command exists at the wire-protocol level so a future typed method (e.g. for sleep/HRV data, if the AI training-plan features end up needing it) is a small, self-contained addition when actually needed, not a protocol change.
## Error handling
- Python: every `call` dispatch exception is caught and returned as `{"error": "<message>"}`. `authenticate`/`complete_mfa` never raise past their own handlers (matches today).
- Go: a non-nil `error` field in a response becomes `fmt.Errorf("call %s: %s", method, resp.Error)` (or equivalent for `authenticate`/`complete_mfa`), the same shape `internal/sync` already receives and wraps further today (`fmt.Errorf("get_activities(...): %w", err)`, etc.) — confirmed no code downstream branches on specific error text (e.g. no "Authentication error" string-matching in `internal/sync`/`internal/api`), so no structured error-type field is needed beyond the plain message.
- Process-level failures (subprocess exited, pipe closed, malformed JSON line) surface as a Go error from the read/write call. `started` is not auto-reset on such failures — matching today's behavior where a crashed subprocess just errors on next use until `UpdateCredentials` forces a respawn.
## Process lifecycle & testing
- Lifecycle unchanged: lazy-start on first call, one persistent subprocess per user (kept warm across calls to preserve the Garmin session/tokenstore), torn down and respawned only by `UpdateCredentials`.
- `internal/garmin/mock.Client` is untouched — it fakes the `Client` interface, not the transport, so `internal/sync`/`internal/api` tests need no changes.
- New/updated tests in `internal/garmin`:
- Framing round-trip: a request encodes correctly, a response line decodes correctly, and a large (multi-MB) `get_activity_details`-shaped payload doesn't overflow the scanner buffer.
- Structured auth status mapping (replacing today's `parseAuthResult` string-matching tests).
- Error propagation: a `{"error": ...}` response becomes a Go error with the expected message.
- These can run against a stub script (not the real `wrapper.py`/`garminconnect`) that speaks the protocol, mirroring how `client_test.go` already avoids hitting real Garmin.
## Migration & container
- Full replacement, no compatibility shim, consistent with this project's "no migration history" stance elsewhere: `mcp-go` import and all MCP-specific code in `client.go` are deleted in the same change, not kept behind a flag.
- `backend/start.sh`'s `MCP_GARMIN_PYTHON` / `MCP_GARMIN_SERVER` env vars are retired, not just renamed. Only one config knob remains: `GARMIN_WRAPPER_PYTHON`, and it becomes **optional** (default `"python3"`, resolved via `PATH`) rather than required — matching how `config.Load()` already treats genuinely-optional plumbing (e.g. `GENIUSRUN_OIDC_REQUIRED_ROLE`). There is no longer a script-location env var at all, since `wrapper.py` is embedded in the binary (see Components above). Local dev can still point `GARMIN_WRAPPER_PYTHON` at a venv interpreter if desired; it's just no longer required to.
- Container image only needs: a Python runtime with `garminconnect` installed (`pip install garminconnect` in the image, no venv necessary since the container itself is the isolation boundary) — no second repo to clone/vendor, no `mcp` SDK on either side, and no separate wrapper script file to `COPY` in.
- The standalone `mcp-garmin` repo is retired (archived) once this ships; its test suite (`tests/test_server.py`) either gets ported to test `wrapper.py` directly inside geniusrun, or is dropped in favor of the new `internal/garmin` tests above, whichever ends up covering the same ground with less duplication — a call to make during implementation, not part of this spec.
## Rollout notes
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
- Existing per-user token stores under `GarminTokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).