feat(mcp): add list resources and fallback tools

Amolith created

Resources:
- lunatask://notes, notes/pinned, notes/recent
- lunatask://notes/{notebook}, notes/{notebook}/pinned,
  notes/{notebook}/recent
- lunatask://people, people/{relationship}

Fallback tools (default-disabled):
- list_notebooks, list_habits, list_areas, list_goals
- show_person

Default-disable list_notes and list_people tools since resources are now
the primary read interface.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/mcp/mcp.go                           |  13 
cmd/mcp/server.go                        | 167 +++++++++++++
internal/config/config.go                |  14 
internal/mcp/resources/notes/handler.go  | 302 ++++++++++++++++++++++++++
internal/mcp/resources/people/handler.go | 156 +++++++++++++
internal/mcp/tools/area/list.go          |  96 ++++++++
internal/mcp/tools/goal/list.go          | 128 +++++++++++
internal/mcp/tools/habit/list.go         |  82 +++++++
internal/mcp/tools/notebook/list.go      |  94 ++++++++
internal/mcp/tools/person/show.go        | 121 ++++++++++
10 files changed, 1,155 insertions(+), 18 deletions(-)

Detailed changes

cmd/mcp/mcp.go 🔗

@@ -11,9 +11,12 @@ import (
 
 	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/mcp/tools/area"
+	"git.secluded.site/lune/internal/mcp/tools/goal"
 	"git.secluded.site/lune/internal/mcp/tools/habit"
 	"git.secluded.site/lune/internal/mcp/tools/journal"
 	"git.secluded.site/lune/internal/mcp/tools/note"
+	"git.secluded.site/lune/internal/mcp/tools/notebook"
 	"git.secluded.site/lune/internal/mcp/tools/person"
 	"git.secluded.site/lune/internal/mcp/tools/task"
 	"git.secluded.site/lune/internal/mcp/tools/timestamp"
@@ -173,13 +176,13 @@ var validToolNames = map[string]func(*config.ToolsConfig, bool){
 	person.DeleteToolName:   func(t *config.ToolsConfig, v bool) { t.DeletePerson = v },
 	person.ListToolName:     func(t *config.ToolsConfig, v bool) { t.ListPeople = v },
 	person.TimelineToolName: func(t *config.ToolsConfig, v bool) { t.PersonTimeline = v },
+	person.ShowToolName:     func(t *config.ToolsConfig, v bool) { t.ShowPerson = v },
 	habit.TrackToolName:     func(t *config.ToolsConfig, v bool) { t.TrackHabit = v },
+	habit.ListToolName:      func(t *config.ToolsConfig, v bool) { t.ListHabits = v },
+	notebook.ListToolName:   func(t *config.ToolsConfig, v bool) { t.ListNotebooks = v },
+	area.ListToolName:       func(t *config.ToolsConfig, v bool) { t.ListAreas = v },
+	goal.ListToolName:       func(t *config.ToolsConfig, v bool) { t.ListGoals = v },
 	journal.CreateToolName:  func(t *config.ToolsConfig, v bool) { t.CreateJournal = v },
-	// TODO: Add these once implemented:
-	// - show_person (ShowPerson)
-	// - list_habits (ListHabits)
-	// - list_areas (ListAreas) - needs config field
-	// - list_goals (ListGoals) - needs config field
 }
 
 // resolveTools modifies cfg.MCP.Tools based on CLI flags.

cmd/mcp/server.go 🔗

@@ -16,13 +16,18 @@ import (
 	"git.secluded.site/lune/internal/mcp/resources/habits"
 	noters "git.secluded.site/lune/internal/mcp/resources/note"
 	"git.secluded.site/lune/internal/mcp/resources/notebooks"
+	"git.secluded.site/lune/internal/mcp/resources/notes"
+	"git.secluded.site/lune/internal/mcp/resources/people"
 	personrs "git.secluded.site/lune/internal/mcp/resources/person"
 	taskrs "git.secluded.site/lune/internal/mcp/resources/task"
 	"git.secluded.site/lune/internal/mcp/resources/tasks"
 	"git.secluded.site/lune/internal/mcp/shared"
+	areatool "git.secluded.site/lune/internal/mcp/tools/area"
+	goaltool "git.secluded.site/lune/internal/mcp/tools/goal"
 	"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/notebook"
 	persontool "git.secluded.site/lune/internal/mcp/tools/person"
 	"git.secluded.site/lune/internal/mcp/tools/task"
 	"git.secluded.site/lune/internal/mcp/tools/timestamp"
@@ -46,7 +51,7 @@ func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server {
 	notebookProviders := shared.ToNotebookProviders(cfg.Notebooks)
 
 	registerResources(mcpServer, accessToken, areaProviders, habitProviders, notebookProviders)
-	registerResourceTemplates(mcpServer, accessToken, areaProviders)
+	registerResourceTemplates(mcpServer, accessToken, areaProviders, notebookProviders)
 	registerTools(mcpServer, cfg, accessToken, areaProviders, habitProviders, notebookProviders)
 
 	return mcpServer
@@ -100,9 +105,16 @@ func registerResources(
 	}, notebooksHandler.HandleRead)
 
 	registerTaskListResources(mcpServer, accessToken, areaProviders)
+	registerNoteListResources(mcpServer, accessToken, notebookProviders)
+	registerPeopleListResources(mcpServer, accessToken)
 }
 
-func registerResourceTemplates(mcpServer *mcp.Server, accessToken string, areaProviders []shared.AreaProvider) {
+func registerResourceTemplates(
+	mcpServer *mcp.Server,
+	accessToken string,
+	areaProviders []shared.AreaProvider,
+	notebookProviders []shared.NotebookProvider,
+) {
 	taskHandler := taskrs.NewHandler(accessToken)
 	mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
 		Name:        "task",
@@ -128,6 +140,7 @@ func registerResourceTemplates(mcpServer *mcp.Server, accessToken string, areaPr
 	}, personHandler.HandleRead)
 
 	registerAreaTaskTemplates(mcpServer, accessToken, areaProviders)
+	registerNotebookNoteTemplates(mcpServer, accessToken, notebookProviders)
 }
 
 func registerTaskListResources(
@@ -244,6 +257,85 @@ func registerAreaTaskTemplates(
 	}, handler.HandleReadAreaTasks)
 }
 
+func registerNoteListResources(
+	mcpServer *mcp.Server,
+	accessToken string,
+	notebookProviders []shared.NotebookProvider,
+) {
+	handler := notes.NewHandler(accessToken, notebookProviders)
+
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "notes-all",
+		URI:         notes.ResourceURIAll,
+		Description: notes.AllDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadAll)
+
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "notes-pinned",
+		URI:         notes.ResourceURIPinned,
+		Description: notes.PinnedDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadPinned)
+
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "notes-recent",
+		URI:         notes.ResourceURIRecent,
+		Description: notes.RecentDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadRecent)
+}
+
+func registerPeopleListResources(
+	mcpServer *mcp.Server,
+	accessToken string,
+) {
+	handler := people.NewHandler(accessToken)
+
+	mcpServer.AddResource(&mcp.Resource{
+		Name:        "people-all",
+		URI:         people.ResourceURIAll,
+		Description: people.AllDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadAll)
+
+	mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
+		Name:        "people-by-relationship",
+		URITemplate: people.RelationshipTemplate,
+		Description: people.RelationshipDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadByRelationship)
+}
+
+func registerNotebookNoteTemplates(
+	mcpServer *mcp.Server,
+	accessToken string,
+	notebookProviders []shared.NotebookProvider,
+) {
+	handler := notes.NewHandler(accessToken, notebookProviders)
+
+	mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
+		Name:        "notebook-notes",
+		URITemplate: notes.NotebookNotesTemplate,
+		Description: notes.NotebookNotesDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadNotebookNotes)
+
+	mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
+		Name:        "notebook-notes-pinned",
+		URITemplate: notes.NotebookNotesPinnedTemplate,
+		Description: notes.NotebookFilteredDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadNotebookNotes)
+
+	mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
+		Name:        "notebook-notes-recent",
+		URITemplate: notes.NotebookNotesRecentTemplate,
+		Description: notes.NotebookFilteredDescription,
+		MIMEType:    "application/json",
+	}, handler.HandleReadNotebookNotes)
+}
+
 func registerTools(
 	mcpServer *mcp.Server,
 	cfg *config.Config,
@@ -265,21 +357,73 @@ func registerTools(
 	registerTaskTools(mcpServer, cfg, tools, accessToken, areaProviders)
 	registerNoteTools(mcpServer, tools, accessToken, notebookProviders)
 	registerPersonTools(mcpServer, tools, accessToken)
+	registerHabitTools(mcpServer, tools, accessToken, habitProviders)
+	registerConfigListTools(mcpServer, tools, areaProviders, notebookProviders)
+
+	if tools.CreateJournal {
+		journalHandler := journal.NewHandler(accessToken)
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        journal.CreateToolName,
+			Description: journal.CreateToolDescription,
+		}, journalHandler.HandleCreate)
+	}
+}
+
+func registerHabitTools(
+	mcpServer *mcp.Server,
+	tools *config.ToolsConfig,
+	accessToken string,
+	habitProviders []shared.HabitProvider,
+) {
+	if !tools.TrackHabit && !tools.ListHabits {
+		return
+	}
+
+	habitHandler := habit.NewHandler(accessToken, habitProviders)
 
 	if tools.TrackHabit {
-		habitHandler := habit.NewHandler(accessToken, habitProviders)
 		mcp.AddTool(mcpServer, &mcp.Tool{
 			Name:        habit.TrackToolName,
 			Description: habit.TrackToolDescription,
 		}, habitHandler.HandleTrack)
 	}
 
-	if tools.CreateJournal {
-		journalHandler := journal.NewHandler(accessToken)
+	if tools.ListHabits {
 		mcp.AddTool(mcpServer, &mcp.Tool{
-			Name:        journal.CreateToolName,
-			Description: journal.CreateToolDescription,
-		}, journalHandler.HandleCreate)
+			Name:        habit.ListToolName,
+			Description: habit.ListToolDescription,
+		}, habitHandler.HandleList)
+	}
+}
+
+func registerConfigListTools(
+	mcpServer *mcp.Server,
+	tools *config.ToolsConfig,
+	areaProviders []shared.AreaProvider,
+	notebookProviders []shared.NotebookProvider,
+) {
+	if tools.ListNotebooks {
+		notebookHandler := notebook.NewHandler(notebookProviders)
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        notebook.ListToolName,
+			Description: notebook.ListToolDescription,
+		}, notebookHandler.HandleList)
+	}
+
+	if tools.ListAreas {
+		areaHandler := areatool.NewHandler(areaProviders)
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        areatool.ListToolName,
+			Description: areatool.ListToolDescription,
+		}, areaHandler.HandleList)
+	}
+
+	if tools.ListGoals {
+		goalHandler := goaltool.NewHandler(areaProviders)
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        goaltool.ListToolName,
+			Description: goaltool.ListToolDescription,
+		}, goalHandler.HandleList)
 	}
 }
 
@@ -413,6 +557,13 @@ func registerPersonTools(
 			Description: persontool.TimelineToolDescription,
 		}, personHandler.HandleTimeline)
 	}
+
+	if tools.ShowPerson {
+		mcp.AddTool(mcpServer, &mcp.Tool{
+			Name:        persontool.ShowToolName,
+			Description: persontool.ShowToolDescription,
+		}, personHandler.HandleShow)
+	}
 }
 
 func runStdio(mcpServer *mcp.Server) error {

internal/config/config.go 🔗

@@ -60,8 +60,11 @@ type ToolsConfig struct {
 	ShowPerson     bool `toml:"show_person"`
 	PersonTimeline bool `toml:"person_timeline"`
 
-	TrackHabit bool `toml:"track_habit"`
-	ListHabits bool `toml:"list_habits"`
+	TrackHabit    bool `toml:"track_habit"`
+	ListHabits    bool `toml:"list_habits"`
+	ListNotebooks bool `toml:"list_notebooks"`
+	ListAreas     bool `toml:"list_areas"`
+	ListGoals     bool `toml:"list_goals"`
 
 	CreateJournal bool `toml:"create_journal"`
 }
@@ -94,7 +97,8 @@ func (t *ToolsConfig) ApplyDefaults() {
 		!t.ListTasks && !t.ShowTask && !t.CreateNote && !t.UpdateNote &&
 		!t.DeleteNote && !t.ListNotes && !t.ShowNote && !t.CreatePerson &&
 		!t.UpdatePerson && !t.DeletePerson && !t.ListPeople && !t.ShowPerson &&
-		!t.PersonTimeline && !t.TrackHabit && !t.ListHabits && !t.CreateJournal {
+		!t.PersonTimeline && !t.TrackHabit && !t.ListHabits && !t.ListNotebooks &&
+		!t.ListAreas && !t.ListGoals && !t.CreateJournal {
 		t.GetTimestamp = true
 		t.CreateTask = true
 		t.UpdateTask = true
@@ -104,12 +108,12 @@ func (t *ToolsConfig) ApplyDefaults() {
 		t.CreateNote = true
 		t.UpdateNote = true
 		t.DeleteNote = true
-		t.ListNotes = true
+		// ListNotes: default-disabled (fallback for lunatask://notes/* resources)
 		// ShowNote: default-disabled (fallback for lunatask://note/{id} resource)
 		t.CreatePerson = true
 		t.UpdatePerson = true
 		t.DeletePerson = true
-		t.ListPeople = true
+		// ListPeople: default-disabled (fallback for lunatask://people/* resources)
 		// ShowPerson: default-disabled (fallback for lunatask://person/{id} resource)
 		t.PersonTimeline = true
 		t.TrackHabit = true

internal/mcp/resources/notes/handler.go 🔗

@@ -0,0 +1,302 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package notes provides MCP resources for filtered note lists.
+package notes
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+	"time"
+
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ErrUnknownNotebook indicates the notebook reference could not be resolved.
+var ErrUnknownNotebook = errors.New("unknown notebook")
+
+// Resource URIs for note list filters.
+const (
+	ResourceURIAll    = "lunatask://notes"
+	ResourceURIPinned = "lunatask://notes/pinned"
+	ResourceURIRecent = "lunatask://notes/recent"
+)
+
+// Notebook-scoped resource templates.
+const (
+	NotebookNotesTemplate       = "lunatask://notes/{notebook}"
+	NotebookNotesPinnedTemplate = "lunatask://notes/{notebook}/pinned"
+	NotebookNotesRecentTemplate = "lunatask://notes/{notebook}/recent"
+)
+
+// Resource descriptions.
+const (
+	AllDescription = `All notes. EXPENSIVE - prefer filtered resources.`
+
+	PinnedDescription = `Pinned notes only.`
+
+	RecentDescription = `Recently created or updated notes (last 7 days).`
+
+	NotebookNotesDescription = `Notes in a specific notebook. EXPENSIVE - prefer filtered resources.
+{notebook} accepts config key or UUID.`
+
+	NotebookFilteredDescription = `Filtered notes in notebook. {notebook} accepts config key or UUID.`
+)
+
+// RecentWindow is the time window for recent notes.
+const RecentWindow = 7 * 24 * time.Hour
+
+// Handler handles note list resource requests.
+type Handler struct {
+	client    *lunatask.Client
+	notebooks []shared.NotebookProvider
+}
+
+// NewHandler creates a new notes resource handler.
+func NewHandler(accessToken string, notebooks []shared.NotebookProvider) *Handler {
+	return &Handler{
+		client:    lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
+		notebooks: notebooks,
+	}
+}
+
+// noteSummary represents a note in list output.
+type noteSummary 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"`
+	UpdatedAt  string  `json:"updated_at"`
+}
+
+// FilterType identifies which filter to apply.
+type FilterType int
+
+// Filter type constants.
+const (
+	TypeAll FilterType = iota
+	TypePinned
+	TypeRecent
+)
+
+// HandleReadAll handles the all notes resource.
+func (h *Handler) HandleReadAll(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	return h.handleFiltered(ctx, req, TypeAll, "")
+}
+
+// HandleReadPinned handles the pinned notes resource.
+func (h *Handler) HandleReadPinned(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	return h.handleFiltered(ctx, req, TypePinned, "")
+}
+
+// HandleReadRecent handles the recent notes resource.
+func (h *Handler) HandleReadRecent(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	return h.handleFiltered(ctx, req, TypeRecent, "")
+}
+
+// HandleReadNotebookNotes handles notebook-scoped note resources.
+func (h *Handler) HandleReadNotebookNotes(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	notebookRef, filterType := parseNotebookURI(req.Params.URI)
+	if notebookRef == "" {
+		return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI))
+	}
+
+	notebookID, err := h.resolveNotebookRef(notebookRef)
+	if err != nil {
+		return nil, fmt.Errorf("invalid notebook %q: %w", notebookRef, mcp.ResourceNotFoundError(req.Params.URI))
+	}
+
+	return h.handleFiltered(ctx, req, filterType, notebookID)
+}
+
+// resolveNotebookRef resolves a notebook reference to a UUID.
+// Accepts config key or UUID.
+func (h *Handler) resolveNotebookRef(input string) (string, error) {
+	if err := lunatask.ValidateUUID(input); err == nil {
+		return input, nil
+	}
+
+	for _, nb := range h.notebooks {
+		if nb.Key == input {
+			return nb.ID, nil
+		}
+	}
+
+	return "", fmt.Errorf("%w: %s", ErrUnknownNotebook, input)
+}
+
+func (h *Handler) handleFiltered(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+	filterType FilterType,
+	notebookID string,
+) (*mcp.ReadResourceResult, error) {
+	notes, err := h.client.ListNotes(ctx, nil)
+	if err != nil {
+		return nil, fmt.Errorf("fetching notes: %w", err)
+	}
+
+	if notebookID != "" {
+		notes = filterByNotebook(notes, notebookID)
+	}
+
+	notes = applyFilter(notes, filterType)
+
+	summaries := buildSummaries(notes)
+
+	data, err := json.MarshalIndent(summaries, "", "  ")
+	if err != nil {
+		return nil, fmt.Errorf("marshaling notes: %w", err)
+	}
+
+	return &mcp.ReadResourceResult{
+		Contents: []*mcp.ResourceContents{{
+			URI:      req.Params.URI,
+			MIMEType: "application/json",
+			Text:     string(data),
+		}},
+	}, nil
+}
+
+func applyFilter(notes []lunatask.Note, filterType FilterType) []lunatask.Note {
+	switch filterType {
+	case TypeAll:
+		return notes
+	case TypePinned:
+		return filterPinned(notes)
+	case TypeRecent:
+		return filterRecent(notes)
+	default:
+		return notes
+	}
+}
+
+func filterPinned(notes []lunatask.Note) []lunatask.Note {
+	result := make([]lunatask.Note, 0)
+
+	for _, note := range notes {
+		if note.Pinned {
+			result = append(result, note)
+		}
+	}
+
+	return result
+}
+
+func filterRecent(notes []lunatask.Note) []lunatask.Note {
+	cutoff := time.Now().Add(-RecentWindow)
+	result := make([]lunatask.Note, 0)
+
+	for _, note := range notes {
+		if note.CreatedAt.After(cutoff) || note.UpdatedAt.After(cutoff) {
+			result = append(result, note)
+		}
+	}
+
+	sort.Slice(result, func(i, j int) bool {
+		return result[i].UpdatedAt.After(result[j].UpdatedAt)
+	})
+
+	return result
+}
+
+func filterByNotebook(notes []lunatask.Note, notebookID string) []lunatask.Note {
+	result := make([]lunatask.Note, 0)
+
+	for _, note := range notes {
+		if note.NotebookID != nil && *note.NotebookID == notebookID {
+			result = append(result, note)
+		}
+	}
+
+	return result
+}
+
+func buildSummaries(notes []lunatask.Note) []noteSummary {
+	summaries := make([]noteSummary, 0, len(notes))
+
+	for _, note := range notes {
+		summary := noteSummary{
+			NotebookID: note.NotebookID,
+			Pinned:     note.Pinned,
+			CreatedAt:  note.CreatedAt.Format(time.RFC3339),
+			UpdatedAt:  note.UpdatedAt.Format(time.RFC3339),
+		}
+
+		summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
+
+		if note.DateOn != nil {
+			s := note.DateOn.Format("2006-01-02")
+			summary.DateOn = &s
+		}
+
+		summaries = append(summaries, summary)
+	}
+
+	return summaries
+}
+
+// parseNotebookURI extracts notebook and filter type from notebook-scoped URIs.
+// Examples:
+//   - lunatask://notes/work -> work, TypeAll
+//   - lunatask://notes/work/pinned -> work, TypePinned
+//   - lunatask://notes/work/recent -> work, TypeRecent
+func parseNotebookURI(uri string) (string, FilterType) {
+	const prefix = "lunatask://notes/"
+
+	filterNameToType := map[string]FilterType{
+		"pinned": TypePinned,
+		"recent": TypeRecent,
+	}
+
+	if !strings.HasPrefix(uri, prefix) {
+		return "", TypeAll
+	}
+
+	const maxParts = 2
+
+	rest := strings.TrimPrefix(uri, prefix)
+	parts := strings.SplitN(rest, "/", maxParts)
+
+	if len(parts) == 0 || parts[0] == "" {
+		return "", TypeAll
+	}
+
+	// Check if first part is a global filter (not a notebook ref)
+	if parts[0] == "pinned" || parts[0] == "recent" {
+		return "", TypeAll
+	}
+
+	notebookRef := parts[0]
+
+	if len(parts) == 1 {
+		return notebookRef, TypeAll
+	}
+
+	filterType, ok := filterNameToType[parts[1]]
+	if !ok {
+		return "", TypeAll
+	}
+
+	return notebookRef, filterType
+}

internal/mcp/resources/people/handler.go 🔗

@@ -0,0 +1,156 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package people provides MCP resources for filtered people lists.
+package people
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"time"
+
+	"git.secluded.site/go-lunatask"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// Resource URIs for people list filters.
+const (
+	ResourceURIAll = "lunatask://people"
+)
+
+// RelationshipTemplate is the URI template for relationship-filtered people resources.
+const RelationshipTemplate = "lunatask://people/{relationship}"
+
+// Resource descriptions.
+const (
+	AllDescription = `All people from relationship tracker.`
+
+	RelationshipDescription = `People filtered by relationship strength.
+{relationship} accepts: family, intimate-friends, close-friends, casual-friends,
+acquaintances, business-contacts, almost-strangers.`
+)
+
+// Handler handles people list resource requests.
+type Handler struct {
+	client *lunatask.Client
+}
+
+// NewHandler creates a new people resource handler.
+func NewHandler(accessToken string) *Handler {
+	return &Handler{
+		client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
+	}
+}
+
+// personSummary represents a person in list output.
+type personSummary struct {
+	DeepLink             string  `json:"deep_link"`
+	RelationshipStrength *string `json:"relationship_strength,omitempty"`
+	CreatedAt            string  `json:"created_at"`
+	UpdatedAt            string  `json:"updated_at"`
+}
+
+// HandleReadAll handles the all people resource.
+func (h *Handler) HandleReadAll(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	return h.handleFiltered(ctx, req, "")
+}
+
+// HandleReadByRelationship handles relationship-filtered people resources.
+func (h *Handler) HandleReadByRelationship(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+) (*mcp.ReadResourceResult, error) {
+	relationship := parseRelationshipURI(req.Params.URI)
+	if relationship == "" {
+		return nil, fmt.Errorf("invalid URI %q: %w", req.Params.URI, mcp.ResourceNotFoundError(req.Params.URI))
+	}
+
+	// Validate relationship strength
+	if _, err := lunatask.ParseRelationshipStrength(relationship); err != nil {
+		return nil, fmt.Errorf("invalid relationship %q: %w", relationship, mcp.ResourceNotFoundError(req.Params.URI))
+	}
+
+	return h.handleFiltered(ctx, req, relationship)
+}
+
+func (h *Handler) handleFiltered(
+	ctx context.Context,
+	req *mcp.ReadResourceRequest,
+	relationship string,
+) (*mcp.ReadResourceResult, error) {
+	people, err := h.client.ListPeople(ctx, nil)
+	if err != nil {
+		return nil, fmt.Errorf("fetching people: %w", err)
+	}
+
+	if relationship != "" {
+		people = filterByRelationship(people, relationship)
+	}
+
+	summaries := buildSummaries(people)
+
+	data, err := json.MarshalIndent(summaries, "", "  ")
+	if err != nil {
+		return nil, fmt.Errorf("marshaling people: %w", err)
+	}
+
+	return &mcp.ReadResourceResult{
+		Contents: []*mcp.ResourceContents{{
+			URI:      req.Params.URI,
+			MIMEType: "application/json",
+			Text:     string(data),
+		}},
+	}, nil
+}
+
+func filterByRelationship(people []lunatask.Person, relationship string) []lunatask.Person {
+	result := make([]lunatask.Person, 0)
+
+	for _, person := range people {
+		if person.RelationshipStrength != nil && string(*person.RelationshipStrength) == relationship {
+			result = append(result, person)
+		}
+	}
+
+	return result
+}
+
+func buildSummaries(people []lunatask.Person) []personSummary {
+	summaries := make([]personSummary, 0, len(people))
+
+	for _, person := range people {
+		summary := personSummary{
+			CreatedAt: person.CreatedAt.Format(time.RFC3339),
+			UpdatedAt: person.UpdatedAt.Format(time.RFC3339),
+		}
+
+		summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
+
+		if person.RelationshipStrength != nil {
+			s := string(*person.RelationshipStrength)
+			summary.RelationshipStrength = &s
+		}
+
+		summaries = append(summaries, summary)
+	}
+
+	return summaries
+}
+
+// parseRelationshipURI extracts relationship from the URI.
+// Example: lunatask://people/close-friends -> close-friends.
+func parseRelationshipURI(uri string) string {
+	const prefix = "lunatask://people/"
+
+	if !strings.HasPrefix(uri, prefix) {
+		return ""
+	}
+
+	return strings.TrimPrefix(uri, prefix)
+}

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

@@ -0,0 +1,96 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package area provides MCP tools for area operations.
+package area
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ListToolName is the name of the list areas tool.
+const ListToolName = "list_areas"
+
+// ListToolDescription describes the list areas tool for LLMs.
+const ListToolDescription = `Lists configured areas. Fallback for lunatask://areas resource.
+
+Returns area metadata (id, name, key, workflow) from config.
+Use the lunatask://areas resource if your client supports MCP resources.`
+
+// ListInput is the input schema for listing areas.
+type ListInput struct{}
+
+// Summary represents an area in the list output.
+type Summary struct {
+	ID       string `json:"id"`
+	Name     string `json:"name"`
+	Key      string `json:"key"`
+	Workflow string `json:"workflow"`
+}
+
+// ListOutput is the output schema for listing areas.
+type ListOutput struct {
+	Areas []Summary `json:"areas"`
+	Count int       `json:"count"`
+}
+
+// Handler handles area tool requests.
+type Handler struct {
+	areas []shared.AreaProvider
+}
+
+// NewHandler creates a new area tool handler.
+func NewHandler(areas []shared.AreaProvider) *Handler {
+	return &Handler{areas: areas}
+}
+
+// HandleList lists configured areas.
+func (h *Handler) HandleList(
+	_ context.Context,
+	_ *mcp.CallToolRequest,
+	_ ListInput,
+) (*mcp.CallToolResult, ListOutput, error) {
+	summaries := make([]Summary, 0, len(h.areas))
+
+	for _, area := range h.areas {
+		summaries = append(summaries, Summary{
+			ID:       area.ID,
+			Name:     area.Name,
+			Key:      area.Key,
+			Workflow: string(area.Workflow),
+		})
+	}
+
+	output := ListOutput{
+		Areas: summaries,
+		Count: len(summaries),
+	}
+
+	text := formatListText(summaries)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: text}},
+	}, output, nil
+}
+
+func formatListText(areas []Summary) string {
+	if len(areas) == 0 {
+		return "No areas configured"
+	}
+
+	var text strings.Builder
+
+	text.WriteString(fmt.Sprintf("Found %d area(s):\n", len(areas)))
+
+	for _, a := range areas {
+		text.WriteString(fmt.Sprintf("- %s: %s (%s, workflow: %s)\n", a.Key, a.Name, a.ID, a.Workflow))
+	}
+
+	return text.String()
+}

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

@@ -0,0 +1,128 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package goal provides MCP tools for goal operations.
+package goal
+
+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 goals tool.
+const ListToolName = "list_goals"
+
+// ListToolDescription describes the list goals tool for LLMs.
+const ListToolDescription = `Lists goals for an area. Use list_areas to find area IDs first.
+
+Required:
+- area_id: Area UUID, deep link, or config key
+
+Returns goal metadata (id, name, key) for the specified area.`
+
+// ListInput is the input schema for listing goals.
+type ListInput struct {
+	AreaID string `json:"area_id" jsonschema:"required"`
+}
+
+// Summary represents a goal in the list output.
+type Summary struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+	Key  string `json:"key"`
+}
+
+// ListOutput is the output schema for listing goals.
+type ListOutput struct {
+	Goals  []Summary `json:"goals"`
+	Count  int       `json:"count"`
+	AreaID string    `json:"area_id"`
+}
+
+// Handler handles goal tool requests.
+type Handler struct {
+	areas []shared.AreaProvider
+}
+
+// NewHandler creates a new goal tool handler.
+func NewHandler(areas []shared.AreaProvider) *Handler {
+	return &Handler{areas: areas}
+}
+
+// HandleList lists goals for an area.
+func (h *Handler) HandleList(
+	_ context.Context,
+	_ *mcp.CallToolRequest,
+	input ListInput,
+) (*mcp.CallToolResult, ListOutput, error) {
+	area := h.resolveAreaRef(input.AreaID)
+	if area == nil {
+		return shared.ErrorResult("unknown area: " + input.AreaID), ListOutput{}, nil
+	}
+
+	summaries := make([]Summary, 0, len(area.Goals))
+
+	for _, goal := range area.Goals {
+		summaries = append(summaries, Summary{
+			ID:   goal.ID,
+			Name: goal.Name,
+			Key:  goal.Key,
+		})
+	}
+
+	output := ListOutput{
+		Goals:  summaries,
+		Count:  len(summaries),
+		AreaID: area.ID,
+	}
+
+	text := formatListText(summaries, area.Name)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: text}},
+	}, output, nil
+}
+
+// resolveAreaRef resolves an area reference to an AreaProvider.
+// Accepts config key, UUID, or deep link.
+func (h *Handler) resolveAreaRef(input string) *shared.AreaProvider {
+	// Try UUID or deep link first
+	if _, id, err := lunatask.ParseReference(input); err == nil {
+		for i := range h.areas {
+			if h.areas[i].ID == id {
+				return &h.areas[i]
+			}
+		}
+	}
+
+	// Try config key lookup
+	for i := range h.areas {
+		if h.areas[i].Key == input {
+			return &h.areas[i]
+		}
+	}
+
+	return nil
+}
+
+func formatListText(goals []Summary, areaName string) string {
+	if len(goals) == 0 {
+		return fmt.Sprintf("No goals configured for area %q", areaName)
+	}
+
+	var text strings.Builder
+
+	text.WriteString(fmt.Sprintf("Found %d goal(s) in %q:\n", len(goals), areaName))
+
+	for _, g := range goals {
+		text.WriteString(fmt.Sprintf("- %s: %s (%s)\n", g.Key, g.Name, g.ID))
+	}
+
+	return text.String()
+}

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

@@ -0,0 +1,82 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package habit
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ListToolName is the name of the list habits tool.
+const ListToolName = "list_habits"
+
+// ListToolDescription describes the list habits tool for LLMs.
+const ListToolDescription = `Lists configured habits. Fallback for lunatask://habits resource.
+
+Returns habit metadata (id, name, key) from config.
+Use the lunatask://habits resource if your client supports MCP resources.`
+
+// ListInput is the input schema for listing habits.
+type ListInput struct{}
+
+// Summary represents a habit in the list output.
+type Summary struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+	Key  string `json:"key"`
+}
+
+// ListOutput is the output schema for listing habits.
+type ListOutput struct {
+	Habits []Summary `json:"habits"`
+	Count  int       `json:"count"`
+}
+
+// HandleList lists configured habits.
+func (h *Handler) HandleList(
+	_ context.Context,
+	_ *mcp.CallToolRequest,
+	_ ListInput,
+) (*mcp.CallToolResult, ListOutput, error) {
+	summaries := make([]Summary, 0, len(h.habits))
+
+	for _, habit := range h.habits {
+		summaries = append(summaries, Summary{
+			ID:   habit.ID,
+			Name: habit.Name,
+			Key:  habit.Key,
+		})
+	}
+
+	output := ListOutput{
+		Habits: summaries,
+		Count:  len(summaries),
+	}
+
+	text := formatListText(summaries)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: text}},
+	}, output, nil
+}
+
+func formatListText(habits []Summary) string {
+	if len(habits) == 0 {
+		return "No habits configured"
+	}
+
+	var text strings.Builder
+
+	text.WriteString(fmt.Sprintf("Found %d habit(s):\n", len(habits)))
+
+	for _, h := range habits {
+		text.WriteString(fmt.Sprintf("- %s: %s (%s)\n", h.Key, h.Name, h.ID))
+	}
+
+	return text.String()
+}

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

@@ -0,0 +1,94 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package notebook provides MCP tools for notebook operations.
+package notebook
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"git.secluded.site/lune/internal/mcp/shared"
+	"github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// ListToolName is the name of the list notebooks tool.
+const ListToolName = "list_notebooks"
+
+// ListToolDescription describes the list notebooks tool for LLMs.
+const ListToolDescription = `Lists configured notebooks. Fallback for lunatask://notebooks resource.
+
+Returns notebook metadata (IDs, names, keys).
+Use notebook IDs when creating or updating notes.`
+
+// ListInput is the input schema for listing notebooks.
+type ListInput struct{}
+
+// Summary represents a notebook in the list output.
+type Summary struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+	Key  string `json:"key"`
+}
+
+// ListOutput is the output schema for listing notebooks.
+type ListOutput struct {
+	Notebooks []Summary `json:"notebooks"`
+	Count     int       `json:"count"`
+}
+
+// Handler handles notebook tool requests.
+type Handler struct {
+	notebooks []shared.NotebookProvider
+}
+
+// NewHandler creates a new notebook tool handler.
+func NewHandler(notebooks []shared.NotebookProvider) *Handler {
+	return &Handler{notebooks: notebooks}
+}
+
+// HandleList lists configured notebooks.
+func (h *Handler) HandleList(
+	_ context.Context,
+	_ *mcp.CallToolRequest,
+	_ ListInput,
+) (*mcp.CallToolResult, ListOutput, error) {
+	summaries := make([]Summary, 0, len(h.notebooks))
+
+	for _, nb := range h.notebooks {
+		summaries = append(summaries, Summary{
+			ID:   nb.ID,
+			Name: nb.Name,
+			Key:  nb.Key,
+		})
+	}
+
+	output := ListOutput{
+		Notebooks: summaries,
+		Count:     len(summaries),
+	}
+
+	text := formatListText(summaries)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: text}},
+	}, output, nil
+}
+
+func formatListText(notebooks []Summary) string {
+	if len(notebooks) == 0 {
+		return "No notebooks configured"
+	}
+
+	var text strings.Builder
+
+	text.WriteString(fmt.Sprintf("Found %d notebook(s):\n", len(notebooks)))
+
+	for _, nb := range notebooks {
+		text.WriteString(fmt.Sprintf("- %s (key: %s, id: %s)\n", nb.Name, nb.Key, nb.ID))
+	}
+
+	return text.String()
+}

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

@@ -0,0 +1,121 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package person
+
+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 person tool.
+const ShowToolName = "show_person"
+
+// ShowToolDescription describes the show person tool for LLMs.
+const ShowToolDescription = `Shows metadata for a specific person from Lunatask.
+
+Required:
+- id: Person UUID or lunatask:// deep link
+
+Note: Due to end-to-end encryption, person name is not available.
+Only metadata (relationship strength, sources) is returned.`
+
+// ShowInput is the input schema for showing a person.
+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 person.
+type ShowOutput struct {
+	DeepLink             string       `json:"deep_link"`
+	RelationshipStrength *string      `json:"relationship_strength,omitempty"`
+	Sources              []ShowSource `json:"sources,omitempty"`
+	CreatedAt            string       `json:"created_at"`
+	UpdatedAt            string       `json:"updated_at"`
+}
+
+// HandleShow shows a person'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
+	}
+
+	person, err := h.client.GetPerson(ctx, id)
+	if err != nil {
+		return shared.ErrorResult(err.Error()), ShowOutput{}, nil
+	}
+
+	output := buildShowOutput(person)
+	text := formatPersonShowText(output)
+
+	return &mcp.CallToolResult{
+		Content: []mcp.Content{&mcp.TextContent{Text: text}},
+	}, output, nil
+}
+
+func buildShowOutput(person *lunatask.Person) ShowOutput {
+	output := ShowOutput{
+		CreatedAt: person.CreatedAt.Format(time.RFC3339),
+		UpdatedAt: person.UpdatedAt.Format(time.RFC3339),
+	}
+
+	output.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
+
+	if person.RelationshipStrength != nil {
+		s := string(*person.RelationshipStrength)
+		output.RelationshipStrength = &s
+	}
+
+	if len(person.Sources) > 0 {
+		output.Sources = make([]ShowSource, 0, len(person.Sources))
+		for _, src := range person.Sources {
+			output.Sources = append(output.Sources, ShowSource{
+				Source:   src.Source,
+				SourceID: src.SourceID,
+			})
+		}
+	}
+
+	return output
+}
+
+func formatPersonShowText(output ShowOutput) string {
+	var builder strings.Builder
+
+	builder.WriteString(fmt.Sprintf("Person: %s\n", output.DeepLink))
+
+	if output.RelationshipStrength != nil {
+		builder.WriteString(fmt.Sprintf("Relationship: %s\n", *output.RelationshipStrength))
+	}
+
+	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()
+}