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