Detailed changes
@@ -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)
@@ -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
+}
@@ -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
+}
@@ -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()
+}
@@ -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
+}