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.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}