diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index c4b95004a3979a1ae06cacbd2d5d599beab5b063..6fca1aad3e2b774044ccee3167ade234ce65ad77 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -167,6 +167,7 @@ var validToolNames = map[string]func(*config.ToolsConfig, bool){ note.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.UpdateNote = v }, note.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.DeleteNote = v }, note.ListToolName: func(t *config.ToolsConfig, v bool) { t.ListNotes = v }, + note.ShowToolName: func(t *config.ToolsConfig, v bool) { t.ShowNote = v }, person.CreateToolName: func(t *config.ToolsConfig, v bool) { t.CreatePerson = v }, person.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.UpdatePerson = v }, person.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.DeletePerson = v }, @@ -175,7 +176,6 @@ var validToolNames = map[string]func(*config.ToolsConfig, bool){ habit.TrackToolName: func(t *config.ToolsConfig, v bool) { t.TrackHabit = v }, journal.CreateToolName: func(t *config.ToolsConfig, v bool) { t.CreateJournal = v }, // TODO: Add these once implemented: - // - show_note (ShowNote) // - show_person (ShowPerson) // - list_habits (ListHabits) // - list_areas (ListAreas) - needs config field diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index ae3a33276e2dff5273347a20af4eae49ccdd0318..9f310d9a4f74dc3a61bed5fb60d964a5b794d56e 100644 --- a/cmd/mcp/server.go +++ b/cmd/mcp/server.go @@ -243,6 +243,13 @@ func registerNoteTools( Description: notetool.ListToolDescription, }, noteHandler.HandleList) } + + if tools.ShowNote { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: notetool.ShowToolName, + Description: notetool.ShowToolDescription, + }, noteHandler.HandleShow) + } } func registerPersonTools( diff --git a/internal/config/config.go b/internal/config/config.go index e5b636bc64b637924d1075347f86fe311c0f8b0f..8b20385d3d9f4b2c1ff00a7b8a3eabc781373cc2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,10 +84,12 @@ func (c *MCPConfig) MCPDefaults() { } // ApplyDefaults enables all tools if none are explicitly configured. +// Note: "show" tools and config-based "list" tools are default-disabled +// because they are fallbacks for agents that don't support MCP resources. // //nolint:cyclop // Complexity from repetitive boolean checks; structurally simple. func (t *ToolsConfig) ApplyDefaults() { - // If all are false (zero value), enable everything + // If all are false (zero value), enable everything except resource fallbacks if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask && !t.ListTasks && !t.ShowTask && !t.CreateNote && !t.UpdateNote && !t.DeleteNote && !t.ListNotes && !t.ShowNote && !t.CreatePerson && @@ -98,20 +100,20 @@ func (t *ToolsConfig) ApplyDefaults() { t.UpdateTask = true t.DeleteTask = true t.ListTasks = true - t.ShowTask = true + // ShowTask: default-disabled (fallback for lunatask://task/{id} resource) t.CreateNote = true t.UpdateNote = true t.DeleteNote = true t.ListNotes = true - t.ShowNote = true + // ShowNote: default-disabled (fallback for lunatask://note/{id} resource) t.CreatePerson = true t.UpdatePerson = true t.DeletePerson = true t.ListPeople = true - t.ShowPerson = true + // ShowPerson: default-disabled (fallback for lunatask://person/{id} resource) t.PersonTimeline = true t.TrackHabit = true - t.ListHabits = true + // ListHabits: default-disabled (fallback for lunatask://habits resource) t.CreateJournal = true } } diff --git a/internal/mcp/tools/note/show.go b/internal/mcp/tools/note/show.go new file mode 100644 index 0000000000000000000000000000000000000000..68defc98919e79e9fcd9401e5832a02e98608260 --- /dev/null +++ b/internal/mcp/tools/note/show.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package note + +import ( + "context" + "fmt" + "strings" + "time" + + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/mcp/shared" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ShowToolName is the name of the show note tool. +const ShowToolName = "show_note" + +// ShowToolDescription describes the show note tool for LLMs. +const ShowToolDescription = `Shows metadata for a specific note from Lunatask. + +Required: +- id: Note UUID or lunatask:// deep link + +Note: Due to end-to-end encryption, note title and content are not available. +Only metadata (notebook, date, pinned status, sources) is returned.` + +// ShowInput is the input schema for showing a note. +type ShowInput struct { + ID string `json:"id" jsonschema:"required"` +} + +// ShowSource represents a source reference in the output. +type ShowSource struct { + Source string `json:"source"` + SourceID string `json:"source_id"` +} + +// ShowOutput is the output schema for showing a note. +type ShowOutput struct { + DeepLink string `json:"deep_link"` + NotebookID *string `json:"notebook_id,omitempty"` + DateOn *string `json:"date_on,omitempty"` + Pinned bool `json:"pinned"` + Sources []ShowSource `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// HandleShow shows a note's details. +func (h *Handler) HandleShow( + ctx context.Context, + _ *mcp.CallToolRequest, + input ShowInput, +) (*mcp.CallToolResult, ShowOutput, error) { + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), ShowOutput{}, nil + } + + note, err := h.client.GetNote(ctx, id) + if err != nil { + return shared.ErrorResult(err.Error()), ShowOutput{}, nil + } + + output := buildShowOutput(note) + text := formatNoteShowText(output, h.notebooks) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, output, nil +} + +func buildShowOutput(note *lunatask.Note) ShowOutput { + output := ShowOutput{ + NotebookID: note.NotebookID, + Pinned: note.Pinned, + CreatedAt: note.CreatedAt.Format(time.RFC3339), + UpdatedAt: note.UpdatedAt.Format(time.RFC3339), + } + + output.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) + + if note.DateOn != nil { + s := note.DateOn.Format("2006-01-02") + output.DateOn = &s + } + + if len(note.Sources) > 0 { + output.Sources = make([]ShowSource, 0, len(note.Sources)) + for _, src := range note.Sources { + output.Sources = append(output.Sources, ShowSource{ + Source: src.Source, + SourceID: src.SourceID, + }) + } + } + + return output +} + +func formatNoteShowText(output ShowOutput, notebooks []shared.NotebookProvider) string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Note: %s\n", output.DeepLink)) + + if output.NotebookID != nil { + nbName := *output.NotebookID + for _, nb := range notebooks { + if nb.ID == *output.NotebookID { + nbName = nb.Key + + break + } + } + + builder.WriteString(fmt.Sprintf("Notebook: %s\n", nbName)) + } + + if output.DateOn != nil { + builder.WriteString(fmt.Sprintf("Date: %s\n", *output.DateOn)) + } + + if output.Pinned { + builder.WriteString("Pinned: yes\n") + } + + if len(output.Sources) > 0 { + builder.WriteString("Sources:\n") + + for _, src := range output.Sources { + builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID)) + } + } + + builder.WriteString(fmt.Sprintf("Created: %s\n", output.CreatedAt)) + builder.WriteString("Updated: " + output.UpdatedAt) + + return builder.String() +}