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