1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package notes provides MCP resources for filtered note lists.
6package notes
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "sort"
14 "strings"
15 "time"
16
17 "git.secluded.site/go-lunatask"
18 "git.secluded.site/lune/internal/mcp/shared"
19 "github.com/modelcontextprotocol/go-sdk/mcp"
20)
21
22// ErrUnknownNotebook indicates the notebook reference could not be resolved.
23var ErrUnknownNotebook = errors.New("unknown notebook")
24
25// Resource URIs for note list filters.
26const (
27 ResourceURIAll = "lunatask://notes"
28 ResourceURIPinned = "lunatask://notes/pinned"
29 ResourceURIRecent = "lunatask://notes/recent"
30)
31
32// Notebook-scoped resource templates.
33const (
34 NotebookNotesTemplate = "lunatask://notes/{notebook}"
35 NotebookNotesPinnedTemplate = "lunatask://notes/{notebook}/pinned"
36 NotebookNotesRecentTemplate = "lunatask://notes/{notebook}/recent"
37)
38
39// Resource descriptions.
40const (
41 AllDescription = `All notes. EXPENSIVE - prefer filtered resources.`
42
43 PinnedDescription = `Pinned notes only.`
44
45 RecentDescription = `Recently created or updated notes (last 7 days).`
46
47 NotebookNotesDescription = `Notes in a specific notebook. EXPENSIVE - prefer filtered resources.
48{notebook} accepts config key or UUID.`
49
50 NotebookFilteredDescription = `Filtered notes in notebook. {notebook} accepts config key or UUID.`
51)
52
53// RecentWindow is the time window for recent notes.
54const RecentWindow = 7 * 24 * time.Hour
55
56// Handler handles note list resource requests.
57type Handler struct {
58 client *lunatask.Client
59 notebooks []shared.NotebookProvider
60}
61
62// NewHandler creates a new notes resource handler.
63func NewHandler(accessToken string, notebooks []shared.NotebookProvider) *Handler {
64 return &Handler{
65 client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
66 notebooks: notebooks,
67 }
68}
69
70// noteSummary represents a note in list output.
71type noteSummary struct {
72 DeepLink string `json:"deep_link"`
73 NotebookID *string `json:"notebook_id,omitempty"`
74 DateOn *string `json:"date_on,omitempty"`
75 Pinned bool `json:"pinned"`
76 CreatedAt string `json:"created_at"`
77 UpdatedAt string `json:"updated_at"`
78}
79
80// FilterType identifies which filter to apply.
81type FilterType int
82
83// Filter type constants.
84const (
85 TypeAll FilterType = iota
86 TypePinned
87 TypeRecent
88)
89
90// HandleReadAll handles the all notes resource.
91func (h *Handler) HandleReadAll(
92 ctx context.Context,
93 req *mcp.ReadResourceRequest,
94) (*mcp.ReadResourceResult, error) {
95 return h.handleFiltered(ctx, req, TypeAll, "")
96}
97
98// HandleReadPinned handles the pinned notes resource.
99func (h *Handler) HandleReadPinned(
100 ctx context.Context,
101 req *mcp.ReadResourceRequest,
102) (*mcp.ReadResourceResult, error) {
103 return h.handleFiltered(ctx, req, TypePinned, "")
104}
105
106// HandleReadRecent handles the recent notes resource.
107func (h *Handler) HandleReadRecent(
108 ctx context.Context,
109 req *mcp.ReadResourceRequest,
110) (*mcp.ReadResourceResult, error) {
111 return h.handleFiltered(ctx, req, TypeRecent, "")
112}
113
114// HandleReadNotebookNotes handles notebook-scoped note resources.
115func (h *Handler) HandleReadNotebookNotes(
116 ctx context.Context,
117 req *mcp.ReadResourceRequest,
118) (*mcp.ReadResourceResult, error) {
119 notebookRef, filterType := parseNotebookURI(req.Params.URI)
120 if notebookRef == "" {
121 return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI))
122 }
123
124 notebookID, err := h.resolveNotebookRef(notebookRef)
125 if err != nil {
126 return nil, fmt.Errorf("invalid notebook %q: %w", notebookRef, mcp.ResourceNotFoundError(req.Params.URI))
127 }
128
129 return h.handleFiltered(ctx, req, filterType, notebookID)
130}
131
132// resolveNotebookRef resolves a notebook reference to a UUID.
133// Accepts config key or UUID.
134func (h *Handler) resolveNotebookRef(input string) (string, error) {
135 if err := lunatask.ValidateUUID(input); err == nil {
136 return input, nil
137 }
138
139 for _, nb := range h.notebooks {
140 if nb.Key == input {
141 return nb.ID, nil
142 }
143 }
144
145 return "", fmt.Errorf("%w: %s", ErrUnknownNotebook, input)
146}
147
148func (h *Handler) handleFiltered(
149 ctx context.Context,
150 req *mcp.ReadResourceRequest,
151 filterType FilterType,
152 notebookID string,
153) (*mcp.ReadResourceResult, error) {
154 notes, err := h.client.ListNotes(ctx, nil)
155 if err != nil {
156 return nil, fmt.Errorf("fetching notes: %w", err)
157 }
158
159 if notebookID != "" {
160 notes = filterByNotebook(notes, notebookID)
161 }
162
163 notes = applyFilter(notes, filterType)
164
165 summaries := buildSummaries(notes)
166
167 data, err := json.MarshalIndent(summaries, "", " ")
168 if err != nil {
169 return nil, fmt.Errorf("marshaling notes: %w", err)
170 }
171
172 return &mcp.ReadResourceResult{
173 Contents: []*mcp.ResourceContents{{
174 URI: req.Params.URI,
175 MIMEType: "application/json",
176 Text: string(data),
177 }},
178 }, nil
179}
180
181func applyFilter(notes []lunatask.Note, filterType FilterType) []lunatask.Note {
182 switch filterType {
183 case TypeAll:
184 return notes
185 case TypePinned:
186 return filterPinned(notes)
187 case TypeRecent:
188 return filterRecent(notes)
189 default:
190 return notes
191 }
192}
193
194func filterPinned(notes []lunatask.Note) []lunatask.Note {
195 result := make([]lunatask.Note, 0)
196
197 for _, note := range notes {
198 if note.Pinned {
199 result = append(result, note)
200 }
201 }
202
203 return result
204}
205
206func filterRecent(notes []lunatask.Note) []lunatask.Note {
207 cutoff := time.Now().Add(-RecentWindow)
208 result := make([]lunatask.Note, 0)
209
210 for _, note := range notes {
211 if note.CreatedAt.After(cutoff) || note.UpdatedAt.After(cutoff) {
212 result = append(result, note)
213 }
214 }
215
216 sort.Slice(result, func(i, j int) bool {
217 return result[i].UpdatedAt.After(result[j].UpdatedAt)
218 })
219
220 return result
221}
222
223func filterByNotebook(notes []lunatask.Note, notebookID string) []lunatask.Note {
224 result := make([]lunatask.Note, 0)
225
226 for _, note := range notes {
227 if note.NotebookID != nil && *note.NotebookID == notebookID {
228 result = append(result, note)
229 }
230 }
231
232 return result
233}
234
235func buildSummaries(notes []lunatask.Note) []noteSummary {
236 summaries := make([]noteSummary, 0, len(notes))
237
238 for _, note := range notes {
239 summary := noteSummary{
240 NotebookID: note.NotebookID,
241 Pinned: note.Pinned,
242 CreatedAt: note.CreatedAt.Format(time.RFC3339),
243 UpdatedAt: note.UpdatedAt.Format(time.RFC3339),
244 }
245
246 summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
247
248 if note.DateOn != nil {
249 s := note.DateOn.Format("2006-01-02")
250 summary.DateOn = &s
251 }
252
253 summaries = append(summaries, summary)
254 }
255
256 return summaries
257}
258
259// parseNotebookURI extracts notebook and filter type from notebook-scoped URIs.
260// Examples:
261// - lunatask://notes/work -> work, TypeAll
262// - lunatask://notes/work/pinned -> work, TypePinned
263// - lunatask://notes/work/recent -> work, TypeRecent
264func parseNotebookURI(uri string) (string, FilterType) {
265 const prefix = "lunatask://notes/"
266
267 filterNameToType := map[string]FilterType{
268 "pinned": TypePinned,
269 "recent": TypeRecent,
270 }
271
272 if !strings.HasPrefix(uri, prefix) {
273 return "", TypeAll
274 }
275
276 const maxParts = 2
277
278 rest := strings.TrimPrefix(uri, prefix)
279 parts := strings.SplitN(rest, "/", maxParts)
280
281 if len(parts) == 0 || parts[0] == "" {
282 return "", TypeAll
283 }
284
285 // Check if first part is a global filter (not a notebook ref)
286 if parts[0] == "pinned" || parts[0] == "recent" {
287 return "", TypeAll
288 }
289
290 notebookRef := parts[0]
291
292 if len(parts) == 1 {
293 return notebookRef, TypeAll
294 }
295
296 filterType, ok := filterNameToType[parts[1]]
297 if !ok {
298 return "", TypeAll
299 }
300
301 return notebookRef, filterType
302}