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