handler.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5// Package tasks provides MCP resources for filtered task lists.
  6package tasks
  7
  8import (
  9	"context"
 10	"encoding/json"
 11	"errors"
 12	"fmt"
 13	"strings"
 14	"time"
 15
 16	"git.secluded.site/go-lunatask"
 17	"git.secluded.site/lune/internal/mcp/shared"
 18	"github.com/modelcontextprotocol/go-sdk/mcp"
 19)
 20
 21// ErrUnknownArea indicates the area reference could not be resolved.
 22var ErrUnknownArea = errors.New("unknown area")
 23
 24// Resource URIs for task list filters.
 25const (
 26	ResourceURIAll               = "lunatask://tasks/all"
 27	ResourceURIToday             = "lunatask://tasks/today"
 28	ResourceURIOverdue           = "lunatask://tasks/overdue"
 29	ResourceURINext7Days         = "lunatask://tasks/next-7-days"
 30	ResourceURIHighPriority      = "lunatask://tasks/high-priority"
 31	ResourceURINow               = "lunatask://tasks/now"
 32	ResourceURIRecentCompletions = "lunatask://tasks/recent-completions"
 33)
 34
 35// Area-scoped resource templates.
 36const (
 37	AreaTasksTemplate          = "lunatask://area/{area}/tasks"
 38	AreaTodayTemplate          = "lunatask://area/{area}/today"
 39	AreaOverdueTemplate        = "lunatask://area/{area}/overdue"
 40	AreaNext7DaysTemplate      = "lunatask://area/{area}/next-7-days"
 41	AreaHighPriorityTemplate   = "lunatask://area/{area}/high-priority"
 42	AreaNowTemplate            = "lunatask://area/{area}/now"
 43	AreaRecentCompletionsTempl = "lunatask://area/{area}/recent-completions"
 44)
 45
 46// Resource descriptions.
 47const (
 48	AllDescription = `All incomplete tasks. EXPENSIVE - prefer filtered resources.`
 49
 50	TodayDescription = `Tasks scheduled for today.`
 51
 52	OverdueDescription = `Overdue tasks (scheduled before today, incomplete).`
 53
 54	Next7DaysDescription = `Tasks scheduled within the next 7 days.`
 55
 56	HighPriorityDescription = `Tasks with highest priority.`
 57
 58	NowDescription = `Tasks needing immediate attention: started, highest priority, must-do, or urgent+important.`
 59
 60	RecentCompletionsDescription = `Tasks completed in the last 72 hours.`
 61
 62	AreaTasksDescription = `Incomplete tasks in area. {area} accepts config key or UUID.`
 63
 64	AreaFilteredDescription = `Filtered tasks in area. {area} accepts config key or UUID.`
 65)
 66
 67// Handler handles task list resource requests.
 68type Handler struct {
 69	client *lunatask.Client
 70	areas  []shared.AreaProvider
 71}
 72
 73// NewHandler creates a new tasks resource handler.
 74func NewHandler(accessToken string, areas []shared.AreaProvider) *Handler {
 75	return &Handler{
 76		client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
 77		areas:  areas,
 78	}
 79}
 80
 81// taskSummary represents a task in list output.
 82type taskSummary struct {
 83	DeepLink    string  `json:"deep_link"`
 84	Status      *string `json:"status,omitempty"`
 85	Priority    *int    `json:"priority,omitempty"`
 86	ScheduledOn *string `json:"scheduled_on,omitempty"`
 87	CompletedAt *string `json:"completed_at,omitempty"`
 88	CreatedAt   string  `json:"created_at"`
 89	AreaID      *string `json:"area_id,omitempty"`
 90	GoalID      *string `json:"goal_id,omitempty"`
 91	Important   *bool   `json:"important,omitempty"`
 92	Urgent      *bool   `json:"urgent,omitempty"`
 93}
 94
 95// FilterType identifies which filter to apply.
 96type FilterType int
 97
 98// Filter type constants.
 99const (
100	TypeAll FilterType = iota
101	TypeToday
102	TypeOverdue
103	TypeNext7Days
104	TypeHighPriority
105	TypeNow
106	TypeRecentCompletions
107)
108
109// HandleReadAll handles the all tasks resource.
110func (h *Handler) HandleReadAll(
111	ctx context.Context,
112	req *mcp.ReadResourceRequest,
113) (*mcp.ReadResourceResult, error) {
114	return h.handleFiltered(ctx, req, TypeAll, "")
115}
116
117// HandleReadToday handles the today tasks resource.
118func (h *Handler) HandleReadToday(
119	ctx context.Context,
120	req *mcp.ReadResourceRequest,
121) (*mcp.ReadResourceResult, error) {
122	return h.handleFiltered(ctx, req, TypeToday, "")
123}
124
125// HandleReadOverdue handles the overdue tasks resource.
126func (h *Handler) HandleReadOverdue(
127	ctx context.Context,
128	req *mcp.ReadResourceRequest,
129) (*mcp.ReadResourceResult, error) {
130	return h.handleFiltered(ctx, req, TypeOverdue, "")
131}
132
133// HandleReadNext7Days handles the next 7 days tasks resource.
134func (h *Handler) HandleReadNext7Days(
135	ctx context.Context,
136	req *mcp.ReadResourceRequest,
137) (*mcp.ReadResourceResult, error) {
138	return h.handleFiltered(ctx, req, TypeNext7Days, "")
139}
140
141// HandleReadHighPriority handles the high priority tasks resource.
142func (h *Handler) HandleReadHighPriority(
143	ctx context.Context,
144	req *mcp.ReadResourceRequest,
145) (*mcp.ReadResourceResult, error) {
146	return h.handleFiltered(ctx, req, TypeHighPriority, "")
147}
148
149// HandleReadNow handles the now tasks resource.
150func (h *Handler) HandleReadNow(
151	ctx context.Context,
152	req *mcp.ReadResourceRequest,
153) (*mcp.ReadResourceResult, error) {
154	return h.handleFiltered(ctx, req, TypeNow, "")
155}
156
157// HandleReadRecentCompletions handles the recent completions resource.
158func (h *Handler) HandleReadRecentCompletions(
159	ctx context.Context,
160	req *mcp.ReadResourceRequest,
161) (*mcp.ReadResourceResult, error) {
162	return h.handleFiltered(ctx, req, TypeRecentCompletions, "")
163}
164
165// HandleReadAreaTasks handles area-scoped task resources.
166func (h *Handler) HandleReadAreaTasks(
167	ctx context.Context,
168	req *mcp.ReadResourceRequest,
169) (*mcp.ReadResourceResult, error) {
170	areaRef, filterType := parseAreaURI(req.Params.URI)
171	if areaRef == "" {
172		return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI))
173	}
174
175	areaID, err := h.resolveAreaRef(areaRef)
176	if err != nil {
177		return nil, fmt.Errorf("invalid area %q: %w", areaRef, mcp.ResourceNotFoundError(req.Params.URI))
178	}
179
180	return h.handleFiltered(ctx, req, filterType, areaID)
181}
182
183// resolveAreaRef resolves an area reference to a UUID.
184// Accepts config key or UUID.
185func (h *Handler) resolveAreaRef(input string) (string, error) {
186	// Try UUID or deep link first
187	if _, id, err := lunatask.ParseReference(input); err == nil {
188		return id, nil
189	}
190
191	// Try config key lookup
192	for _, area := range h.areas {
193		if area.Key == input {
194			return area.ID, nil
195		}
196	}
197
198	return "", fmt.Errorf("%w: %s", ErrUnknownArea, input)
199}
200
201func (h *Handler) handleFiltered(
202	ctx context.Context,
203	req *mcp.ReadResourceRequest,
204	filterType FilterType,
205	areaID string,
206) (*mcp.ReadResourceResult, error) {
207	tasks, err := h.client.ListTasks(ctx, nil)
208	if err != nil {
209		return nil, fmt.Errorf("fetching tasks: %w", err)
210	}
211
212	// Apply area filter first if specified
213	if areaID != "" {
214		tasks = FilterByArea(tasks, areaID)
215	}
216
217	// Apply semantic filter
218	tasks = applyFilter(tasks, filterType)
219
220	summaries := buildSummaries(tasks)
221
222	data, err := json.MarshalIndent(summaries, "", "  ")
223	if err != nil {
224		return nil, fmt.Errorf("marshaling tasks: %w", err)
225	}
226
227	return &mcp.ReadResourceResult{
228		Contents: []*mcp.ResourceContents{{
229			URI:      req.Params.URI,
230			MIMEType: "application/json",
231			Text:     string(data),
232		}},
233	}, nil
234}
235
236func applyFilter(tasks []lunatask.Task, filterType FilterType) []lunatask.Task {
237	switch filterType {
238	case TypeAll:
239		return filterIncomplete(tasks)
240	case TypeToday:
241		return FilterToday(tasks)
242	case TypeOverdue:
243		return FilterOverdue(tasks)
244	case TypeNext7Days:
245		return FilterNext7Days(tasks)
246	case TypeHighPriority:
247		return FilterHighPriority(tasks)
248	case TypeNow:
249		return FilterNow(tasks)
250	case TypeRecentCompletions:
251		return FilterRecentCompletions(tasks)
252	default:
253		return filterIncomplete(tasks)
254	}
255}
256
257func filterIncomplete(tasks []lunatask.Task) []lunatask.Task {
258	result := make([]lunatask.Task, 0)
259
260	for _, task := range tasks {
261		if !isCompleted(&task) {
262			result = append(result, task)
263		}
264	}
265
266	return result
267}
268
269func buildSummaries(tasks []lunatask.Task) []taskSummary {
270	summaries := make([]taskSummary, 0, len(tasks))
271
272	for _, task := range tasks {
273		summary := taskSummary{
274			CreatedAt: task.CreatedAt.Format(time.RFC3339),
275			AreaID:    task.AreaID,
276			GoalID:    task.GoalID,
277		}
278
279		summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
280
281		if task.Status != nil {
282			s := string(*task.Status)
283			summary.Status = &s
284		}
285
286		if task.Priority != nil {
287			p := int(*task.Priority)
288			summary.Priority = &p
289		}
290
291		if task.ScheduledOn != nil {
292			s := task.ScheduledOn.Format("2006-01-02")
293			summary.ScheduledOn = &s
294		}
295
296		if task.CompletedAt != nil {
297			s := task.CompletedAt.Format(time.RFC3339)
298			summary.CompletedAt = &s
299		}
300
301		if task.Eisenhower != nil {
302			important := task.Eisenhower.IsImportant()
303			urgent := task.Eisenhower.IsUrgent()
304			summary.Important = &important
305			summary.Urgent = &urgent
306		}
307
308		summaries = append(summaries, summary)
309	}
310
311	return summaries
312}
313
314// parseAreaURI extracts area_id and filter type from area-scoped URIs.
315// Examples:
316//   - lunatask://area/uuid/tasks -> uuid, FilterAll
317//   - lunatask://area/uuid/today -> uuid, FilterToday
318func parseAreaURI(uri string) (string, FilterType) {
319	const (
320		prefix    = "lunatask://area/"
321		maxParts  = 2
322		minLength = 1
323	)
324
325	filterNameToType := map[string]FilterType{
326		"tasks":              TypeAll,
327		"today":              TypeToday,
328		"overdue":            TypeOverdue,
329		"next-7-days":        TypeNext7Days,
330		"high-priority":      TypeHighPriority,
331		"now":                TypeNow,
332		"recent-completions": TypeRecentCompletions,
333	}
334
335	if !strings.HasPrefix(uri, prefix) {
336		return "", TypeAll
337	}
338
339	rest := strings.TrimPrefix(uri, prefix)
340	parts := strings.SplitN(rest, "/", maxParts)
341
342	if len(parts) == 0 || parts[0] == "" {
343		return "", TypeAll
344	}
345
346	areaID := parts[0]
347
348	if len(parts) == minLength {
349		return areaID, TypeAll
350	}
351
352	filterType, ok := filterNameToType[parts[1]]
353	if !ok {
354		return "", TypeAll
355	}
356
357	return areaID, filterType
358}