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}