track.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5// Package habit provides MCP tools for Lunatask habit operations.
  6package habit
  7
  8import (
  9	"context"
 10
 11	"git.secluded.site/go-lunatask"
 12	"git.secluded.site/lune/internal/dateutil"
 13	"git.secluded.site/lune/internal/mcp/shared"
 14	"github.com/modelcontextprotocol/go-sdk/mcp"
 15)
 16
 17// TrackToolName is the name of the track habit tool.
 18const TrackToolName = "track_habit"
 19
 20// TrackToolDescription describes the track habit tool for LLMs.
 21const TrackToolDescription = `Record that a habit was performed.
 22
 23Use lunatask://habits resource to discover valid habit IDs and config keys.
 24Tracks for today by default. Idempotent—re-tracking the same date has no effect.`
 25
 26// TrackToolAnnotations returns hints about tool behavior.
 27func TrackToolAnnotations() *mcp.ToolAnnotations {
 28	return &mcp.ToolAnnotations{
 29		IdempotentHint: true,
 30		OpenWorldHint:  ptr(true),
 31		Title:          "Track habit",
 32	}
 33}
 34
 35func ptr[T any](v T) *T { return &v }
 36
 37// TrackInput is the input schema for tracking a habit.
 38type TrackInput struct {
 39	HabitID     string  `json:"habit_id"               jsonschema:"Habit UUID, lunatask:// deep link, or config key"`
 40	PerformedOn *string `json:"performed_on,omitempty" jsonschema:"Date performed (strtotime syntax, default: today)"`
 41}
 42
 43// TrackOutput is the output schema for tracking a habit.
 44type TrackOutput struct {
 45	Success     bool   `json:"success"`
 46	HabitID     string `json:"habit_id"`
 47	PerformedOn string `json:"performed_on"`
 48}
 49
 50// Handler handles habit-related MCP tool requests.
 51type Handler struct {
 52	client *lunatask.Client
 53	habits []shared.HabitProvider
 54}
 55
 56// NewHandler creates a new habit handler.
 57func NewHandler(accessToken string, habits []shared.HabitProvider) *Handler {
 58	return &Handler{
 59		client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
 60		habits: habits,
 61	}
 62}
 63
 64// HandleTrack records a habit activity.
 65func (h *Handler) HandleTrack(
 66	ctx context.Context,
 67	_ *mcp.CallToolRequest,
 68	input TrackInput,
 69) (*mcp.CallToolResult, TrackOutput, error) {
 70	habitID := h.resolveHabitRef(input.HabitID)
 71	if habitID == "" {
 72		return shared.ErrorResult("unknown habit: " + input.HabitID), TrackOutput{}, nil
 73	}
 74
 75	dateStr := ""
 76	if input.PerformedOn != nil {
 77		dateStr = *input.PerformedOn
 78	}
 79
 80	performedOn, err := dateutil.Parse(dateStr)
 81	if err != nil {
 82		return shared.ErrorResult(err.Error()), TrackOutput{}, nil
 83	}
 84
 85	req := &lunatask.TrackHabitActivityRequest{
 86		PerformedOn: performedOn,
 87	}
 88
 89	_, err = h.client.TrackHabitActivity(ctx, habitID, req)
 90	if err != nil {
 91		return shared.ErrorResult(err.Error()), TrackOutput{}, nil
 92	}
 93
 94	return nil, TrackOutput{
 95		Success:     true,
 96		HabitID:     habitID,
 97		PerformedOn: performedOn.Format("2006-01-02"),
 98	}, nil
 99}
100
101// resolveHabitRef resolves a habit reference to a UUID.
102// Accepts config key, UUID, or deep link.
103func (h *Handler) resolveHabitRef(input string) string {
104	// Try UUID or deep link first
105	if _, id, err := lunatask.ParseReference(input); err == nil {
106		return id
107	}
108
109	// Try config key lookup
110	for _, habit := range h.habits {
111		if habit.Key == input {
112			return habit.ID
113		}
114	}
115
116	return ""
117}