Files
geniusrun/backend/cmd/mcpspike/main.go
Christophe Vila f689f74ae0 Initial commit: smartrun MVP
Garmin run classification and progression tracker. Go backend (MCP
client to mcp-garmin, SQLite store, deterministic rule engine, REST
API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 18:33:06 +02:00

244 lines
7.3 KiB
Go

// 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)"
}