diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index decc11c610a452fd4bb8efdcd90fa3f203f3cc4c..4ed2cbb4877b9d028be54b6178514e54f1c3921e 100644 --- a/cmd/mcp/server.go +++ b/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) diff --git a/internal/mcp/tools/note/create.go b/internal/mcp/tools/note/create.go new file mode 100644 index 0000000000000000000000000000000000000000..05e0a314f92a4a5c153a2c42f9109d5b2c7c9d9a --- /dev/null +++ b/internal/mcp/tools/note/create.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/note/delete.go b/internal/mcp/tools/note/delete.go new file mode 100644 index 0000000000000000000000000000000000000000..3cf3f2480c8bb9d2c12aab128c40ef8fee1dab1f --- /dev/null +++ b/internal/mcp/tools/note/delete.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/tools/note/list.go b/internal/mcp/tools/note/list.go new file mode 100644 index 0000000000000000000000000000000000000000..5f957e4e5af4d6a5a872e46f7cd4f65cf2f57b64 --- /dev/null +++ b/internal/mcp/tools/note/list.go @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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() +} diff --git a/internal/mcp/tools/note/update.go b/internal/mcp/tools/note/update.go new file mode 100644 index 0000000000000000000000000000000000000000..c36d703d813ab9b96cfa7f8700163d052fd2f55e --- /dev/null +++ b/internal/mcp/tools/note/update.go @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +}