// Package garmin wraps the mcp-garmin MCP server as a narrow Go client // interface, so the rest of geniusrun 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 geniusrun 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) // 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) // 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) // GetWorkoutByID fetches a structured workout's step-by-step plan. GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) // 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: "geniusrund", 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 } // 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 } } // 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) } 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) { 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 } var envelope struct { ActivityID int64 `json:"activityId"` Laps []json.RawMessage `json:"lapDTOs"` } if err := json.Unmarshal([]byte(msg), &envelope); err != nil { return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err) } 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) } 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) } details.Raw = json.RawMessage(msg) return details, nil } 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) } workout.Raw = json.RawMessage(msg) 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)" }