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