1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package crud
6
7import (
8 "context"
9 "fmt"
10 "strings"
11 "time"
12
13 "git.secluded.site/go-lunatask"
14 "git.secluded.site/lune/internal/mcp/shared"
15 "github.com/google/jsonschema-go/jsonschema"
16 "github.com/modelcontextprotocol/go-sdk/mcp"
17)
18
19// QueryToolName is the name of the consolidated query tool.
20const QueryToolName = "query"
21
22// QueryToolDescription describes the query tool for LLMs.
23const QueryToolDescription = `Query Lunatask entities. Fallback for agents without MCP resource support.
24
25Provide id for single entity details, omit for filtered list.
26E2E encryption means names/content unavailable in lists—only metadata.
27Prefer MCP resources (lunatask://tasks/today, lunatask://areas) when available.`
28
29// QueryToolAnnotations returns hints about tool behavior.
30func QueryToolAnnotations() *mcp.ToolAnnotations {
31 return &mcp.ToolAnnotations{
32 ReadOnlyHint: true,
33 OpenWorldHint: ptr(true),
34 Title: "Query entities",
35 }
36}
37
38// QueryInputSchema returns a custom schema with enum constraints.
39func QueryInputSchema() *jsonschema.Schema {
40 schema, _ := jsonschema.For[QueryInput](nil)
41
42 schema.Properties["entity"].Enum = []any{
43 EntityTask, EntityNote, EntityPerson, EntityArea, EntityGoal, EntityNotebook, EntityHabit,
44 }
45 schema.Properties["status"].Enum = toAnyStrings(lunatask.AllTaskStatuses())
46
47 return schema
48}
49
50// QueryInput is the input schema for the consolidated query tool.
51type QueryInput struct {
52 Entity string `json:"entity" jsonschema:"Entity type to query"`
53 ID *string `json:"id,omitempty" jsonschema:"UUID or deep link for single entity lookup"`
54
55 // Task/Goal filters
56 AreaID *string `json:"area_id,omitempty" jsonschema:"Filter by area (UUID, deep link, or config key)"`
57
58 // Task filters
59 Status *string `json:"status,omitempty" jsonschema:"Filter by task status"`
60 IncludeCompleted *bool `json:"include_completed,omitempty" jsonschema:"Include completed tasks (default: false)"`
61
62 // Note filters
63 NotebookID *string `json:"notebook_id,omitempty" jsonschema:"Filter by notebook UUID"`
64
65 // Note/Person filters
66 Source *string `json:"source,omitempty" jsonschema:"Filter by source identifier"`
67 SourceID *string `json:"source_id,omitempty" jsonschema:"Filter by source-specific ID"`
68}
69
70// QueryOutput is the output schema for the consolidated query tool.
71type QueryOutput struct {
72 Entity string `json:"entity"`
73 Count int `json:"count,omitempty"`
74 // Results will be entity-specific; kept as any for flexibility
75 Items any `json:"items,omitempty"`
76 // Single item fields (when ID is provided)
77 DeepLink string `json:"deep_link,omitempty"`
78}
79
80// HandleQuery queries entities based on the entity type.
81func (h *Handler) HandleQuery(
82 ctx context.Context,
83 _ *mcp.CallToolRequest,
84 input QueryInput,
85) (*mcp.CallToolResult, QueryOutput, error) {
86 switch input.Entity {
87 case EntityTask:
88 return h.queryTask(ctx, input)
89 case EntityNote:
90 return h.queryNote(ctx, input)
91 case EntityPerson:
92 return h.queryPerson(ctx, input)
93 case EntityArea:
94 return h.queryArea(ctx, input)
95 case EntityGoal:
96 return h.queryGoal(ctx, input)
97 case EntityNotebook:
98 return h.queryNotebook(ctx, input)
99 case EntityHabit:
100 return h.queryHabit(ctx, input)
101 default:
102 return shared.ErrorResult("invalid entity: must be task, note, person, area, goal, notebook, or habit"),
103 QueryOutput{Entity: input.Entity}, nil
104 }
105}
106
107// TaskSummary represents a task in list output.
108type TaskSummary struct {
109 DeepLink string `json:"deep_link"`
110 Status *string `json:"status,omitempty"`
111 Priority *int `json:"priority,omitempty"`
112 ScheduledOn *string `json:"scheduled_on,omitempty"`
113 CreatedAt string `json:"created_at"`
114 AreaID *string `json:"area_id,omitempty"`
115 GoalID *string `json:"goal_id,omitempty"`
116}
117
118// TaskDetail represents detailed task information.
119type TaskDetail struct {
120 DeepLink string `json:"deep_link"`
121 Status *string `json:"status,omitempty"`
122 Priority *int `json:"priority,omitempty"`
123 Estimate *int `json:"estimate,omitempty"`
124 ScheduledOn *string `json:"scheduled_on,omitempty"`
125 CompletedAt *string `json:"completed_at,omitempty"`
126 CreatedAt string `json:"created_at"`
127 UpdatedAt string `json:"updated_at"`
128 AreaID *string `json:"area_id,omitempty"`
129 GoalID *string `json:"goal_id,omitempty"`
130 Important *bool `json:"important,omitempty"`
131 Urgent *bool `json:"urgent,omitempty"`
132}
133
134func (h *Handler) queryTask(
135 ctx context.Context,
136 input QueryInput,
137) (*mcp.CallToolResult, QueryOutput, error) {
138 if input.ID != nil {
139 return h.showTask(ctx, *input.ID)
140 }
141
142 return h.listTasks(ctx, input)
143}
144
145func (h *Handler) showTask(
146 ctx context.Context,
147 id string,
148) (*mcp.CallToolResult, QueryOutput, error) {
149 _, taskID, err := lunatask.ParseReference(id)
150 if err != nil {
151 return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"),
152 QueryOutput{Entity: EntityTask}, nil
153 }
154
155 task, err := h.client.GetTask(ctx, taskID)
156 if err != nil {
157 return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityTask}, nil
158 }
159
160 detail := TaskDetail{
161 CreatedAt: task.CreatedAt.Format(time.RFC3339),
162 UpdatedAt: task.UpdatedAt.Format(time.RFC3339),
163 AreaID: task.AreaID,
164 GoalID: task.GoalID,
165 }
166
167 detail.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
168
169 if task.Status != nil {
170 s := string(*task.Status)
171 detail.Status = &s
172 }
173
174 if task.Priority != nil {
175 p := int(*task.Priority)
176 detail.Priority = &p
177 }
178
179 if task.Estimate != nil {
180 detail.Estimate = task.Estimate
181 }
182
183 if task.ScheduledOn != nil {
184 s := task.ScheduledOn.Format("2006-01-02")
185 detail.ScheduledOn = &s
186 }
187
188 if task.CompletedAt != nil {
189 s := task.CompletedAt.Format(time.RFC3339)
190 detail.CompletedAt = &s
191 }
192
193 if task.Eisenhower != nil {
194 important := task.Eisenhower.IsImportant()
195 urgent := task.Eisenhower.IsUrgent()
196 detail.Important = &important
197 detail.Urgent = &urgent
198 }
199
200 text := formatTaskShowText(detail)
201
202 return &mcp.CallToolResult{
203 Content: []mcp.Content{&mcp.TextContent{Text: text}},
204 }, QueryOutput{Entity: EntityTask, DeepLink: detail.DeepLink, Items: detail}, nil
205}
206
207func (h *Handler) listTasks(
208 ctx context.Context,
209 input QueryInput,
210) (*mcp.CallToolResult, QueryOutput, error) {
211 if input.AreaID != nil {
212 if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
213 return shared.ErrorResult("invalid area_id: expected UUID"),
214 QueryOutput{Entity: EntityTask}, nil
215 }
216 }
217
218 if input.Status != nil {
219 if _, err := lunatask.ParseTaskStatus(*input.Status); err != nil {
220 return shared.ErrorResult(fmt.Sprintf("invalid status '%s'; valid: %s", *input.Status, shared.FormatAllStatuses())),
221 QueryOutput{Entity: EntityTask}, nil
222 }
223 }
224
225 tasks, err := h.client.ListTasks(ctx, nil)
226 if err != nil {
227 return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityTask}, nil
228 }
229
230 opts := &lunatask.TaskFilterOptions{
231 AreaID: input.AreaID,
232 IncludeCompleted: input.IncludeCompleted != nil && *input.IncludeCompleted,
233 Today: time.Now(),
234 }
235
236 if input.Status != nil {
237 s := lunatask.TaskStatus(*input.Status)
238 opts.Status = &s
239 }
240
241 filtered := lunatask.FilterTasks(tasks, opts)
242 summaries := buildTaskSummaries(filtered)
243 text := formatTaskListText(summaries)
244
245 return &mcp.CallToolResult{
246 Content: []mcp.Content{&mcp.TextContent{Text: text}},
247 }, QueryOutput{
248 Entity: EntityTask,
249 Items: summaries,
250 Count: len(summaries),
251 }, nil
252}
253
254func buildTaskSummaries(tasks []lunatask.Task) []TaskSummary {
255 summaries := make([]TaskSummary, 0, len(tasks))
256
257 for _, task := range tasks {
258 summary := TaskSummary{
259 CreatedAt: task.CreatedAt.Format(time.RFC3339),
260 AreaID: task.AreaID,
261 GoalID: task.GoalID,
262 }
263
264 summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID)
265
266 if task.Status != nil {
267 s := string(*task.Status)
268 summary.Status = &s
269 }
270
271 if task.Priority != nil {
272 p := int(*task.Priority)
273 summary.Priority = &p
274 }
275
276 if task.ScheduledOn != nil {
277 s := task.ScheduledOn.Format("2006-01-02")
278 summary.ScheduledOn = &s
279 }
280
281 summaries = append(summaries, summary)
282 }
283
284 return summaries
285}
286
287func formatTaskListText(summaries []TaskSummary) string {
288 if len(summaries) == 0 {
289 return "No tasks found."
290 }
291
292 var builder strings.Builder
293
294 builder.WriteString(fmt.Sprintf("Found %d task(s):\n", len(summaries)))
295
296 for _, summary := range summaries {
297 status := "unknown"
298 if summary.Status != nil {
299 status = *summary.Status
300 }
301
302 builder.WriteString(fmt.Sprintf("- %s (%s)\n", summary.DeepLink, status))
303 }
304
305 builder.WriteString("\nUse query with id for full details.")
306
307 return builder.String()
308}
309
310func formatTaskShowText(detail TaskDetail) string {
311 var builder strings.Builder
312
313 builder.WriteString(fmt.Sprintf("Task: %s\n", detail.DeepLink))
314 writeOptionalField(&builder, "Status", detail.Status)
315 writeOptionalIntField(&builder, "Priority", detail.Priority)
316 writeOptionalField(&builder, "Scheduled", detail.ScheduledOn)
317 writeOptionalMinutesField(&builder, "Estimate", detail.Estimate)
318 writeEisenhowerField(&builder, detail.Important, detail.Urgent)
319 builder.WriteString(fmt.Sprintf("Created: %s\n", detail.CreatedAt))
320 builder.WriteString("Updated: " + detail.UpdatedAt)
321 writeOptionalField(&builder, "\nCompleted", detail.CompletedAt)
322
323 return builder.String()
324}
325
326func writeOptionalField(builder *strings.Builder, label string, value *string) {
327 if value != nil {
328 fmt.Fprintf(builder, "%s: %s\n", label, *value)
329 }
330}
331
332func writeOptionalIntField(builder *strings.Builder, label string, value *int) {
333 if value != nil {
334 fmt.Fprintf(builder, "%s: %d\n", label, *value)
335 }
336}
337
338func writeOptionalMinutesField(builder *strings.Builder, label string, value *int) {
339 if value != nil {
340 fmt.Fprintf(builder, "%s: %d min\n", label, *value)
341 }
342}
343
344func writeEisenhowerField(builder *strings.Builder, important, urgent *bool) {
345 var parts []string
346
347 if important != nil && *important {
348 parts = append(parts, "important")
349 }
350
351 if urgent != nil && *urgent {
352 parts = append(parts, "urgent")
353 }
354
355 if len(parts) > 0 {
356 fmt.Fprintf(builder, "Eisenhower: %s\n", strings.Join(parts, ", "))
357 }
358}
359
360// NoteSummary represents a note in list output.
361type NoteSummary struct {
362 DeepLink string `json:"deep_link"`
363 NotebookID *string `json:"notebook_id,omitempty"`
364 DateOn *string `json:"date_on,omitempty"`
365 Pinned bool `json:"pinned"`
366 CreatedAt string `json:"created_at"`
367}
368
369// NoteSource represents a source reference in note output.
370type NoteSource struct {
371 Source string `json:"source"`
372 SourceID string `json:"source_id"`
373}
374
375// NoteDetail represents detailed note information.
376type NoteDetail struct {
377 DeepLink string `json:"deep_link"`
378 NotebookID *string `json:"notebook_id,omitempty"`
379 DateOn *string `json:"date_on,omitempty"`
380 Pinned bool `json:"pinned"`
381 Sources []NoteSource `json:"sources,omitempty"`
382 CreatedAt string `json:"created_at"`
383 UpdatedAt string `json:"updated_at"`
384}
385
386func (h *Handler) queryNote(
387 ctx context.Context,
388 input QueryInput,
389) (*mcp.CallToolResult, QueryOutput, error) {
390 if input.ID != nil {
391 return h.showNote(ctx, *input.ID)
392 }
393
394 return h.listNotes(ctx, input)
395}
396
397func (h *Handler) showNote(
398 ctx context.Context,
399 id string,
400) (*mcp.CallToolResult, QueryOutput, error) {
401 _, noteID, err := lunatask.ParseReference(id)
402 if err != nil {
403 return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"),
404 QueryOutput{Entity: EntityNote}, nil
405 }
406
407 note, err := h.client.GetNote(ctx, noteID)
408 if err != nil {
409 return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityNote}, nil
410 }
411
412 detail := NoteDetail{
413 NotebookID: note.NotebookID,
414 Pinned: note.Pinned,
415 CreatedAt: note.CreatedAt.Format(time.RFC3339),
416 UpdatedAt: note.UpdatedAt.Format(time.RFC3339),
417 }
418
419 detail.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
420
421 if note.DateOn != nil {
422 s := note.DateOn.Format("2006-01-02")
423 detail.DateOn = &s
424 }
425
426 if len(note.Sources) > 0 {
427 detail.Sources = make([]NoteSource, 0, len(note.Sources))
428 for _, src := range note.Sources {
429 detail.Sources = append(detail.Sources, NoteSource{
430 Source: src.Source,
431 SourceID: src.SourceID,
432 })
433 }
434 }
435
436 text := formatNoteShowText(detail, h.notebooks)
437
438 return &mcp.CallToolResult{
439 Content: []mcp.Content{&mcp.TextContent{Text: text}},
440 }, QueryOutput{Entity: EntityNote, DeepLink: detail.DeepLink, Items: detail}, nil
441}
442
443func (h *Handler) listNotes(
444 ctx context.Context,
445 input QueryInput,
446) (*mcp.CallToolResult, QueryOutput, error) {
447 if input.NotebookID != nil {
448 if err := lunatask.ValidateUUID(*input.NotebookID); err != nil {
449 return shared.ErrorResult("invalid notebook_id: expected UUID"),
450 QueryOutput{Entity: EntityNote}, nil
451 }
452 }
453
454 opts := buildNoteListOptions(input)
455
456 notes, err := h.client.ListNotes(ctx, opts)
457 if err != nil {
458 return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityNote}, nil
459 }
460
461 if input.NotebookID != nil {
462 notes = filterNotesByNotebook(notes, *input.NotebookID)
463 }
464
465 summaries := buildNoteSummaries(notes)
466 text := formatNoteListText(summaries, h.notebooks)
467
468 return &mcp.CallToolResult{
469 Content: []mcp.Content{&mcp.TextContent{Text: text}},
470 }, QueryOutput{
471 Entity: EntityNote,
472 Items: summaries,
473 Count: len(summaries),
474 }, nil
475}
476
477func buildNoteListOptions(input QueryInput) *lunatask.ListNotesOptions {
478 if input.Source == nil && input.SourceID == nil {
479 return nil
480 }
481
482 opts := &lunatask.ListNotesOptions{}
483
484 if input.Source != nil {
485 opts.Source = input.Source
486 }
487
488 if input.SourceID != nil {
489 opts.SourceID = input.SourceID
490 }
491
492 return opts
493}
494
495func filterNotesByNotebook(notes []lunatask.Note, notebookID string) []lunatask.Note {
496 filtered := make([]lunatask.Note, 0, len(notes))
497
498 for _, note := range notes {
499 if note.NotebookID != nil && *note.NotebookID == notebookID {
500 filtered = append(filtered, note)
501 }
502 }
503
504 return filtered
505}
506
507func buildNoteSummaries(notes []lunatask.Note) []NoteSummary {
508 summaries := make([]NoteSummary, 0, len(notes))
509
510 for _, note := range notes {
511 summary := NoteSummary{
512 NotebookID: note.NotebookID,
513 Pinned: note.Pinned,
514 CreatedAt: note.CreatedAt.Format("2006-01-02"),
515 }
516
517 summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
518
519 if note.DateOn != nil {
520 dateStr := note.DateOn.Format("2006-01-02")
521 summary.DateOn = &dateStr
522 }
523
524 summaries = append(summaries, summary)
525 }
526
527 return summaries
528}
529
530func formatNoteListText(summaries []NoteSummary, notebooks []shared.NotebookProvider) string {
531 if len(summaries) == 0 {
532 return "No notes found"
533 }
534
535 var text strings.Builder
536
537 text.WriteString(fmt.Sprintf("Found %d note(s):\n", len(summaries)))
538
539 for _, item := range summaries {
540 text.WriteString("- ")
541 text.WriteString(item.DeepLink)
542
543 var details []string
544
545 if item.NotebookID != nil {
546 nbName := *item.NotebookID
547 for _, nb := range notebooks {
548 if nb.ID == *item.NotebookID {
549 nbName = nb.Key
550
551 break
552 }
553 }
554
555 details = append(details, "notebook: "+nbName)
556 }
557
558 if item.Pinned {
559 details = append(details, "pinned")
560 }
561
562 if len(details) > 0 {
563 text.WriteString(" (")
564 text.WriteString(strings.Join(details, ", "))
565 text.WriteString(")")
566 }
567
568 text.WriteString("\n")
569 }
570
571 text.WriteString("\nUse query with id for full details.")
572
573 return text.String()
574}
575
576func formatNoteShowText(detail NoteDetail, notebooks []shared.NotebookProvider) string {
577 var builder strings.Builder
578
579 builder.WriteString(fmt.Sprintf("Note: %s\n", detail.DeepLink))
580
581 if detail.NotebookID != nil {
582 nbName := *detail.NotebookID
583 for _, nb := range notebooks {
584 if nb.ID == *detail.NotebookID {
585 nbName = nb.Key
586
587 break
588 }
589 }
590
591 builder.WriteString(fmt.Sprintf("Notebook: %s\n", nbName))
592 }
593
594 if detail.DateOn != nil {
595 builder.WriteString(fmt.Sprintf("Date: %s\n", *detail.DateOn))
596 }
597
598 if detail.Pinned {
599 builder.WriteString("Pinned: yes\n")
600 }
601
602 if len(detail.Sources) > 0 {
603 builder.WriteString("Sources:\n")
604
605 for _, src := range detail.Sources {
606 builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID))
607 }
608 }
609
610 builder.WriteString(fmt.Sprintf("Created: %s\n", detail.CreatedAt))
611 builder.WriteString("Updated: " + detail.UpdatedAt)
612
613 return builder.String()
614}
615
616func (h *Handler) queryPerson(
617 ctx context.Context,
618 input QueryInput,
619) (*mcp.CallToolResult, QueryOutput, error) {
620 if input.ID != nil {
621 return h.showPerson(ctx, *input.ID)
622 }
623
624 return h.listPeople(ctx, input)
625}
626
627// PersonSummary represents a person in list output.
628type PersonSummary struct {
629 DeepLink string `json:"deep_link"`
630 RelationshipStrength *string `json:"relationship_strength,omitempty"`
631 CreatedAt string `json:"created_at"`
632}
633
634// PersonSource represents a source reference in person output.
635type PersonSource struct {
636 Source string `json:"source"`
637 SourceID string `json:"source_id"`
638}
639
640// PersonDetail represents detailed person information.
641type PersonDetail struct {
642 DeepLink string `json:"deep_link"`
643 RelationshipStrength *string `json:"relationship_strength,omitempty"`
644 Sources []PersonSource `json:"sources,omitempty"`
645 CreatedAt string `json:"created_at"`
646 UpdatedAt string `json:"updated_at"`
647}
648
649func (h *Handler) showPerson(
650 ctx context.Context,
651 id string,
652) (*mcp.CallToolResult, QueryOutput, error) {
653 _, personID, err := lunatask.ParseReference(id)
654 if err != nil {
655 return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"),
656 QueryOutput{Entity: EntityPerson}, nil
657 }
658
659 person, err := h.client.GetPerson(ctx, personID)
660 if err != nil {
661 return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityPerson}, nil
662 }
663
664 detail := PersonDetail{
665 CreatedAt: person.CreatedAt.Format(time.RFC3339),
666 UpdatedAt: person.UpdatedAt.Format(time.RFC3339),
667 }
668
669 detail.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
670
671 if person.RelationshipStrength != nil {
672 s := string(*person.RelationshipStrength)
673 detail.RelationshipStrength = &s
674 }
675
676 if len(person.Sources) > 0 {
677 detail.Sources = make([]PersonSource, 0, len(person.Sources))
678 for _, src := range person.Sources {
679 detail.Sources = append(detail.Sources, PersonSource{
680 Source: src.Source,
681 SourceID: src.SourceID,
682 })
683 }
684 }
685
686 text := formatPersonShowText(detail)
687
688 return &mcp.CallToolResult{
689 Content: []mcp.Content{&mcp.TextContent{Text: text}},
690 }, QueryOutput{Entity: EntityPerson, DeepLink: detail.DeepLink, Items: detail}, nil
691}
692
693func (h *Handler) listPeople(
694 ctx context.Context,
695 input QueryInput,
696) (*mcp.CallToolResult, QueryOutput, error) {
697 opts := buildPeopleListOptions(input)
698
699 people, err := h.client.ListPeople(ctx, opts)
700 if err != nil {
701 return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityPerson}, nil
702 }
703
704 summaries := buildPersonSummaries(people)
705 text := formatPeopleListText(summaries)
706
707 return &mcp.CallToolResult{
708 Content: []mcp.Content{&mcp.TextContent{Text: text}},
709 }, QueryOutput{
710 Entity: EntityPerson,
711 Items: summaries,
712 Count: len(summaries),
713 }, nil
714}
715
716func buildPeopleListOptions(input QueryInput) *lunatask.ListPeopleOptions {
717 if input.Source == nil && input.SourceID == nil {
718 return nil
719 }
720
721 opts := &lunatask.ListPeopleOptions{}
722
723 if input.Source != nil {
724 opts.Source = input.Source
725 }
726
727 if input.SourceID != nil {
728 opts.SourceID = input.SourceID
729 }
730
731 return opts
732}
733
734func buildPersonSummaries(people []lunatask.Person) []PersonSummary {
735 summaries := make([]PersonSummary, 0, len(people))
736
737 for _, person := range people {
738 deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
739
740 summary := PersonSummary{
741 DeepLink: deepLink,
742 CreatedAt: person.CreatedAt.Format("2006-01-02"),
743 }
744
745 if person.RelationshipStrength != nil {
746 rel := string(*person.RelationshipStrength)
747 summary.RelationshipStrength = &rel
748 }
749
750 summaries = append(summaries, summary)
751 }
752
753 return summaries
754}
755
756func formatPeopleListText(summaries []PersonSummary) string {
757 if len(summaries) == 0 {
758 return "No people found"
759 }
760
761 var text strings.Builder
762
763 text.WriteString(fmt.Sprintf("Found %d person(s):\n", len(summaries)))
764
765 for _, item := range summaries {
766 text.WriteString("- ")
767 text.WriteString(item.DeepLink)
768
769 if item.RelationshipStrength != nil {
770 text.WriteString(" (")
771 text.WriteString(*item.RelationshipStrength)
772 text.WriteString(")")
773 }
774
775 text.WriteString("\n")
776 }
777
778 text.WriteString("\nUse query with id for full details.")
779
780 return text.String()
781}
782
783func formatPersonShowText(detail PersonDetail) string {
784 var builder strings.Builder
785
786 builder.WriteString(fmt.Sprintf("Person: %s\n", detail.DeepLink))
787
788 if detail.RelationshipStrength != nil {
789 builder.WriteString(fmt.Sprintf("Relationship: %s\n", *detail.RelationshipStrength))
790 }
791
792 if len(detail.Sources) > 0 {
793 builder.WriteString("Sources:\n")
794
795 for _, src := range detail.Sources {
796 builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID))
797 }
798 }
799
800 builder.WriteString(fmt.Sprintf("Created: %s\n", detail.CreatedAt))
801 builder.WriteString("Updated: " + detail.UpdatedAt)
802
803 return builder.String()
804}
805
806func (h *Handler) queryArea(
807 _ context.Context,
808 input QueryInput,
809) (*mcp.CallToolResult, QueryOutput, error) {
810 if input.ID != nil {
811 return shared.ErrorResult("area entities are config-based and do not support ID lookup"),
812 QueryOutput{Entity: input.Entity}, nil
813 }
814
815 summaries := make([]AreaSummary, 0, len(h.areas))
816
817 for _, area := range h.areas {
818 summaries = append(summaries, AreaSummary{
819 ID: area.ID,
820 Name: area.Name,
821 Key: area.Key,
822 Workflow: string(area.Workflow),
823 })
824 }
825
826 text := formatAreaListText(summaries)
827
828 return &mcp.CallToolResult{
829 Content: []mcp.Content{&mcp.TextContent{Text: text}},
830 }, QueryOutput{
831 Entity: EntityArea,
832 Items: summaries,
833 Count: len(summaries),
834 }, nil
835}
836
837// AreaSummary represents an area in list output.
838type AreaSummary struct {
839 ID string `json:"id"`
840 Name string `json:"name"`
841 Key string `json:"key"`
842 Workflow string `json:"workflow"`
843}
844
845func formatAreaListText(areas []AreaSummary) string {
846 if len(areas) == 0 {
847 return "No areas configured"
848 }
849
850 var text strings.Builder
851
852 text.WriteString(fmt.Sprintf("Found %d area(s):\n", len(areas)))
853
854 for _, a := range areas {
855 text.WriteString(fmt.Sprintf("- %s: %s (%s, workflow: %s)\n", a.Key, a.Name, a.ID, a.Workflow))
856 }
857
858 return text.String()
859}
860
861func (h *Handler) queryGoal(
862 _ context.Context,
863 input QueryInput,
864) (*mcp.CallToolResult, QueryOutput, error) {
865 if input.ID != nil {
866 return shared.ErrorResult("goal entities are config-based and do not support ID lookup"),
867 QueryOutput{Entity: input.Entity}, nil
868 }
869
870 if input.AreaID == nil {
871 return shared.ErrorResult("area_id is required for goal query"),
872 QueryOutput{Entity: input.Entity}, nil
873 }
874
875 area := h.resolveAreaRef(*input.AreaID)
876 if area == nil {
877 return shared.ErrorResult("unknown area: " + *input.AreaID),
878 QueryOutput{Entity: input.Entity}, nil
879 }
880
881 summaries := make([]GoalSummary, 0, len(area.Goals))
882
883 for _, goal := range area.Goals {
884 summaries = append(summaries, GoalSummary{
885 ID: goal.ID,
886 Name: goal.Name,
887 Key: goal.Key,
888 })
889 }
890
891 text := formatGoalListText(summaries, area.Name)
892
893 return &mcp.CallToolResult{
894 Content: []mcp.Content{&mcp.TextContent{Text: text}},
895 }, QueryOutput{
896 Entity: EntityGoal,
897 Items: summaries,
898 Count: len(summaries),
899 }, nil
900}
901
902// GoalSummary represents a goal in list output.
903type GoalSummary struct {
904 ID string `json:"id"`
905 Name string `json:"name"`
906 Key string `json:"key"`
907}
908
909// resolveAreaRef resolves an area reference to an AreaProvider.
910// Accepts config key, UUID, or deep link.
911func (h *Handler) resolveAreaRef(input string) *shared.AreaProvider {
912 // Try UUID or deep link first
913 if _, id, err := lunatask.ParseReference(input); err == nil {
914 for i := range h.areas {
915 if h.areas[i].ID == id {
916 return &h.areas[i]
917 }
918 }
919 }
920
921 // Try config key lookup
922 for i := range h.areas {
923 if h.areas[i].Key == input {
924 return &h.areas[i]
925 }
926 }
927
928 return nil
929}
930
931func formatGoalListText(goals []GoalSummary, areaName string) string {
932 if len(goals) == 0 {
933 return fmt.Sprintf("No goals configured for area %q", areaName)
934 }
935
936 var text strings.Builder
937
938 text.WriteString(fmt.Sprintf("Found %d goal(s) in %q:\n", len(goals), areaName))
939
940 for _, g := range goals {
941 text.WriteString(fmt.Sprintf("- %s: %s (%s)\n", g.Key, g.Name, g.ID))
942 }
943
944 return text.String()
945}
946
947func (h *Handler) queryNotebook(
948 _ context.Context,
949 input QueryInput,
950) (*mcp.CallToolResult, QueryOutput, error) {
951 if input.ID != nil {
952 return shared.ErrorResult("notebook entities are config-based and do not support ID lookup"),
953 QueryOutput{Entity: input.Entity}, nil
954 }
955
956 summaries := make([]NotebookSummary, 0, len(h.notebooks))
957
958 for _, nb := range h.notebooks {
959 summaries = append(summaries, NotebookSummary{
960 ID: nb.ID,
961 Name: nb.Name,
962 Key: nb.Key,
963 })
964 }
965
966 text := formatNotebookListText(summaries)
967
968 return &mcp.CallToolResult{
969 Content: []mcp.Content{&mcp.TextContent{Text: text}},
970 }, QueryOutput{
971 Entity: EntityNotebook,
972 Items: summaries,
973 Count: len(summaries),
974 }, nil
975}
976
977// NotebookSummary represents a notebook in list output.
978type NotebookSummary struct {
979 ID string `json:"id"`
980 Name string `json:"name"`
981 Key string `json:"key"`
982}
983
984func formatNotebookListText(notebooks []NotebookSummary) string {
985 if len(notebooks) == 0 {
986 return "No notebooks configured"
987 }
988
989 var text strings.Builder
990
991 text.WriteString(fmt.Sprintf("Found %d notebook(s):\n", len(notebooks)))
992
993 for _, nb := range notebooks {
994 text.WriteString(fmt.Sprintf("- %s (key: %s, id: %s)\n", nb.Name, nb.Key, nb.ID))
995 }
996
997 return text.String()
998}
999
1000func (h *Handler) queryHabit(
1001 _ context.Context,
1002 input QueryInput,
1003) (*mcp.CallToolResult, QueryOutput, error) {
1004 if input.ID != nil {
1005 return shared.ErrorResult("habit entities are config-based and do not support ID lookup"),
1006 QueryOutput{Entity: input.Entity}, nil
1007 }
1008
1009 summaries := make([]HabitSummary, 0, len(h.habits))
1010
1011 for _, habit := range h.habits {
1012 summaries = append(summaries, HabitSummary{
1013 ID: habit.ID,
1014 Name: habit.Name,
1015 Key: habit.Key,
1016 })
1017 }
1018
1019 text := formatHabitListText(summaries)
1020
1021 return &mcp.CallToolResult{
1022 Content: []mcp.Content{&mcp.TextContent{Text: text}},
1023 }, QueryOutput{
1024 Entity: EntityHabit,
1025 Items: summaries,
1026 Count: len(summaries),
1027 }, nil
1028}
1029
1030// HabitSummary represents a habit in list output.
1031type HabitSummary struct {
1032 ID string `json:"id"`
1033 Name string `json:"name"`
1034 Key string `json:"key"`
1035}
1036
1037func formatHabitListText(habits []HabitSummary) string {
1038 if len(habits) == 0 {
1039 return "No habits configured"
1040 }
1041
1042 var text strings.Builder
1043
1044 text.WriteString(fmt.Sprintf("Found %d habit(s):\n", len(habits)))
1045
1046 for _, h := range habits {
1047 text.WriteString(fmt.Sprintf("- %s: %s (%s)\n", h.Key, h.Name, h.ID))
1048 }
1049
1050 return text.String()
1051}