2026-07-17 18:33:06 +02:00
|
|
|
// Package garmin wraps the mcp-garmin MCP server as a narrow Go client
|
|
|
|
|
// interface, so the rest of smartrun never deals with MCP/JSON-RPC directly.
|
|
|
|
|
package garmin
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
|
|
|
|
|
mcpclient "github.com/mark3labs/mcp-go/client"
|
|
|
|
|
"github.com/mark3labs/mcp-go/client/transport"
|
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Client is the interface the rest of smartrun depends on. The real
|
|
|
|
|
// implementation drives mcp-garmin over stdio; internal/garmin/mock provides
|
|
|
|
|
// a fake for tests and frontend-only development.
|
|
|
|
|
type Client interface {
|
|
|
|
|
// Authenticate triggers Garmin login using credentials the subprocess
|
|
|
|
|
// was started with. Spawns the subprocess on first call.
|
|
|
|
|
Authenticate(ctx context.Context) (AuthResult, error)
|
|
|
|
|
// CompleteMFA submits an MFA code for a login started by Authenticate.
|
|
|
|
|
CompleteMFA(ctx context.Context, code string) (AuthResult, error)
|
2026-07-17 18:57:54 +02:00
|
|
|
// UpdateCredentials replaces the Garmin email/password used to spawn
|
|
|
|
|
// the subprocess, and terminates any already-running subprocess (which
|
|
|
|
|
// would otherwise still be authenticated under the old credentials).
|
|
|
|
|
// The next call that needs the subprocess spawns a fresh one with the
|
|
|
|
|
// new credentials.
|
|
|
|
|
UpdateCredentials(email, password string)
|
2026-07-17 18:33:06 +02:00
|
|
|
// GetActivities lists activities between start and end (YYYY-MM-DD).
|
|
|
|
|
GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error)
|
|
|
|
|
// GetActivitySplits fetches lap/split summaries for one activity.
|
|
|
|
|
GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error)
|
|
|
|
|
// GetActivityDetails fetches raw per-second telemetry for one activity.
|
|
|
|
|
GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error)
|
2026-07-19 11:55:13 +02:00
|
|
|
// GetWorkoutByID fetches a structured workout's step-by-step plan.
|
|
|
|
|
GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error)
|
2026-07-17 18:33:06 +02:00
|
|
|
// Close terminates the subprocess, if running.
|
|
|
|
|
Close() error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Config configures how the mcp-garmin subprocess is spawned.
|
|
|
|
|
type Config struct {
|
|
|
|
|
PythonPath string // path to mcp-garmin's venv python executable
|
|
|
|
|
ServerPath string // path to mcp-garmin's server.py
|
|
|
|
|
GarminEmail string
|
|
|
|
|
GarminPassword string
|
|
|
|
|
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so mcp-garmin
|
|
|
|
|
// persists/resumes a Garmin session there instead of the default ~/.garth.
|
|
|
|
|
TokenStorePath string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// mcpClient is the real Client implementation, backed by an mcp-garmin
|
|
|
|
|
// subprocess spoken to over stdio MCP.
|
|
|
|
|
type mcpClient struct {
|
|
|
|
|
cfg Config
|
|
|
|
|
|
|
|
|
|
mu sync.Mutex // serializes calls; mcp-garmin holds a single shared Garmin client
|
|
|
|
|
inner *mcpclient.Client
|
|
|
|
|
started bool
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewClient builds a Client. The subprocess is not spawned until the first
|
|
|
|
|
// call that needs it (Authenticate, or any data call once authenticated).
|
|
|
|
|
func NewClient(cfg Config) Client {
|
|
|
|
|
return &mcpClient{cfg: cfg}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *mcpClient) ensureStarted(ctx context.Context) error {
|
|
|
|
|
if c.started {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
env := []string{
|
|
|
|
|
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
|
|
|
|
|
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
|
|
|
|
|
"PYTHONUNBUFFERED=1",
|
|
|
|
|
}
|
|
|
|
|
if c.cfg.TokenStorePath != "" {
|
|
|
|
|
env = append(env, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inner, err := mcpclient.NewStdioMCPClient(c.cfg.PythonPath, env, c.cfg.ServerPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("spawn mcp-garmin subprocess: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if stdio, ok := inner.GetTransport().(*transport.Stdio); ok {
|
|
|
|
|
go drainStderr(stdio)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
initReq := mcp.InitializeRequest{}
|
|
|
|
|
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
|
|
|
|
initReq.Params.ClientInfo = mcp.Implementation{Name: "smartrund", Version: "0.0.1"}
|
|
|
|
|
if _, err := inner.Initialize(ctx, initReq); err != nil {
|
|
|
|
|
inner.Close()
|
|
|
|
|
return fmt.Errorf("mcp initialize handshake: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.inner = inner
|
|
|
|
|
c.started = true
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:57:54 +02:00
|
|
|
// UpdateCredentials implements Client.
|
|
|
|
|
func (c *mcpClient) UpdateCredentials(email, password string) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
c.cfg.GarminEmail = email
|
|
|
|
|
c.cfg.GarminPassword = password
|
|
|
|
|
|
|
|
|
|
if c.started {
|
|
|
|
|
if c.inner != nil {
|
|
|
|
|
c.inner.Close()
|
|
|
|
|
}
|
|
|
|
|
c.inner = nil
|
|
|
|
|
c.started = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
// drainStderr forwards the subprocess's debug/log output so it isn't
|
|
|
|
|
// silently dropped (mcp-garmin logs auth/rate-limit diagnostics there).
|
|
|
|
|
func drainStderr(stdio *transport.Stdio) {
|
|
|
|
|
buf := make([]byte, 4096)
|
|
|
|
|
for {
|
|
|
|
|
n, err := stdio.Stderr().Read(buf)
|
|
|
|
|
if n > 0 {
|
|
|
|
|
fmt.Print(string(buf[:n]))
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (string, error) {
|
|
|
|
|
req := mcp.CallToolRequest{}
|
|
|
|
|
req.Params.Name = name
|
|
|
|
|
req.Params.Arguments = args
|
|
|
|
|
|
|
|
|
|
res, err := c.inner.CallTool(ctx, req)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", fmt.Errorf("call tool %s: %w", name, err)
|
|
|
|
|
}
|
|
|
|
|
if res.IsError {
|
|
|
|
|
return "", fmt.Errorf("tool %s returned an error result", name)
|
|
|
|
|
}
|
|
|
|
|
var out strings.Builder
|
|
|
|
|
for _, content := range res.Content {
|
|
|
|
|
if tc, ok := content.(mcp.TextContent); ok {
|
|
|
|
|
out.WriteString(tc.Text)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out.String(), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *mcpClient) Authenticate(ctx context.Context) (AuthResult, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if err := c.ensureStarted(ctx); 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
|
|
|
|
|
}
|
|
|
|
|
msg, err := c.callTool(ctx, "get_activities", map[string]any{
|
|
|
|
|
"start_date": startDate,
|
|
|
|
|
"end_date": endDate,
|
|
|
|
|
"limit": limit,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var rawActivities []json.RawMessage
|
|
|
|
|
if err := json.Unmarshal([]byte(msg), &rawActivities); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("get_activities did not return a JSON array (%q): %w", truncate(msg, 200), err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activities := make([]Activity, 0, len(rawActivities))
|
|
|
|
|
for _, raw := range rawActivities {
|
|
|
|
|
var a Activity
|
|
|
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse activity: %w", err)
|
|
|
|
|
}
|
|
|
|
|
a.Raw = raw
|
|
|
|
|
activities = append(activities, a)
|
|
|
|
|
}
|
|
|
|
|
return activities, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
|
|
|
|
return ActivitySplits{}, err
|
|
|
|
|
}
|
|
|
|
|
msg, err := c.callTool(ctx, "get_activity_splits", map[string]any{
|
|
|
|
|
"activity_id": strconv.FormatInt(activityID, 10),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ActivitySplits{}, err
|
|
|
|
|
}
|
|
|
|
|
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
var envelope struct {
|
|
|
|
|
ActivityID int64 `json:"activityId"`
|
|
|
|
|
Laps []json.RawMessage `json:"lapDTOs"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal([]byte(msg), &envelope); err != nil {
|
2026-07-17 18:33:06 +02:00
|
|
|
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err)
|
|
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
|
|
|
|
|
splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))}
|
|
|
|
|
for _, raw := range envelope.Laps {
|
|
|
|
|
var l Lap
|
|
|
|
|
if err := json.Unmarshal(raw, &l); err != nil {
|
|
|
|
|
return ActivitySplits{}, fmt.Errorf("parse lap: %w", err)
|
|
|
|
|
}
|
|
|
|
|
l.Raw = raw
|
|
|
|
|
splits.Laps = append(splits.Laps, l)
|
|
|
|
|
}
|
2026-07-17 18:33:06 +02:00
|
|
|
return splits, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
|
|
|
|
return ActivityDetails{}, err
|
|
|
|
|
}
|
|
|
|
|
msg, err := c.callTool(ctx, "get_activity_details", map[string]any{
|
|
|
|
|
"activity_id": strconv.FormatInt(activityID, 10),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ActivityDetails{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var details ActivityDetails
|
|
|
|
|
if err := json.Unmarshal([]byte(msg), &details); err != nil {
|
|
|
|
|
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err)
|
|
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
details.Raw = json.RawMessage(msg)
|
2026-07-17 18:33:06 +02:00
|
|
|
return details, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 11:55:13 +02:00
|
|
|
func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if err := c.ensureStarted(ctx); err != nil {
|
|
|
|
|
return Workout{}, err
|
|
|
|
|
}
|
|
|
|
|
msg, err := c.callTool(ctx, "get_workout_by_id", map[string]any{
|
|
|
|
|
"workout_id": strconv.FormatInt(workoutID, 10),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return Workout{}, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var workout Workout
|
|
|
|
|
if err := json.Unmarshal([]byte(msg), &workout); err != nil {
|
|
|
|
|
return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err)
|
|
|
|
|
}
|
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
|
|
|
workout.Raw = json.RawMessage(msg)
|
2026-07-19 11:55:13 +02:00
|
|
|
return workout, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 18:33:06 +02:00
|
|
|
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)"
|
|
|
|
|
}
|