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}