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