Compare commits
16 Commits
0ff6793854
...
85577c9d26
| Author | SHA1 | Date | |
|---|---|---|---|
| 85577c9d26 | |||
| 487df1f9b9 | |||
| e422f86925 | |||
| 136567d964 | |||
| 2fad83fe9a | |||
| f122f28206 | |||
| 1b9088bd03 | |||
| 284ec3142d | |||
| 8c94c94c3f | |||
| 9cbcf62aed | |||
| df197f45fd | |||
| 6c161f2206 | |||
| 7985000440 | |||
| de48c44858 | |||
| 0936d98161 | |||
| 961a7d8aca |
@@ -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.
|
||||||
|
|||||||
27
CLAUDE.md
27
CLAUDE.md
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)"
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
// Close implements Client.
|
||||||
if c.inner != nil {
|
func (c *subprocessClient) Close() error {
|
||||||
c.inner.Close()
|
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)"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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},
|
func fakeResult(v any) wireResponsePayload {
|
||||||
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed},
|
b, err := json.Marshal(v)
|
||||||
{"Authentication failed after MFA: bad code", AuthFailed},
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
return wireResponsePayload{result: b}
|
||||||
got := parseAuthResult(c.msg)
|
}
|
||||||
if got.Status != c.want {
|
|
||||||
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
4
backend/internal/garmin/pyscript/.gitignore
vendored
Normal file
4
backend/internal/garmin/pyscript/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
*.pyc
|
||||||
18
backend/internal/garmin/pyscript/pyproject.toml
Normal file
18
backend/internal/garmin/pyscript/pyproject.toml
Normal 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 = ["."]
|
||||||
0
backend/internal/garmin/pyscript/tests/__init__.py
Normal file
0
backend/internal/garmin/pyscript/tests/__init__.py
Normal file
208
backend/internal/garmin/pyscript/tests/test_wrapper.py
Normal file
208
backend/internal/garmin/pyscript/tests/test_wrapper.py
Normal 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"
|
||||||
202
backend/internal/garmin/pyscript/wrapper.py
Normal file
202
backend/internal/garmin/pyscript/wrapper.py
Normal 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()
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
-
|
||||||
1707
docs/superpowers/plans/2026-07-25-garmin-direct-wrapper-plan.md
Normal file
1707
docs/superpowers/plans/2026-07-25-garmin-direct-wrapper-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||||
Reference in New Issue
Block a user