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 tools for task management in Lunatask.
  6package tasks
  7
  8import (
  9	"context"
 10	"fmt"
 11
 12	"git.secluded.site/go-lunatask"
 13	"github.com/mark3labs/mcp-go/mcp"
 14
 15	"git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
 16)
 17
 18// Handler handles task-related MCP tool calls.
 19type Handler struct {
 20	accessToken string
 21	timezone    string
 22	areas       []shared.AreaProvider
 23}
 24
 25// NewHandler creates a new tasks Handler.
 26func NewHandler(
 27	accessToken string,
 28	timezone string,
 29	areas []shared.AreaProvider,
 30) *Handler {
 31	return &Handler{
 32		accessToken: accessToken,
 33		timezone:    timezone,
 34		areas:       areas,
 35	}
 36}
 37
 38// HandleCreate handles the create_task tool call.
 39//
 40//nolint:cyclop,funlen,wrapcheck // validation complexity; ReportError returns nil
 41func (h *Handler) HandleCreate(
 42	ctx context.Context,
 43	request mcp.CallToolRequest,
 44) (*mcp.CallToolResult, error) {
 45	arguments := request.Params.Arguments
 46
 47	if _, err := shared.LoadLocation(h.timezone); err != nil {
 48		return shared.ReportError(err.Error())
 49	}
 50
 51	areaID, ok := arguments["area_id"].(string)
 52	if !ok || areaID == "" {
 53		return shared.ReportError("Missing or invalid required argument: area_id")
 54	}
 55
 56	area := FindArea(h.areas, areaID)
 57	if area == nil {
 58		return shared.ReportError("Area not found for given area_id")
 59	}
 60
 61	goalID, errResult := h.validateGoalID(arguments, area)
 62	if errResult != nil {
 63		return errResult, nil
 64	}
 65
 66	name, ok := arguments["name"].(string)
 67	if !ok || name == "" {
 68		return shared.ReportError("Missing or invalid required argument: name")
 69	}
 70
 71	if errResult := ValidateName(name); errResult != nil {
 72		return errResult, nil
 73	}
 74
 75	task := lunatask.CreateTaskRequest{
 76		Name:   name,
 77		AreaID: &areaID,
 78		GoalID: goalID,
 79	}
 80
 81	if err := h.populateCreateFields(&task, arguments); err != nil {
 82		return err, nil
 83	}
 84
 85	client := lunatask.NewClient(h.accessToken)
 86
 87	response, err := client.CreateTask(ctx, &task)
 88	if err != nil {
 89		return shared.ReportError(fmt.Sprintf("%v", err))
 90	}
 91
 92	if response == nil {
 93		return &mcp.CallToolResult{
 94			Content: []mcp.Content{
 95				mcp.TextContent{
 96					Type: "text",
 97					Text: "Task already exists (not an error).",
 98				},
 99			},
100		}, nil
101	}
102
103	return &mcp.CallToolResult{
104		Content: []mcp.Content{
105			mcp.TextContent{
106				Type: "text",
107				Text: "Task created successfully with ID: " + response.ID,
108			},
109		},
110	}, nil
111}
112
113// HandleUpdate handles the update_task tool call.
114//
115//nolint:wrapcheck // ReportError returns nil
116func (h *Handler) HandleUpdate(
117	ctx context.Context,
118	request mcp.CallToolRequest,
119) (*mcp.CallToolResult, error) {
120	arguments := request.Params.Arguments
121
122	taskID, ok := arguments["task_id"].(string)
123	if !ok || taskID == "" {
124		return shared.ReportError("Missing or invalid required argument: task_id")
125	}
126
127	if _, err := shared.LoadLocation(h.timezone); err != nil {
128		return shared.ReportError(err.Error())
129	}
130
131	updatePayload := lunatask.UpdateTaskRequest{}
132
133	area, errResult := h.validateUpdateArea(arguments, &updatePayload)
134	if errResult != nil {
135		return errResult, nil
136	}
137
138	if errResult := h.validateUpdateGoal(arguments, area, &updatePayload); errResult != nil {
139		return errResult, nil
140	}
141
142	if errResult := h.validateUpdateName(arguments, &updatePayload); errResult != nil {
143		return errResult, nil
144	}
145
146	if err := h.populateUpdateFields(&updatePayload, arguments); err != nil {
147		return err, nil
148	}
149
150	client := lunatask.NewClient(h.accessToken)
151
152	response, err := client.UpdateTask(ctx, taskID, &updatePayload)
153	if err != nil {
154		return shared.ReportError(fmt.Sprintf("Failed to update task: %v", err))
155	}
156
157	return &mcp.CallToolResult{
158		Content: []mcp.Content{
159			mcp.TextContent{
160				Type: "text",
161				Text: "Task updated successfully. ID: " + response.ID,
162			},
163		},
164	}, nil
165}
166
167// HandleDelete handles the delete_task tool call.
168//
169//nolint:wrapcheck // ReportError returns nil
170func (h *Handler) HandleDelete(
171	ctx context.Context,
172	request mcp.CallToolRequest,
173) (*mcp.CallToolResult, error) {
174	taskID, ok := request.Params.Arguments["task_id"].(string)
175	if !ok || taskID == "" {
176		return shared.ReportError("Missing or invalid required argument: task_id")
177	}
178
179	client := lunatask.NewClient(h.accessToken)
180
181	_, err := client.DeleteTask(ctx, taskID)
182	if err != nil {
183		return shared.ReportError(fmt.Sprintf("Failed to delete task: %v", err))
184	}
185
186	return &mcp.CallToolResult{
187		Content: []mcp.Content{
188			mcp.TextContent{
189				Type: "text",
190				Text: "Task deleted successfully.",
191			},
192		},
193	}, nil
194}
195
196// validateGoalID validates and returns the goal_id if provided.
197func (h *Handler) validateGoalID(
198	arguments map[string]any,
199	area shared.AreaProvider,
200) (*string, *mcp.CallToolResult) {
201	goalIDStr, exists := arguments["goal_id"].(string)
202	if !exists || goalIDStr == "" {
203		return nil, nil
204	}
205
206	if !FindGoalInArea(area, goalIDStr) {
207		result, _ := shared.ReportError(
208			"Goal not found in specified area for given goal_id",
209		)
210
211		return nil, result
212	}
213
214	return &goalIDStr, nil
215}
216
217// validateUpdateArea validates area_id for update and returns the area provider.
218//
219//nolint:ireturn // returns interface by design
220func (h *Handler) validateUpdateArea(
221	arguments map[string]any,
222	payload *lunatask.UpdateTaskRequest,
223) (shared.AreaProvider, *mcp.CallToolResult) {
224	areaIDArg, exists := arguments["area_id"]
225	if !exists {
226		return nil, nil
227	}
228
229	areaIDStr, ok := areaIDArg.(string)
230	if !ok && areaIDArg != nil {
231		result, _ := shared.ReportError(
232			"Invalid type for area_id argument: expected string.",
233		)
234
235		return nil, result
236	}
237
238	if !ok || areaIDStr == "" {
239		return nil, nil
240	}
241
242	payload.AreaID = &areaIDStr
243	area := FindArea(h.areas, areaIDStr)
244
245	if area == nil {
246		result, _ := shared.ReportError("Area not found for given area_id: " + areaIDStr)
247
248		return nil, result
249	}
250
251	return area, nil
252}
253
254// validateUpdateGoal validates goal_id for update.
255func (h *Handler) validateUpdateGoal(
256	arguments map[string]any,
257	area shared.AreaProvider,
258	payload *lunatask.UpdateTaskRequest,
259) *mcp.CallToolResult {
260	goalIDArg, exists := arguments["goal_id"]
261	if !exists {
262		return nil
263	}
264
265	goalIDStr, ok := goalIDArg.(string)
266	if !ok && goalIDArg != nil {
267		result, _ := shared.ReportError(
268			"Invalid type for goal_id argument: expected string.",
269		)
270
271		return result
272	}
273
274	if !ok || goalIDStr == "" {
275		return nil
276	}
277
278	payload.GoalID = &goalIDStr
279
280	if area != nil && !FindGoalInArea(area, goalIDStr) {
281		result, _ := shared.ReportError(fmt.Sprintf(
282			"Goal not found in specified area '%s' for given goal_id: %s",
283			area.GetName(),
284			goalIDStr,
285		))
286
287		return result
288	}
289
290	return nil
291}
292
293// validateUpdateName validates and sets the name for update.
294func (h *Handler) validateUpdateName(
295	arguments map[string]any,
296	payload *lunatask.UpdateTaskRequest,
297) *mcp.CallToolResult {
298	nameArg := arguments["name"]
299	nameStr, ok := nameArg.(string)
300
301	if !ok {
302		result, _ := shared.ReportError(
303			"Invalid type for name argument: expected string.",
304		)
305
306		return result
307	}
308
309	if errResult := ValidateName(nameStr); errResult != nil {
310		return errResult
311	}
312
313	payload.Name = &nameStr
314
315	return nil
316}
317
318// populateCreateFields populates optional fields for task creation.
319func (h *Handler) populateCreateFields(
320	task *lunatask.CreateTaskRequest,
321	arguments map[string]any,
322) *mcp.CallToolResult {
323	if noteVal, exists := arguments["note"].(string); exists {
324		task.Note = &noteVal
325	}
326
327	if errResult := h.setCreatePriority(task, arguments); errResult != nil {
328		return errResult
329	}
330
331	if errResult := h.setCreateEisenhower(task, arguments); errResult != nil {
332		return errResult
333	}
334
335	if errResult := h.setCreateMotivation(task, arguments); errResult != nil {
336		return errResult
337	}
338
339	if errResult := h.setCreateStatus(task, arguments); errResult != nil {
340		return errResult
341	}
342
343	if errResult := h.setCreateEstimate(task, arguments); errResult != nil {
344		return errResult
345	}
346
347	if errResult := h.setCreateScheduledOn(task, arguments); errResult != nil {
348		return errResult
349	}
350
351	h.setCreateSource(task, arguments)
352
353	return nil
354}
355
356// populateUpdateFields populates optional fields for task update.
357func (h *Handler) populateUpdateFields(
358	payload *lunatask.UpdateTaskRequest,
359	arguments map[string]any,
360) *mcp.CallToolResult {
361	if errResult := h.setUpdateNote(payload, arguments); errResult != nil {
362		return errResult
363	}
364
365	if errResult := h.setUpdateEstimate(payload, arguments); errResult != nil {
366		return errResult
367	}
368
369	if errResult := h.setUpdatePriority(payload, arguments); errResult != nil {
370		return errResult
371	}
372
373	if errResult := h.setUpdateEisenhower(payload, arguments); errResult != nil {
374		return errResult
375	}
376
377	if errResult := h.setUpdateMotivation(payload, arguments); errResult != nil {
378		return errResult
379	}
380
381	if errResult := h.setUpdateStatus(payload, arguments); errResult != nil {
382		return errResult
383	}
384
385	if errResult := h.setUpdateScheduledOn(payload, arguments); errResult != nil {
386		return errResult
387	}
388
389	return nil
390}