1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package crud provides consolidated MCP tools for Lunatask CRUD operations.
6package crud
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// Entity type constants.
20const (
21 EntityTask = "task"
22 EntityNote = "note"
23 EntityPerson = "person"
24 EntityJournal = "journal"
25 EntityArea = "area"
26 EntityGoal = "goal"
27 EntityNotebook = "notebook"
28 EntityHabit = "habit"
29)
30
31// CreateToolName is the name of the consolidated create tool.
32const CreateToolName = "create"
33
34// CreateToolDescription describes the create tool for LLMs.
35const CreateToolDescription = `Creates a new entity in Lunatask.
36
37Required:
38- entity: Type to create (task, note, person, journal)
39
40Entity-specific fields:
41
42**task** (requires name, area_id):
43- name: Task title
44- area_id: Area UUID, lunatask:// deep link, or config key
45- goal_id: Goal UUID, deep link, or config key (optional)
46- status: later, next, started, waiting (default: later)
47- note: Markdown note/description
48- priority: lowest, low, normal, high, highest
49- estimate: Time estimate in minutes (0-720)
50- motivation: must, should, want
51- important: true/false for Eisenhower matrix
52- urgent: true/false for Eisenhower matrix
53- scheduled_on: Date to schedule (YYYY-MM-DD or natural language)
54
55**note** (all fields optional):
56- name: Note title
57- notebook_id: Notebook UUID
58- content: Markdown content
59- source: Origin identifier for integrations
60- source_id: Source-specific ID (requires source)
61
62**person** (requires first_name):
63- first_name: First name
64- last_name: Last name
65- relationship: Relationship strength (family, intimate-friends, close-friends,
66 casual-friends, acquaintances, business-contacts, almost-strangers)
67- source: Origin identifier
68- source_id: Source-specific ID (requires source)
69
70**journal** (all fields optional):
71- name: Entry title (defaults to weekday name)
72- content: Markdown content
73- date: Entry date (YYYY-MM-DD or natural language, default: today)
74
75Returns the created entity's ID and deep link.`
76
77// CreateInput is the input schema for the consolidated create tool.
78type CreateInput struct {
79 Entity string `json:"entity" jsonschema:"required"`
80
81 // Common fields
82 Name *string `json:"name,omitempty"`
83 Content *string `json:"content,omitempty"`
84 Source *string `json:"source,omitempty"`
85 SourceID *string `json:"source_id,omitempty"`
86
87 // Task-specific fields
88 AreaID *string `json:"area_id,omitempty"`
89 GoalID *string `json:"goal_id,omitempty"`
90 Status *string `json:"status,omitempty"`
91 Note *string `json:"note,omitempty"`
92 Priority *string `json:"priority,omitempty"`
93 Estimate *int `json:"estimate,omitempty"`
94 Motivation *string `json:"motivation,omitempty"`
95 Important *bool `json:"important,omitempty"`
96 Urgent *bool `json:"urgent,omitempty"`
97 ScheduledOn *string `json:"scheduled_on,omitempty"`
98
99 // Note-specific fields
100 NotebookID *string `json:"notebook_id,omitempty"`
101
102 // Person-specific fields
103 FirstName *string `json:"first_name,omitempty"`
104 LastName *string `json:"last_name,omitempty"`
105 Relationship *string `json:"relationship,omitempty"`
106
107 // Journal-specific fields
108 Date *string `json:"date,omitempty"`
109}
110
111// CreateOutput is the output schema for the consolidated create tool.
112type CreateOutput struct {
113 Entity string `json:"entity"`
114 DeepLink string `json:"deep_link,omitempty"`
115 ID string `json:"id,omitempty"`
116}
117
118// Handler handles consolidated CRUD tool requests.
119type Handler struct {
120 client *lunatask.Client
121 cfg *config.Config
122 areas []shared.AreaProvider
123 habits []shared.HabitProvider
124 notebooks []shared.NotebookProvider
125}
126
127// NewHandler creates a new consolidated CRUD handler.
128func NewHandler(
129 accessToken string,
130 cfg *config.Config,
131 areas []shared.AreaProvider,
132 habits []shared.HabitProvider,
133 notebooks []shared.NotebookProvider,
134) *Handler {
135 return &Handler{
136 client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
137 cfg: cfg,
138 areas: areas,
139 habits: habits,
140 notebooks: notebooks,
141 }
142}
143
144// HandleCreate creates a new entity based on the entity type.
145func (h *Handler) HandleCreate(
146 ctx context.Context,
147 _ *mcp.CallToolRequest,
148 input CreateInput,
149) (*mcp.CallToolResult, CreateOutput, error) {
150 switch input.Entity {
151 case EntityTask:
152 return h.createTask(ctx, input)
153 case EntityNote:
154 return h.createNote(ctx, input)
155 case EntityPerson:
156 return h.createPerson(ctx, input)
157 case EntityJournal:
158 return h.createJournal(ctx, input)
159 default:
160 return shared.ErrorResult("invalid entity: must be task, note, person, or journal"),
161 CreateOutput{Entity: input.Entity}, nil
162 }
163}
164
165// parsedTaskCreateInput holds validated and parsed task create input fields.
166type parsedTaskCreateInput struct {
167 Name string
168 AreaID string
169 GoalID *string
170 Status *lunatask.TaskStatus
171 Note *string
172 Priority *lunatask.Priority
173 Estimate *int
174 Motivation *lunatask.Motivation
175 Important *bool
176 Urgent *bool
177 ScheduledOn *lunatask.Date
178}
179
180func (h *Handler) createTask(
181 ctx context.Context,
182 input CreateInput,
183) (*mcp.CallToolResult, CreateOutput, error) {
184 parsed, errResult := h.parseTaskCreateInput(input)
185 if errResult != nil {
186 return errResult, CreateOutput{Entity: input.Entity}, nil
187 }
188
189 builder := h.client.NewTask(parsed.Name)
190 applyToTaskBuilder(builder, parsed)
191
192 task, err := builder.Create(ctx)
193 if err != nil {
194 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
195 }
196
197 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
198
199 return &mcp.CallToolResult{
200 Content: []mcp.Content{&mcp.TextContent{
201 Text: "Task created: " + deepLink,
202 }},
203 }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: task.ID}, nil
204}
205
206//nolint:cyclop,funlen
207func (h *Handler) parseTaskCreateInput(input CreateInput) (*parsedTaskCreateInput, *mcp.CallToolResult) {
208 if input.Name == nil || *input.Name == "" {
209 return nil, shared.ErrorResult("name is required for task creation")
210 }
211
212 if input.AreaID == nil || *input.AreaID == "" {
213 return nil, shared.ErrorResult("area_id is required for task creation")
214 }
215
216 parsed := &parsedTaskCreateInput{
217 Name: *input.Name,
218 Note: input.Note,
219 Estimate: input.Estimate,
220 Important: input.Important,
221 Urgent: input.Urgent,
222 }
223
224 areaID, err := validate.AreaRef(h.cfg, *input.AreaID)
225 if err != nil {
226 return nil, shared.ErrorResult(err.Error())
227 }
228
229 parsed.AreaID = areaID
230
231 if input.GoalID != nil {
232 goalID, err := validate.GoalRef(h.cfg, parsed.AreaID, *input.GoalID)
233 if err != nil {
234 return nil, shared.ErrorResult(err.Error())
235 }
236
237 parsed.GoalID = &goalID
238 }
239
240 if input.Estimate != nil {
241 if err := shared.ValidateEstimate(*input.Estimate); err != nil {
242 return nil, shared.ErrorResult(err.Error())
243 }
244 }
245
246 if input.Status != nil {
247 status, err := lunatask.ParseTaskStatus(*input.Status)
248 if err != nil {
249 return nil, shared.ErrorResult(err.Error())
250 }
251
252 parsed.Status = &status
253 }
254
255 if input.Priority != nil {
256 priority, err := lunatask.ParsePriority(*input.Priority)
257 if err != nil {
258 return nil, shared.ErrorResult(err.Error())
259 }
260
261 parsed.Priority = &priority
262 }
263
264 if input.Motivation != nil {
265 motivation, err := lunatask.ParseMotivation(*input.Motivation)
266 if err != nil {
267 return nil, shared.ErrorResult(err.Error())
268 }
269
270 parsed.Motivation = &motivation
271 }
272
273 if input.ScheduledOn != nil {
274 date, err := dateutil.Parse(*input.ScheduledOn)
275 if err != nil {
276 return nil, shared.ErrorResult(err.Error())
277 }
278
279 parsed.ScheduledOn = &date
280 }
281
282 return parsed, nil
283}
284
285//nolint:cyclop
286func applyToTaskBuilder(builder *lunatask.TaskBuilder, parsed *parsedTaskCreateInput) {
287 builder.InArea(parsed.AreaID)
288
289 if parsed.GoalID != nil {
290 builder.InGoal(*parsed.GoalID)
291 }
292
293 if parsed.Status != nil {
294 builder.WithStatus(*parsed.Status)
295 }
296
297 if parsed.Note != nil {
298 builder.WithNote(*parsed.Note)
299 }
300
301 if parsed.Priority != nil {
302 builder.Priority(*parsed.Priority)
303 }
304
305 if parsed.Estimate != nil {
306 builder.WithEstimate(*parsed.Estimate)
307 }
308
309 if parsed.Motivation != nil {
310 builder.WithMotivation(*parsed.Motivation)
311 }
312
313 if parsed.Important != nil {
314 if *parsed.Important {
315 builder.Important()
316 } else {
317 builder.NotImportant()
318 }
319 }
320
321 if parsed.Urgent != nil {
322 if *parsed.Urgent {
323 builder.Urgent()
324 } else {
325 builder.NotUrgent()
326 }
327 }
328
329 if parsed.ScheduledOn != nil {
330 builder.ScheduledOn(*parsed.ScheduledOn)
331 }
332}
333
334func (h *Handler) createNote(
335 ctx context.Context,
336 input CreateInput,
337) (*mcp.CallToolResult, CreateOutput, error) {
338 if input.NotebookID != nil {
339 if err := lunatask.ValidateUUID(*input.NotebookID); err != nil {
340 return shared.ErrorResult("invalid notebook_id: expected UUID"),
341 CreateOutput{Entity: input.Entity}, nil
342 }
343 }
344
345 builder := h.client.NewNote()
346
347 if input.Name != nil {
348 builder.WithName(*input.Name)
349 }
350
351 if input.NotebookID != nil {
352 builder.InNotebook(*input.NotebookID)
353 }
354
355 if input.Content != nil {
356 builder.WithContent(*input.Content)
357 }
358
359 if input.Source != nil && input.SourceID != nil {
360 builder.FromSource(*input.Source, *input.SourceID)
361 }
362
363 note, err := builder.Create(ctx)
364 if err != nil {
365 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
366 }
367
368 if note == nil {
369 return &mcp.CallToolResult{
370 Content: []mcp.Content{&mcp.TextContent{
371 Text: "Note already exists (duplicate source)",
372 }},
373 }, CreateOutput{Entity: input.Entity}, nil
374 }
375
376 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
377
378 return &mcp.CallToolResult{
379 Content: []mcp.Content{&mcp.TextContent{
380 Text: "Note created: " + deepLink,
381 }},
382 }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: note.ID}, nil
383}
384
385func (h *Handler) createPerson(
386 ctx context.Context,
387 input CreateInput,
388) (*mcp.CallToolResult, CreateOutput, error) {
389 if input.FirstName == nil || *input.FirstName == "" {
390 return shared.ErrorResult("first_name is required for person creation"),
391 CreateOutput{Entity: input.Entity}, nil
392 }
393
394 lastName := ""
395 if input.LastName != nil {
396 lastName = *input.LastName
397 }
398
399 builder := h.client.NewPerson(*input.FirstName, lastName)
400
401 if input.Relationship != nil {
402 rel, err := lunatask.ParseRelationshipStrength(*input.Relationship)
403 if err != nil {
404 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
405 }
406
407 builder.WithRelationshipStrength(rel)
408 }
409
410 if input.Source != nil && input.SourceID != nil {
411 builder.FromSource(*input.Source, *input.SourceID)
412 }
413
414 person, err := builder.Create(ctx)
415 if err != nil {
416 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
417 }
418
419 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
420
421 return &mcp.CallToolResult{
422 Content: []mcp.Content{&mcp.TextContent{
423 Text: "Person created: " + deepLink,
424 }},
425 }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: person.ID}, nil
426}
427
428func (h *Handler) createJournal(
429 ctx context.Context,
430 input CreateInput,
431) (*mcp.CallToolResult, CreateOutput, error) {
432 dateStr := ""
433 if input.Date != nil {
434 dateStr = *input.Date
435 }
436
437 date, err := dateutil.Parse(dateStr)
438 if err != nil {
439 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
440 }
441
442 builder := h.client.NewJournalEntry(date)
443
444 if input.Content != nil {
445 builder.WithContent(*input.Content)
446 }
447
448 if input.Name != nil {
449 builder.WithName(*input.Name)
450 }
451
452 entry, err := builder.Create(ctx)
453 if err != nil {
454 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
455 }
456
457 formattedDate := date.Format("2006-01-02")
458
459 return &mcp.CallToolResult{
460 Content: []mcp.Content{&mcp.TextContent{
461 Text: "Journal entry created for " + formattedDate,
462 }},
463 }, CreateOutput{Entity: input.Entity, ID: entry.ID}, nil
464}