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/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}