handler.go

  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}