1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package tasks provides MCP tools for task management in Lunatask.
6package tasks
7
8import (
9 "context"
10 "fmt"
11
12 "git.secluded.site/go-lunatask"
13 "github.com/modelcontextprotocol/go-sdk/mcp"
14
15 "git.secluded.site/lunatask-mcp-server/tools/shared"
16)
17
18// MaxNameLength is the maximum allowed task name length.
19const MaxNameLength = 100
20
21// Handler handles task-related MCP tool calls.
22type Handler struct {
23 client *lunatask.Client
24 timezone string
25 areas []shared.AreaProvider
26}
27
28// NewHandler creates a new tasks Handler.
29func NewHandler(
30 accessToken string,
31 timezone string,
32 areas []shared.AreaProvider,
33) *Handler {
34 return &Handler{
35 client: lunatask.NewClient(accessToken),
36 timezone: timezone,
37 areas: areas,
38 }
39}
40
41// HandleCreate handles the create_task tool call.
42func (h *Handler) HandleCreate(
43 ctx context.Context,
44 _ *mcp.CallToolRequest,
45 input CreateInput,
46) (*mcp.CallToolResult, CreateOutput, error) {
47 if _, err := shared.LoadLocation(h.timezone); err != nil {
48 return nil, CreateOutput{}, err
49 }
50
51 if len(input.Name) > MaxNameLength {
52 return nil, CreateOutput{}, fmt.Errorf("name must be %d characters or fewer", MaxNameLength)
53 }
54
55 area := shared.FindArea(h.areas, input.AreaID)
56 if area == nil {
57 return nil, CreateOutput{}, fmt.Errorf("area not found: %s", input.AreaID)
58 }
59
60 // Resolve goal key to ID if provided
61 var goalID string
62
63 if input.GoalID != nil && *input.GoalID != "" {
64 goal := shared.GetGoalInArea(area, *input.GoalID)
65 if goal == nil {
66 return nil, CreateOutput{}, fmt.Errorf(
67 "goal %s not found in area %s",
68 *input.GoalID,
69 area.GetName(),
70 )
71 }
72
73 goalID = goal.GetID()
74 }
75
76 builder := h.client.NewTask(input.Name).InArea(area.GetID())
77
78 if err := h.applyCreateOptions(builder, input, goalID); err != nil {
79 return nil, CreateOutput{}, err
80 }
81
82 task, err := builder.Create(ctx)
83 if err != nil {
84 return nil, CreateOutput{}, fmt.Errorf("failed to create task: %w", err)
85 }
86
87 // Handle nil response (task already exists)
88 if task == nil {
89 return nil, CreateOutput{
90 Message: "Task already exists (not an error)",
91 }, nil
92 }
93
94 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
95
96 return nil, CreateOutput{
97 TaskID: task.ID,
98 Message: "Task created successfully",
99 DeepLink: deepLink,
100 }, nil
101}
102
103// HandleUpdate handles the update_task tool call.
104func (h *Handler) HandleUpdate(
105 ctx context.Context,
106 _ *mcp.CallToolRequest,
107 input UpdateInput,
108) (*mcp.CallToolResult, UpdateOutput, error) {
109 if _, err := shared.LoadLocation(h.timezone); err != nil {
110 return nil, UpdateOutput{}, err
111 }
112
113 builder := h.client.NewTaskUpdate(input.TaskID)
114
115 if err := h.applyUpdateOptions(builder, input); err != nil {
116 return nil, UpdateOutput{}, err
117 }
118
119 task, err := builder.Update(ctx)
120 if err != nil {
121 return nil, UpdateOutput{}, fmt.Errorf("failed to update task: %w", err)
122 }
123
124 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
125
126 return nil, UpdateOutput{
127 TaskID: task.ID,
128 Message: "Task updated successfully",
129 DeepLink: deepLink,
130 }, nil
131}
132
133// HandleDelete handles the delete_task tool call.
134func (h *Handler) HandleDelete(
135 ctx context.Context,
136 _ *mcp.CallToolRequest,
137 input DeleteInput,
138) (*mcp.CallToolResult, DeleteOutput, error) {
139 _, err := h.client.DeleteTask(ctx, input.TaskID)
140 if err != nil {
141 return nil, DeleteOutput{}, fmt.Errorf("failed to delete task: %w", err)
142 }
143
144 return nil, DeleteOutput{Message: "Task deleted successfully"}, nil
145}
146
147// Areas returns the list of configured areas for resource listing.
148func (h *Handler) Areas() []shared.AreaProvider {
149 return h.areas
150}
151
152// applyCreateOptions applies optional fields to a TaskBuilder.
153//
154//nolint:funlen // each field handling is straightforward
155func (h *Handler) applyCreateOptions(builder *lunatask.TaskBuilder, input CreateInput, goalID string) error {
156 if goalID != "" {
157 builder.InGoal(goalID)
158 }
159
160 if input.Note != nil {
161 builder.WithNote(*input.Note)
162 }
163
164 if input.Estimate != nil {
165 builder.WithEstimate(*input.Estimate)
166 }
167
168 if input.Priority != nil {
169 p, err := lunatask.ParsePriority(*input.Priority)
170 if err != nil {
171 return fmt.Errorf("invalid priority: %w", err)
172 }
173
174 builder.Priority(p)
175 }
176
177 if input.Motivation != nil {
178 m, err := lunatask.ParseMotivation(*input.Motivation)
179 if err != nil {
180 return fmt.Errorf("invalid motivation: %w", err)
181 }
182
183 builder.WithMotivation(m)
184 }
185
186 if input.Eisenhower != nil {
187 e, err := lunatask.ParseEisenhower(*input.Eisenhower)
188 if err != nil {
189 return fmt.Errorf("invalid eisenhower: %w", err)
190 }
191
192 builder.WithEisenhower(e)
193 }
194
195 if input.Status != nil {
196 s, err := lunatask.ParseTaskStatus(*input.Status)
197 if err != nil {
198 return fmt.Errorf("invalid status: %w", err)
199 }
200
201 builder.WithStatus(s)
202 }
203
204 if input.ScheduledOn != nil && *input.ScheduledOn != "" {
205 date, err := lunatask.ParseDate(*input.ScheduledOn)
206 if err != nil {
207 return fmt.Errorf("invalid scheduled_on date: %w", err)
208 }
209
210 builder.ScheduledOn(date)
211 }
212
213 if input.Source != nil && *input.Source != "" {
214 sourceID := ""
215 if input.SourceID != nil {
216 sourceID = *input.SourceID
217 }
218
219 builder.FromSource(*input.Source, sourceID)
220 }
221
222 return nil
223}
224
225// applyUpdateOptions applies optional fields to a TaskUpdateBuilder.
226//
227//nolint:funlen,gocognit // each field handling is straightforward
228func (h *Handler) applyUpdateOptions(builder *lunatask.TaskUpdateBuilder, input UpdateInput) error {
229 var (
230 resolvedAreaID string
231 resolvedGoalID string
232 )
233
234 if input.AreaID != nil && *input.AreaID != "" {
235 area := shared.FindArea(h.areas, *input.AreaID)
236 if area == nil {
237 return fmt.Errorf("area not found: %s", *input.AreaID)
238 }
239
240 resolvedAreaID = area.GetID()
241 builder.InArea(resolvedAreaID)
242
243 // Validate and resolve goal if also being set
244 if input.GoalID != nil && *input.GoalID != "" {
245 goal := shared.GetGoalInArea(area, *input.GoalID)
246 if goal == nil {
247 return fmt.Errorf("goal %s not found in area %s", *input.GoalID, area.GetName())
248 }
249
250 resolvedGoalID = goal.GetID()
251 }
252 }
253
254 if input.GoalID != nil && *input.GoalID != "" {
255 if resolvedGoalID != "" {
256 // Already resolved above with area context
257 builder.InGoal(resolvedGoalID)
258 } else {
259 // No area context - try to resolve across all areas
260 for _, area := range h.areas {
261 if goal := shared.GetGoalInArea(area, *input.GoalID); goal != nil {
262 builder.InGoal(goal.GetID())
263
264 break
265 }
266 }
267 }
268 }
269
270 if input.Name != nil {
271 if len(*input.Name) > MaxNameLength {
272 return fmt.Errorf("name must be %d characters or fewer", MaxNameLength)
273 }
274
275 builder.Name(*input.Name)
276 }
277
278 if input.Note != nil {
279 builder.WithNote(*input.Note)
280 }
281
282 if input.Estimate != nil {
283 builder.WithEstimate(*input.Estimate)
284 }
285
286 if input.Priority != nil {
287 p, err := lunatask.ParsePriority(*input.Priority)
288 if err != nil {
289 return fmt.Errorf("invalid priority: %w", err)
290 }
291
292 builder.Priority(p)
293 }
294
295 if input.Motivation != nil {
296 m, err := lunatask.ParseMotivation(*input.Motivation)
297 if err != nil {
298 return fmt.Errorf("invalid motivation: %w", err)
299 }
300
301 builder.WithMotivation(m)
302 }
303
304 if input.Eisenhower != nil {
305 e, err := lunatask.ParseEisenhower(*input.Eisenhower)
306 if err != nil {
307 return fmt.Errorf("invalid eisenhower: %w", err)
308 }
309
310 builder.WithEisenhower(e)
311 }
312
313 if input.Status != nil {
314 s, err := lunatask.ParseTaskStatus(*input.Status)
315 if err != nil {
316 return fmt.Errorf("invalid status: %w", err)
317 }
318
319 builder.WithStatus(s)
320 }
321
322 if input.ScheduledOn != nil && *input.ScheduledOn != "" {
323 date, err := lunatask.ParseDate(*input.ScheduledOn)
324 if err != nil {
325 return fmt.Errorf("invalid scheduled_on date: %w", err)
326 }
327
328 builder.ScheduledOn(date)
329 }
330
331 return nil
332}