create.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5// Package task provides MCP tools for Lunatask task operations.
  6package task
  7
  8import (
  9	"context"
 10
 11	"git.secluded.site/go-lunatask"
 12	"git.secluded.site/lune/internal/config"
 13	"git.secluded.site/lune/internal/dateutil"
 14	"git.secluded.site/lune/internal/mcp/shared"
 15	"git.secluded.site/lune/internal/validate"
 16	"github.com/modelcontextprotocol/go-sdk/mcp"
 17)
 18
 19// CreateToolName is the name of the create task tool.
 20const CreateToolName = "create_task"
 21
 22// CreateToolDescription describes the create task tool for LLMs.
 23const CreateToolDescription = `Creates a new task in Lunatask.
 24
 25Required:
 26- name: Task title
 27
 28Optional:
 29- area_id: Area UUID, lunatask:// deep link, or config key
 30- goal_id: Goal UUID, lunatask:// deep link, or config key (requires area_id)
 31- status: later, next, started, waiting (default: later)
 32- note: Markdown note/description for the task
 33- priority: lowest, low, normal, high, highest
 34- estimate: Time estimate in minutes (0-720)
 35- motivation: must, should, want
 36- important: true/false for Eisenhower matrix
 37- urgent: true/false for Eisenhower matrix
 38- scheduled_on: Date to schedule (YYYY-MM-DD or natural language)
 39
 40Returns the created task's ID and deep link.`
 41
 42// CreateInput is the input schema for creating a task.
 43type CreateInput struct {
 44	Name        string  `json:"name"                   jsonschema:"required"`
 45	AreaID      *string `json:"area_id,omitempty"`
 46	GoalID      *string `json:"goal_id,omitempty"`
 47	Status      *string `json:"status,omitempty"`
 48	Note        *string `json:"note,omitempty"`
 49	Priority    *string `json:"priority,omitempty"`
 50	Estimate    *int    `json:"estimate,omitempty"`
 51	Motivation  *string `json:"motivation,omitempty"`
 52	Important   *bool   `json:"important,omitempty"`
 53	Urgent      *bool   `json:"urgent,omitempty"`
 54	ScheduledOn *string `json:"scheduled_on,omitempty"`
 55}
 56
 57// CreateOutput is the output schema for creating a task.
 58type CreateOutput struct {
 59	DeepLink string `json:"deep_link"`
 60}
 61
 62// parsedCreateInput holds validated and parsed create input fields.
 63type parsedCreateInput struct {
 64	Name        string
 65	AreaID      *string
 66	GoalID      *string
 67	Status      *lunatask.TaskStatus
 68	Note        *string
 69	Priority    *lunatask.Priority
 70	Estimate    *int
 71	Motivation  *lunatask.Motivation
 72	Important   *bool
 73	Urgent      *bool
 74	ScheduledOn *lunatask.Date
 75}
 76
 77// Handler handles task-related MCP tool requests.
 78type Handler struct {
 79	client *lunatask.Client
 80	cfg    *config.Config
 81	areas  []shared.AreaProvider
 82}
 83
 84// NewHandler creates a new task handler.
 85func NewHandler(accessToken string, cfg *config.Config, areas []shared.AreaProvider) *Handler {
 86	return &Handler{
 87		client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
 88		cfg:    cfg,
 89		areas:  areas,
 90	}
 91}
 92
 93// HandleCreate creates a new task.
 94func (h *Handler) HandleCreate(
 95	ctx context.Context,
 96	_ *mcp.CallToolRequest,
 97	input CreateInput,
 98) (*mcp.CallToolResult, CreateOutput, error) {
 99	parsed, errResult := parseCreateInput(h.cfg, input)
100	if errResult != nil {
101		return errResult, CreateOutput{}, nil
102	}
103
104	builder := h.client.NewTask(parsed.Name)
105	applyToTaskBuilder(builder, parsed)
106
107	task, err := builder.Create(ctx)
108	if err != nil {
109		return shared.ErrorResult(err.Error()), CreateOutput{}, nil
110	}
111
112	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
113
114	return &mcp.CallToolResult{
115		Content: []mcp.Content{&mcp.TextContent{
116			Text: "Task created: " + deepLink,
117		}},
118	}, CreateOutput{DeepLink: deepLink}, nil
119}
120
121//nolint:cyclop,funlen
122func parseCreateInput(cfg *config.Config, input CreateInput) (*parsedCreateInput, *mcp.CallToolResult) {
123	parsed := &parsedCreateInput{
124		Name:      input.Name,
125		Note:      input.Note,
126		Estimate:  input.Estimate,
127		Important: input.Important,
128		Urgent:    input.Urgent,
129	}
130
131	if input.AreaID != nil {
132		areaID, err := validate.AreaRef(cfg, *input.AreaID)
133		if err != nil {
134			return nil, shared.ErrorResult(err.Error())
135		}
136
137		parsed.AreaID = &areaID
138	}
139
140	if input.GoalID != nil {
141		areaID := ""
142		if parsed.AreaID != nil {
143			areaID = *parsed.AreaID
144		}
145
146		goalID, err := validate.GoalRef(cfg, areaID, *input.GoalID)
147		if err != nil {
148			return nil, shared.ErrorResult(err.Error())
149		}
150
151		parsed.GoalID = &goalID
152	}
153
154	if input.Estimate != nil {
155		if err := shared.ValidateEstimate(*input.Estimate); err != nil {
156			return nil, shared.ErrorResult(err.Error())
157		}
158	}
159
160	if input.Status != nil {
161		status, err := lunatask.ParseTaskStatus(*input.Status)
162		if err != nil {
163			return nil, shared.ErrorResult(err.Error())
164		}
165
166		parsed.Status = &status
167	}
168
169	if input.Priority != nil {
170		priority, err := lunatask.ParsePriority(*input.Priority)
171		if err != nil {
172			return nil, shared.ErrorResult(err.Error())
173		}
174
175		parsed.Priority = &priority
176	}
177
178	if input.Motivation != nil {
179		motivation, err := lunatask.ParseMotivation(*input.Motivation)
180		if err != nil {
181			return nil, shared.ErrorResult(err.Error())
182		}
183
184		parsed.Motivation = &motivation
185	}
186
187	if input.ScheduledOn != nil {
188		date, err := dateutil.Parse(*input.ScheduledOn)
189		if err != nil {
190			return nil, shared.ErrorResult(err.Error())
191		}
192
193		parsed.ScheduledOn = &date
194	}
195
196	return parsed, nil
197}
198
199//nolint:cyclop
200func applyToTaskBuilder(builder *lunatask.TaskBuilder, parsed *parsedCreateInput) {
201	if parsed.AreaID != nil {
202		builder.InArea(*parsed.AreaID)
203	}
204
205	if parsed.GoalID != nil {
206		builder.InGoal(*parsed.GoalID)
207	}
208
209	if parsed.Status != nil {
210		builder.WithStatus(*parsed.Status)
211	}
212
213	if parsed.Note != nil {
214		builder.WithNote(*parsed.Note)
215	}
216
217	if parsed.Priority != nil {
218		builder.Priority(*parsed.Priority)
219	}
220
221	if parsed.Estimate != nil {
222		builder.WithEstimate(*parsed.Estimate)
223	}
224
225	if parsed.Motivation != nil {
226		builder.WithMotivation(*parsed.Motivation)
227	}
228
229	if parsed.Important != nil {
230		if *parsed.Important {
231			builder.Important()
232		} else {
233			builder.NotImportant()
234		}
235	}
236
237	if parsed.Urgent != nil {
238		if *parsed.Urgent {
239			builder.Urgent()
240		} else {
241			builder.NotUrgent()
242		}
243	}
244
245	if parsed.ScheduledOn != nil {
246		builder.ScheduledOn(*parsed.ScheduledOn)
247	}
248}