query.go

   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}