1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5// Package note provides the MCP resource handler for individual Lunatask notes.
6package note
7
8import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "time"
13
14 "git.secluded.site/go-lunatask"
15 "github.com/modelcontextprotocol/go-sdk/mcp"
16)
17
18// ResourceTemplate is the URI template for note resources.
19const ResourceTemplate = "lunatask://note/{id}"
20
21// ResourceDescription describes the note resource for LLMs.
22const ResourceDescription = `Reads metadata for a specific Lunatask note by ID or deep link.
23
24Due to end-to-end encryption, note title and content are not available.
25Returns metadata including notebook, date, pinned status, and sources.`
26
27// sourceInfo represents a source reference in the response.
28type sourceInfo struct {
29 Source string `json:"source"`
30 SourceID string `json:"source_id"`
31}
32
33// noteInfo represents note metadata in the resource response.
34type noteInfo struct {
35 DeepLink string `json:"deep_link"`
36 NotebookID *string `json:"notebook_id,omitempty"`
37 DateOn *string `json:"date_on,omitempty"`
38 Pinned bool `json:"pinned"`
39 Sources []sourceInfo `json:"sources,omitempty"`
40 CreatedAt string `json:"created_at"`
41 UpdatedAt string `json:"updated_at"`
42}
43
44// Handler handles note resource requests.
45type Handler struct {
46 client *lunatask.Client
47}
48
49// NewHandler creates a new note resource handler.
50func NewHandler(accessToken string) *Handler {
51 return &Handler{
52 client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
53 }
54}
55
56// HandleRead returns metadata for a specific note.
57func (h *Handler) HandleRead(
58 ctx context.Context,
59 req *mcp.ReadResourceRequest,
60) (*mcp.ReadResourceResult, error) {
61 _, id, err := lunatask.ParseReference(req.Params.URI)
62 if err != nil {
63 return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI))
64 }
65
66 note, err := h.client.GetNote(ctx, id)
67 if err != nil {
68 return nil, fmt.Errorf("fetching note: %w", err)
69 }
70
71 info := buildNoteInfo(note)
72
73 data, err := json.MarshalIndent(info, "", " ")
74 if err != nil {
75 return nil, fmt.Errorf("marshaling note: %w", err)
76 }
77
78 return &mcp.ReadResourceResult{
79 Contents: []*mcp.ResourceContents{{
80 URI: req.Params.URI,
81 MIMEType: "application/json",
82 Text: string(data),
83 }},
84 }, nil
85}
86
87func buildNoteInfo(note *lunatask.Note) noteInfo {
88 info := noteInfo{
89 NotebookID: note.NotebookID,
90 Pinned: note.Pinned,
91 CreatedAt: note.CreatedAt.Format(time.RFC3339),
92 UpdatedAt: note.UpdatedAt.Format(time.RFC3339),
93 }
94
95 info.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
96
97 if note.DateOn != nil {
98 s := note.DateOn.Format("2006-01-02")
99 info.DateOn = &s
100 }
101
102 if len(note.Sources) > 0 {
103 info.Sources = make([]sourceInfo, 0, len(note.Sources))
104 for _, src := range note.Sources {
105 info.Sources = append(info.Sources, sourceInfo{
106 Source: src.Source,
107 SourceID: src.SourceID,
108 })
109 }
110 }
111
112 return info
113}