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 area := h.cfg.AreaByID(parsed.AreaID)
236 if area == nil {
237 return nil, shared.ErrorResult("internal error: area not found after validation")
238 }
239
240 status, err := shared.ValidateStatusForWorkflow(*input.Status, area.Workflow)
241 if err != nil {
242 return nil, shared.ErrorResult(err.Error())
243 }
244
245 parsed.Status = &status
246 }
247
248 if input.Priority != nil {
249 priority, err := lunatask.ParsePriority(*input.Priority)
250 if err != nil {
251 return nil, shared.ErrorResult(err.Error())
252 }
253
254 parsed.Priority = &priority
255 }
256
257 if input.Motivation != nil {
258 motivation, err := lunatask.ParseMotivation(*input.Motivation)
259 if err != nil {
260 return nil, shared.ErrorResult(err.Error())
261 }
262
263 parsed.Motivation = &motivation
264 }
265
266 if input.ScheduledOn != nil {
267 date, err := dateutil.Parse(*input.ScheduledOn)
268 if err != nil {
269 return nil, shared.ErrorResult(err.Error())
270 }
271
272 parsed.ScheduledOn = &date
273 }
274
275 return parsed, nil
276}
277
278//nolint:cyclop
279func applyToTaskBuilder(builder *lunatask.TaskBuilder, parsed *parsedTaskCreateInput) {
280 builder.InArea(parsed.AreaID)
281
282 if parsed.GoalID != nil {
283 builder.InGoal(*parsed.GoalID)
284 }
285
286 if parsed.Status != nil {
287 builder.WithStatus(*parsed.Status)
288 }
289
290 if parsed.Note != nil {
291 builder.WithNote(*parsed.Note)
292 }
293
294 if parsed.Priority != nil {
295 builder.Priority(*parsed.Priority)
296 }
297
298 if parsed.Estimate != nil {
299 builder.WithEstimate(*parsed.Estimate)
300 }
301
302 if parsed.Motivation != nil {
303 builder.WithMotivation(*parsed.Motivation)
304 }
305
306 if parsed.Important != nil {
307 if *parsed.Important {
308 builder.Important()
309 } else {
310 builder.NotImportant()
311 }
312 }
313
314 if parsed.Urgent != nil {
315 if *parsed.Urgent {
316 builder.Urgent()
317 } else {
318 builder.NotUrgent()
319 }
320 }
321
322 if parsed.ScheduledOn != nil {
323 builder.ScheduledOn(*parsed.ScheduledOn)
324 }
325}
326
327func (h *Handler) createNote(
328 ctx context.Context,
329 input CreateInput,
330) (*mcp.CallToolResult, CreateOutput, error) {
331 if input.NotebookID != nil {
332 if err := lunatask.ValidateUUID(*input.NotebookID); err != nil {
333 return shared.ErrorResult("invalid notebook_id: expected UUID"),
334 CreateOutput{Entity: input.Entity}, nil
335 }
336 }
337
338 builder := h.client.NewNote()
339
340 if input.Name != nil {
341 builder.WithName(*input.Name)
342 }
343
344 if input.NotebookID != nil {
345 builder.InNotebook(*input.NotebookID)
346 }
347
348 if input.Content != nil {
349 builder.WithContent(*input.Content)
350 }
351
352 if input.Source != nil && input.SourceID != nil {
353 builder.FromSource(*input.Source, *input.SourceID)
354 }
355
356 note, err := builder.Create(ctx)
357 if err != nil {
358 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
359 }
360
361 if note == nil {
362 return &mcp.CallToolResult{
363 Content: []mcp.Content{&mcp.TextContent{
364 Text: "Note already exists (duplicate source)",
365 }},
366 }, CreateOutput{Entity: input.Entity}, nil
367 }
368
369 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
370
371 return &mcp.CallToolResult{
372 Content: []mcp.Content{&mcp.TextContent{
373 Text: "Note created: " + deepLink,
374 }},
375 }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: note.ID}, nil
376}
377
378func (h *Handler) createPerson(
379 ctx context.Context,
380 input CreateInput,
381) (*mcp.CallToolResult, CreateOutput, error) {
382 if input.FirstName == nil || *input.FirstName == "" {
383 return shared.ErrorResult("first_name is required for person creation"),
384 CreateOutput{Entity: input.Entity}, nil
385 }
386
387 lastName := ""
388 if input.LastName != nil {
389 lastName = *input.LastName
390 }
391
392 builder := h.client.NewPerson(*input.FirstName, lastName)
393
394 if input.Relationship != nil {
395 rel, err := lunatask.ParseRelationshipStrength(*input.Relationship)
396 if err != nil {
397 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
398 }
399
400 builder.WithRelationshipStrength(rel)
401 }
402
403 if input.Source != nil && input.SourceID != nil {
404 builder.FromSource(*input.Source, *input.SourceID)
405 }
406
407 person, err := builder.Create(ctx)
408 if err != nil {
409 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
410 }
411
412 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
413
414 return &mcp.CallToolResult{
415 Content: []mcp.Content{&mcp.TextContent{
416 Text: "Person created: " + deepLink,
417 }},
418 }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: person.ID}, nil
419}
420
421func (h *Handler) createJournal(
422 ctx context.Context,
423 input CreateInput,
424) (*mcp.CallToolResult, CreateOutput, error) {
425 dateStr := ""
426 if input.Date != nil {
427 dateStr = *input.Date
428 }
429
430 date, err := dateutil.Parse(dateStr)
431 if err != nil {
432 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
433 }
434
435 builder := h.client.NewJournalEntry(date)
436
437 if input.Content != nil {
438 builder.WithContent(*input.Content)
439 }
440
441 if input.Name != nil {
442 builder.WithName(*input.Name)
443 }
444
445 entry, err := builder.Create(ctx)
446 if err != nil {
447 return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil
448 }
449
450 formattedDate := date.Format("2006-01-02")
451
452 return &mcp.CallToolResult{
453 Content: []mcp.Content{&mcp.TextContent{
454 Text: "Journal entry created for " + formattedDate,
455 }},
456 }, CreateOutput{Entity: input.Entity, ID: entry.ID}, nil
457}