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