1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package goal provides MCP tools for goal operations.
6package goal
7
8import (
9 "context"
10 "fmt"
11 "strings"
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// ListToolName is the name of the list goals tool.
19const ListToolName = "list_goals"
20
21// ListToolDescription describes the list goals tool for LLMs.
22const ListToolDescription = `Lists goals for an area. Use list_areas to find area IDs first.
23
24Required:
25- area_id: Area UUID, deep link, or config key
26
27Returns goal metadata (id, name, key) for the specified area.`
28
29// ListInput is the input schema for listing goals.
30type ListInput struct {
31 AreaID string `json:"area_id" jsonschema:"required"`
32}
33
34// Summary represents a goal in the list output.
35type Summary struct {
36 ID string `json:"id"`
37 Name string `json:"name"`
38 Key string `json:"key"`
39}
40
41// ListOutput is the output schema for listing goals.
42type ListOutput struct {
43 Goals []Summary `json:"goals"`
44 Count int `json:"count"`
45 AreaID string `json:"area_id"`
46}
47
48// Handler handles goal tool requests.
49type Handler struct {
50 areas []shared.AreaProvider
51}
52
53// NewHandler creates a new goal tool handler.
54func NewHandler(areas []shared.AreaProvider) *Handler {
55 return &Handler{areas: areas}
56}
57
58// HandleList lists goals for an area.
59func (h *Handler) HandleList(
60 _ context.Context,
61 _ *mcp.CallToolRequest,
62 input ListInput,
63) (*mcp.CallToolResult, ListOutput, error) {
64 area := h.resolveAreaRef(input.AreaID)
65 if area == nil {
66 return shared.ErrorResult("unknown area: " + input.AreaID), ListOutput{}, nil
67 }
68
69 summaries := make([]Summary, 0, len(area.Goals))
70
71 for _, goal := range area.Goals {
72 summaries = append(summaries, Summary{
73 ID: goal.ID,
74 Name: goal.Name,
75 Key: goal.Key,
76 })
77 }
78
79 output := ListOutput{
80 Goals: summaries,
81 Count: len(summaries),
82 AreaID: area.ID,
83 }
84
85 text := formatListText(summaries, area.Name)
86
87 return &mcp.CallToolResult{
88 Content: []mcp.Content{&mcp.TextContent{Text: text}},
89 }, output, nil
90}
91
92// resolveAreaRef resolves an area reference to an AreaProvider.
93// Accepts config key, UUID, or deep link.
94func (h *Handler) resolveAreaRef(input string) *shared.AreaProvider {
95 // Try UUID or deep link first
96 if _, id, err := lunatask.ParseReference(input); err == nil {
97 for i := range h.areas {
98 if h.areas[i].ID == id {
99 return &h.areas[i]
100 }
101 }
102 }
103
104 // Try config key lookup
105 for i := range h.areas {
106 if h.areas[i].Key == input {
107 return &h.areas[i]
108 }
109 }
110
111 return nil
112}
113
114func formatListText(goals []Summary, areaName string) string {
115 if len(goals) == 0 {
116 return fmt.Sprintf("No goals configured for area %q", areaName)
117 }
118
119 var text strings.Builder
120
121 text.WriteString(fmt.Sprintf("Found %d goal(s) in %q:\n", len(goals), areaName))
122
123 for _, g := range goals {
124 text.WriteString(fmt.Sprintf("- %s: %s (%s)\n", g.Key, g.Name, g.ID))
125 }
126
127 return text.String()
128}