diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index 6fca1aad3e2b774044ccee3167ade234ce65ad77..64bc764482ae078da389338987dfe5728f89570c 100644 --- a/cmd/mcp/mcp.go +++ b/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. diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index a11e116d10ccae88be1a33baacf4b4fcd171e8c6..2f2232ce8a7bde7eb2f0f3241f2d229807d88469 100644 --- a/cmd/mcp/server.go +++ b/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 { diff --git a/internal/config/config.go b/internal/config/config.go index 9ab7d740a8970bdf98f79b02a9ddb281a2c305d0..049ce150979b26c7a37f47c39d4470254d06cdfb 100644 --- a/internal/config/config.go +++ b/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 diff --git a/internal/mcp/resources/notes/handler.go b/internal/mcp/resources/notes/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..b2e471f2617da920241f42883b126e49102fede5 --- /dev/null +++ b/internal/mcp/resources/notes/handler.go @@ -0,0 +1,302 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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 +} diff --git a/internal/mcp/resources/people/handler.go b/internal/mcp/resources/people/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..937d877449ea732eaa23b3db64ffa62fa5fd09a1 --- /dev/null +++ b/internal/mcp/resources/people/handler.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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) +} diff --git a/internal/mcp/tools/area/list.go b/internal/mcp/tools/area/list.go new file mode 100644 index 0000000000000000000000000000000000000000..101546a46aecb6311a9aa3d462517caa3a01b713 --- /dev/null +++ b/internal/mcp/tools/area/list.go @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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() +} diff --git a/internal/mcp/tools/goal/list.go b/internal/mcp/tools/goal/list.go new file mode 100644 index 0000000000000000000000000000000000000000..545f5d8a8da6d20663adec6d789e2d7745bb57de --- /dev/null +++ b/internal/mcp/tools/goal/list.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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() +} diff --git a/internal/mcp/tools/habit/list.go b/internal/mcp/tools/habit/list.go new file mode 100644 index 0000000000000000000000000000000000000000..0c9c080a89a3746ba0cf2b009748ec47fc2de1cc --- /dev/null +++ b/internal/mcp/tools/habit/list.go @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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() +} diff --git a/internal/mcp/tools/notebook/list.go b/internal/mcp/tools/notebook/list.go new file mode 100644 index 0000000000000000000000000000000000000000..071cc03f789679ab7174663e3840f32adfab9db6 --- /dev/null +++ b/internal/mcp/tools/notebook/list.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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() +} diff --git a/internal/mcp/tools/person/show.go b/internal/mcp/tools/person/show.go new file mode 100644 index 0000000000000000000000000000000000000000..c95b1768613bee1a2d3c9feded24c639393eb338 --- /dev/null +++ b/internal/mcp/tools/person/show.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Amolith +// +// 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() +}