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}