create.go

  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}