244 lines
7.3 KiB
Go
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)"
|
||
|
|
}
|