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