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