feat(mcp): add note CRUD tools

Amolith created

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/mcp/server.go                 |  42 +++++++
internal/mcp/tools/note/create.go | 113 ++++++++++++++++++++
internal/mcp/tools/note/delete.go |  63 +++++++++++
internal/mcp/tools/note/list.go   | 185 +++++++++++++++++++++++++++++++++
internal/mcp/tools/note/update.go | 110 +++++++++++++++++++
5 files changed, 512 insertions(+), 1 deletion(-)

Detailed changes

cmd/mcp/server.go 🔗

@@ -21,6 +21,7 @@ import (
 	"git.secluded.site/lune/internal/mcp/shared"
 	"git.secluded.site/lune/internal/mcp/tools/habit"
 	"git.secluded.site/lune/internal/mcp/tools/journal"
+	notetool "git.secluded.site/lune/internal/mcp/tools/note"
 	"git.secluded.site/lune/internal/mcp/tools/task"
 	"git.secluded.site/lune/internal/mcp/tools/timestamp"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
@@ -44,7 +45,7 @@ func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server {
 
 	registerResources(mcpServer, areaProviders, habitProviders, notebookProviders)
 	registerResourceTemplates(mcpServer, accessToken)
-	registerTools(mcpServer, cfg, accessToken, areaProviders, habitProviders)
+	registerTools(mcpServer, cfg, accessToken, areaProviders, habitProviders, notebookProviders)
 
 	return mcpServer
 }
@@ -127,6 +128,7 @@ func registerTools(
 	accessToken string,
 	areaProviders []shared.AreaProvider,
 	habitProviders []shared.HabitProvider,
+	notebookProviders []shared.NotebookProvider,
 ) {
 	tools := &cfg.MCP.Tools
 
@@ -139,6 +141,7 @@ func registerTools(
 	}
 
 	registerTaskTools(mcpServer, tools, accessToken, areaProviders)
+	registerNoteTools(mcpServer, tools, accessToken, notebookProviders)
 
 	if tools.TrackHabit {
 		habitHandler := habit.NewHandler(accessToken, habitProviders)
@@ -201,6 +204,43 @@ func registerTaskTools(
 	}
 }
 
+func registerNoteTools(
+	mcpServer *mcp.Server,
+	tools *config.ToolsConfig,
+	accessToken string,
+	notebookProviders []shared.NotebookProvider,
+) {
+	noteHandler := notetool.NewHandler(accessToken, notebookProviders)
+
+	if tools.CreateNote {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        notetool.CreateToolName,
+			Description: notetool.CreateToolDescription,
+		}, noteHandler.HandleCreate)
+	}
+
+	if tools.UpdateNote {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        notetool.UpdateToolName,
+			Description: notetool.UpdateToolDescription,
+		}, noteHandler.HandleUpdate)
+	}
+
+	if tools.DeleteNote {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        notetool.DeleteToolName,
+			Description: notetool.DeleteToolDescription,
+		}, noteHandler.HandleDelete)
+	}
+
+	if tools.ListNotes {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        notetool.ListToolName,
+			Description: notetool.ListToolDescription,
+		}, noteHandler.HandleList)
+	}
+}
+
 func runStdio(mcpServer *mcp.Server) error {
 	if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
 		return fmt.Errorf("stdio server error: %w", err)

internal/mcp/tools/note/create.go 🔗

@@ -0,0 +1,113 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package note provides MCP tools for Lunatask note operations.
+package note
+
+import (
+	"context"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// CreateToolName is the name of the create note tool.
+const CreateToolName = "create_note"
+
+// CreateToolDescription describes the create note tool for LLMs.
+const CreateToolDescription = `Creates a new note in Lunatask.
+
+Optional:
+- name: Note title
+- notebook_id: Notebook UUID (get from lunatask://notebooks resource)
+- content: Markdown content
+- source: Origin identifier for integrations
+- source_id: Source-specific ID (requires source)
+
+All fields are optional — can create an empty note.
+Returns the deep link to the created note.
+
+Note: If a note with the same source/source_id already exists,
+the API returns a duplicate warning instead of creating a new note.`
+
+// CreateInput is the input schema for creating a note.
+type CreateInput struct {
+	Name       *string `json:"name,omitempty"`
+	NotebookID *string `json:"notebook_id,omitempty"`
+	Content    *string `json:"content,omitempty"`
+	Source     *string `json:"source,omitempty"`
+	SourceID   *string `json:"source_id,omitempty"`
+}
+
+// CreateOutput is the output schema for creating a note.
+type CreateOutput struct {
+	DeepLink string `json:"deep_link"`
+}
+
+// Handler handles note-related MCP tool requests.
+type Handler struct {
+	client    *lunatask.Client
+	notebooks []shared.NotebookProvider
+}
+
+// NewHandler creates a new note handler.
+func NewHandler(accessToken string, notebooks []shared.NotebookProvider) *Handler {
+	return &Handler{
+		client:    lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
+		notebooks: notebooks,
+	}
+}
+
+// HandleCreate creates a new note.
+func (h *Handler) HandleCreate(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input CreateInput,
+) (*mcp.CallToolResult, CreateOutput, error) {
+	if input.NotebookID != nil {
+		if err := lunatask.ValidateUUID(*input.NotebookID); err != nil {
+			return shared.ErrorResult("invalid notebook_id: expected UUID"), CreateOutput{}, nil
+		}
+	}
+
+	builder := h.client.NewNote()
+
+	if input.Name != nil {
+		builder.WithName(*input.Name)
+	}
+
+	if input.NotebookID != nil {
+		builder.InNotebook(*input.NotebookID)
+	}
+
+	if input.Content != nil {
+		builder.WithContent(*input.Content)
+	}
+
+	if input.Source != nil && input.SourceID != nil {
+		builder.FromSource(*input.Source, *input.SourceID)
+	}
+
+	note, err := builder.Create(ctx)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), CreateOutput{}, nil
+	}
+
+	if note == nil {
+		return &mcp.CallToolResult{
+			Content: []mcp.Content{&mcp.TextContent{
+				Text: "Note already exists (duplicate source)",
+			}},
+		}, CreateOutput{}, nil
+	}
+
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{
+			Text: "Note created: " + deepLink,
+		}},
+	}, CreateOutput{DeepLink: deepLink}, nil
+}

internal/mcp/tools/note/delete.go 🔗

@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package note
+
+import (
+	"context"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// DeleteToolName is the name of the delete note tool.
+const DeleteToolName = "delete_note"
+
+// DeleteToolDescription describes the delete note tool for LLMs.
+const DeleteToolDescription = `Deletes a note from Lunatask.
+
+Required:
+- id: Note UUID or lunatask://note/... deep link
+
+This action is permanent and cannot be undone.`
+
+// DeleteInput is the input schema for deleting a note.
+type DeleteInput struct {
+	ID string `json:"id" jsonschema:"required"`
+}
+
+// DeleteOutput is the output schema for deleting a note.
+type DeleteOutput struct {
+	Success  bool   `json:"success"`
+	DeepLink string `json:"deep_link"`
+}
+
+// HandleDelete deletes a note.
+func (h *Handler) HandleDelete(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input DeleteInput,
+) (*mcp.CallToolResult, DeleteOutput, error) {
+	id, err := parseReference(input.ID)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), DeleteOutput{}, nil
+	}
+
+	note, err := h.client.DeleteNote(ctx, id)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), DeleteOutput{}, nil
+	}
+
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
+
+	return &mcp.CallToolResult{
+			Content: []mcp.Content{&mcp.TextContent{
+				Text: "Note deleted: " + deepLink,
+			}},
+		}, DeleteOutput{
+			Success:  true,
+			DeepLink: deepLink,
+		}, nil
+}

internal/mcp/tools/note/list.go 🔗

@@ -0,0 +1,185 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package note
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ListToolName is the name of the list notes tool.
+const ListToolName = "list_notes"
+
+// ListToolDescription describes the list notes tool for LLMs.
+const ListToolDescription = `Lists notes from Lunatask.
+
+Optional:
+- notebook_id: Filter by notebook UUID (from lunatask://notebooks)
+- source: Filter by source identifier
+- source_id: Filter by source-specific ID
+
+Returns note metadata (IDs, dates, pinned status).
+Use lunatask://note/{id} resource for full note details.
+
+Note: Due to end-to-end encryption, note names and content
+are not available in the list — only metadata is returned.`
+
+// ListInput is the input schema for listing notes.
+type ListInput struct {
+	NotebookID *string `json:"notebook_id,omitempty"`
+	Source     *string `json:"source,omitempty"`
+	SourceID   *string `json:"source_id,omitempty"`
+}
+
+// ListNoteItem represents a note in the list output.
+type ListNoteItem struct {
+	DeepLink   string  `json:"deep_link"`
+	NotebookID *string `json:"notebook_id,omitempty"`
+	DateOn     *string `json:"date_on,omitempty"`
+	Pinned     bool    `json:"pinned"`
+	CreatedAt  string  `json:"created_at"`
+}
+
+// ListOutput is the output schema for listing notes.
+type ListOutput struct {
+	Notes []ListNoteItem `json:"notes"`
+	Count int            `json:"count"`
+}
+
+// HandleList lists notes.
+func (h *Handler) HandleList(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input ListInput,
+) (*mcp.CallToolResult, ListOutput, error) {
+	if input.NotebookID != nil {
+		if err := lunatask.ValidateUUID(*input.NotebookID); err != nil {
+			return shared.ErrorResult("invalid notebook_id: expected UUID"), ListOutput{}, nil
+		}
+	}
+
+	opts := buildListOptions(input)
+
+	notes, err := h.client.ListNotes(ctx, opts)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), ListOutput{}, nil
+	}
+
+	if input.NotebookID != nil {
+		notes = filterByNotebook(notes, *input.NotebookID)
+	}
+
+	items := make([]ListNoteItem, 0, len(notes))
+
+	for _, note := range notes {
+		deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
+
+		item := ListNoteItem{
+			DeepLink:   deepLink,
+			NotebookID: note.NotebookID,
+			Pinned:     note.Pinned,
+			CreatedAt:  note.CreatedAt.Format("2006-01-02"),
+		}
+
+		if note.DateOn != nil {
+			dateStr := note.DateOn.Format("2006-01-02")
+			item.DateOn = &dateStr
+		}
+
+		items = append(items, item)
+	}
+
+	output := ListOutput{
+		Notes: items,
+		Count: len(items),
+	}
+
+	text := formatListText(items, h.notebooks)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: text}},
+	}, output, nil
+}
+
+func buildListOptions(input ListInput) *lunatask.ListNotesOptions {
+	if input.Source == nil && input.SourceID == nil {
+		return nil
+	}
+
+	opts := &lunatask.ListNotesOptions{}
+
+	if input.Source != nil {
+		opts.Source = input.Source
+	}
+
+	if input.SourceID != nil {
+		opts.SourceID = input.SourceID
+	}
+
+	return opts
+}
+
+func filterByNotebook(notes []lunatask.Note, notebookID string) []lunatask.Note {
+	filtered := make([]lunatask.Note, 0, len(notes))
+
+	for _, note := range notes {
+		if note.NotebookID != nil && *note.NotebookID == notebookID {
+			filtered = append(filtered, note)
+		}
+	}
+
+	return filtered
+}
+
+func formatListText(items []ListNoteItem, notebooks []shared.NotebookProvider) string {
+	if len(items) == 0 {
+		return "No notes found"
+	}
+
+	var text strings.Builder
+
+	text.WriteString(fmt.Sprintf("Found %d note(s):\n", len(items)))
+
+	for _, item := range items {
+		text.WriteString("- ")
+		text.WriteString(item.DeepLink)
+
+		var details []string
+
+		if item.NotebookID != nil {
+			nbName := *item.NotebookID
+			for _, nb := range notebooks {
+				if nb.ID == *item.NotebookID {
+					nbName = nb.Key
+
+					break
+				}
+			}
+
+			details = append(details, "notebook: "+nbName)
+		}
+
+		if item.Pinned {
+			details = append(details, "pinned")
+		}
+
+		if len(details) > 0 {
+			text.WriteString(" (")
+			text.WriteString(strings.Join(details, ", "))
+			text.WriteString(")")
+		}
+
+		text.WriteString("\n")
+	}
+
+	text.WriteString("\nUse lunatask://note/{id} resource for full details.")
+
+	return text.String()
+}

internal/mcp/tools/note/update.go 🔗

@@ -0,0 +1,110 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package note
+
+import (
+	"context"
+	"fmt"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/dateutil"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// UpdateToolName is the name of the update note tool.
+const UpdateToolName = "update_note"
+
+// UpdateToolDescription describes the update note tool for LLMs.
+const UpdateToolDescription = `Updates an existing note in Lunatask.
+
+Required:
+- id: Note UUID or lunatask://note/... deep link
+
+Optional:
+- name: New note title
+- notebook_id: Move to notebook (UUID from lunatask://notebooks)
+- content: Replace content (Markdown)
+- date: Note date (YYYY-MM-DD or natural language)
+
+Only provided fields are modified; other fields remain unchanged.`
+
+// UpdateInput is the input schema for updating a note.
+type UpdateInput struct {
+	ID         string  `json:"id"                    jsonschema:"required"`
+	Name       *string `json:"name,omitempty"`
+	NotebookID *string `json:"notebook_id,omitempty"`
+	Content    *string `json:"content,omitempty"`
+	Date       *string `json:"date,omitempty"`
+}
+
+// UpdateOutput is the output schema for updating a note.
+type UpdateOutput struct {
+	DeepLink string `json:"deep_link"`
+}
+
+// HandleUpdate updates an existing note.
+func (h *Handler) HandleUpdate(
+	ctx context.Context,
+	_ *mcp.CallToolRequest,
+	input UpdateInput,
+) (*mcp.CallToolResult, UpdateOutput, error) {
+	id, err := parseReference(input.ID)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+	}
+
+	if input.NotebookID != nil {
+		if err := lunatask.ValidateUUID(*input.NotebookID); err != nil {
+			return shared.ErrorResult("invalid notebook_id: expected UUID"), UpdateOutput{}, nil
+		}
+	}
+
+	builder := h.client.NewNoteUpdate(id)
+
+	if input.Name != nil {
+		builder.WithName(*input.Name)
+	}
+
+	if input.NotebookID != nil {
+		builder.InNotebook(*input.NotebookID)
+	}
+
+	if input.Content != nil {
+		builder.WithContent(*input.Content)
+	}
+
+	if input.Date != nil {
+		date, err := dateutil.Parse(*input.Date)
+		if err != nil {
+			return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+		}
+
+		builder.OnDate(date)
+	}
+
+	note, err := builder.Update(ctx)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), UpdateOutput{}, nil
+	}
+
+	deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{
+			Text: "Note updated: " + deepLink,
+		}},
+	}, UpdateOutput{DeepLink: deepLink}, nil
+}
+
+// parseReference extracts UUID from either a raw UUID or a lunatask:// deep link.
+func parseReference(ref string) (string, error) {
+	_, id, err := lunatask.ParseReference(ref)
+	if err != nil {
+		return "", fmt.Errorf("invalid ID: %w", err)
+	}
+
+	return id, nil
+}