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