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