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