feat(mcp): show_note tool, default-disable reads

Amolith created

Implements show_note as a default-disabled fallback for the
lunatask://note/{id} resource.

Also default-disables all "read" tools (show_task, show_note,
show_person, list_habits) since MCP resources are the primary read
interface. Tools serve as fallbacks for agents that don't support
resources.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/mcp/mcp.go                  |   2 
cmd/mcp/server.go               |   7 +
internal/config/config.go       |  12 +-
internal/mcp/tools/note/show.go | 142 +++++++++++++++++++++++++++++++++++
4 files changed, 157 insertions(+), 6 deletions(-)

Detailed changes

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

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(

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
 	}
 }

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

@@ -0,0 +1,142 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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()
+}