diff --git a/AGENTS.md b/AGENTS.md index c5c21b31c94d65324e32c6c748f6318522c34bbd..4b3c2e4e2df38f279ad15d7780a00542f8b741e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ internal/ validate/ → Input validation (delegates to go-lunatask Parse* functions) mcp/ shared/ → Provider types (AreaProvider, HabitProvider) and error helpers - tools/ → MCP tool handlers (task/, note/, person/, habit/, journal/, timestamp/) + tools/ → MCP tool handlers (crud/, habit/, timeline/, timestamp/) resources/ → MCP resource handlers (areas/, habits/, notebooks/, task/, note/, person/) ``` @@ -103,13 +103,21 @@ integration. Architecture: - **Shared** (`internal/mcp/shared/`): Provider types decouple config from MCP handlers; `ErrorResult()` returns structured tool errors. +**Consolidated tools** (7 total): + +- **CRUD tools** (`crud/`): `create`, `update`, `delete`, `query` — each accepts + an `entity` field to specify the type (task, note, person, journal, area, etc.) +- **Action tools**: `track_habit` (habit/), `add_timeline_note` (timeline/), + `get_timestamp` (timestamp/) + **Tool handler pattern**: Tools use `mcp.AddTool()` with typed input structs. Validation happens in `parse*Input()` functions returning `*mcp.CallToolResult` for errors (not Go errors). Parse functions call `lunatask.Parse*()` for enums and `dateutil.Parse()` for dates. **Config-driven tools**: `cfg.MCP.Tools.*` booleans enable/disable individual -tools. All default to enabled via `cfg.MCP.MCPDefaults()`. +tools. All default to enabled via `cfg.MCP.MCPDefaults()` (except `query`, which +is a fallback for agents without MCP resource support). ## Core dependencies diff --git a/cmd/mcp/mcp.go b/cmd/mcp/mcp.go index 1d208cd7456c90098baf42888fc9d5db11458637..35a9610a3b6694b783cb9aedfb03aaa15ecf7122 100644 --- a/cmd/mcp/mcp.go +++ b/cmd/mcp/mcp.go @@ -11,15 +11,9 @@ 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/crud" - "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/timeline" "git.secluded.site/lune/internal/mcp/tools/timestamp" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" @@ -161,33 +155,13 @@ func resolvePort(cfg *config.Config) int { // validToolNames maps MCP tool names to their ToolsConfig field setters. var validToolNames = map[string]func(*config.ToolsConfig, bool){ - timestamp.ToolName: func(t *config.ToolsConfig, v bool) { t.GetTimestamp = v }, - task.CreateToolName: func(t *config.ToolsConfig, v bool) { t.CreateTask = v }, - task.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.UpdateTask = v }, - task.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.DeleteTask = v }, - task.ListToolName: func(t *config.ToolsConfig, v bool) { t.ListTasks = v }, - task.ShowToolName: func(t *config.ToolsConfig, v bool) { t.ShowTask = v }, - note.CreateToolName: func(t *config.ToolsConfig, v bool) { t.CreateNote = v }, - note.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.UpdateNote = v }, - note.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.DeleteNote = v }, - note.ListToolName: func(t *config.ToolsConfig, v bool) { t.ListNotes = v }, - note.ShowToolName: func(t *config.ToolsConfig, v bool) { t.ShowNote = v }, - person.CreateToolName: func(t *config.ToolsConfig, v bool) { t.CreatePerson = v }, - person.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.UpdatePerson = v }, - person.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.DeletePerson = v }, - 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 }, - crud.CreateToolName: func(t *config.ToolsConfig, v bool) { t.Create = v }, - crud.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.Update = v }, - crud.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.Delete = v }, - crud.QueryToolName: func(t *config.ToolsConfig, v bool) { t.Query = v }, + timestamp.ToolName: func(t *config.ToolsConfig, v bool) { t.GetTimestamp = v }, + timeline.ToolName: func(t *config.ToolsConfig, v bool) { t.AddTimelineNote = v }, + habit.TrackToolName: func(t *config.ToolsConfig, v bool) { t.TrackHabit = v }, + crud.CreateToolName: func(t *config.ToolsConfig, v bool) { t.Create = v }, + crud.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.Update = v }, + crud.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.Delete = v }, + crud.QueryToolName: func(t *config.ToolsConfig, v bool) { t.Query = v }, } // resolveTools modifies cfg.MCP.Tools based on CLI flags. diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index fafcea206c72d7a7cc5c83e1de9193503e5fba4b..3008c8ef965d564a1da733e3f7f0e046b8cb5035 100644 --- a/cmd/mcp/server.go +++ b/cmd/mcp/server.go @@ -22,15 +22,9 @@ import ( 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" "git.secluded.site/lune/internal/mcp/tools/crud" - 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/timeline" "git.secluded.site/lune/internal/mcp/tools/timestamp" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" @@ -355,79 +349,23 @@ func registerTools( }, tsHandler.Handle) } - 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) + if tools.AddTimelineNote { + timelineHandler := timeline.NewHandler(accessToken) mcp.AddTool(mcpServer, &mcp.Tool{ - Name: journal.CreateToolName, - Description: journal.CreateToolDescription, - }, journalHandler.HandleCreate) + Name: timeline.ToolName, + Description: timeline.ToolDescription, + }, timelineHandler.Handle) } - registerCRUDTools(mcpServer, cfg, tools, accessToken, areaProviders, habitProviders, notebookProviders) -} - -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.ListHabits { - mcp.AddTool(mcpServer, &mcp.Tool{ - 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) - } + registerCRUDTools(mcpServer, cfg, tools, accessToken, areaProviders, habitProviders, notebookProviders) } func registerCRUDTools( @@ -474,145 +412,6 @@ func registerCRUDTools( } } -func registerTaskTools( - mcpServer *mcp.Server, - cfg *config.Config, - tools *config.ToolsConfig, - accessToken string, - areaProviders []shared.AreaProvider, -) { - taskHandler := task.NewHandler(accessToken, cfg, areaProviders) - - if tools.CreateTask { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: task.CreateToolName, - Description: task.CreateToolDescription, - }, taskHandler.HandleCreate) - } - - if tools.UpdateTask { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: task.UpdateToolName, - Description: task.UpdateToolDescription, - }, taskHandler.HandleUpdate) - } - - if tools.DeleteTask { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: task.DeleteToolName, - Description: task.DeleteToolDescription, - }, taskHandler.HandleDelete) - } - - if tools.ListTasks { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: task.ListToolName, - Description: task.ListToolDescription, - }, taskHandler.HandleList) - } - - if tools.ShowTask { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: task.ShowToolName, - Description: task.ShowToolDescription, - }, taskHandler.HandleShow) - } -} - -func registerNoteTools( - mcpServer *mcp.Server, - tools *config.ToolsConfig, - accessToken string, - notebookProviders []shared.NotebookProvider, -) { - noteHandler := notetool.NewHandler(accessToken, notebookProviders) - - if tools.CreateNote { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: notetool.CreateToolName, - Description: notetool.CreateToolDescription, - }, noteHandler.HandleCreate) - } - - if tools.UpdateNote { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: notetool.UpdateToolName, - Description: notetool.UpdateToolDescription, - }, noteHandler.HandleUpdate) - } - - if tools.DeleteNote { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: notetool.DeleteToolName, - Description: notetool.DeleteToolDescription, - }, noteHandler.HandleDelete) - } - - if tools.ListNotes { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: notetool.ListToolName, - Description: notetool.ListToolDescription, - }, noteHandler.HandleList) - } - - if tools.ShowNote { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: notetool.ShowToolName, - Description: notetool.ShowToolDescription, - }, noteHandler.HandleShow) - } -} - -func registerPersonTools( - mcpServer *mcp.Server, - tools *config.ToolsConfig, - accessToken string, -) { - personHandler := persontool.NewHandler(accessToken) - - if tools.CreatePerson { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: persontool.CreateToolName, - Description: persontool.CreateToolDescription, - }, personHandler.HandleCreate) - } - - if tools.UpdatePerson { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: persontool.UpdateToolName, - Description: persontool.UpdateToolDescription, - }, personHandler.HandleUpdate) - } - - if tools.DeletePerson { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: persontool.DeleteToolName, - Description: persontool.DeleteToolDescription, - }, personHandler.HandleDelete) - } - - if tools.ListPeople { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: persontool.ListToolName, - Description: persontool.ListToolDescription, - }, personHandler.HandleList) - } - - if tools.PersonTimeline { - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: persontool.TimelineToolName, - 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 { if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil { return fmt.Errorf("stdio server error: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index ad1a0d1449d1f4b78b6b71537ae32e6f5f7faa57..6ba72c25fa71238979127fecb2bcb1f5fc838213 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,34 +39,9 @@ type MCPConfig struct { // ToolsConfig controls which MCP tools are enabled. // All tools default to enabled when not explicitly set. type ToolsConfig struct { - GetTimestamp bool `toml:"get_timestamp"` - - CreateTask bool `toml:"create_task"` - UpdateTask bool `toml:"update_task"` - DeleteTask bool `toml:"delete_task"` - ListTasks bool `toml:"list_tasks"` - ShowTask bool `toml:"show_task"` - - CreateNote bool `toml:"create_note"` - UpdateNote bool `toml:"update_note"` - DeleteNote bool `toml:"delete_note"` - ListNotes bool `toml:"list_notes"` - ShowNote bool `toml:"show_note"` - - CreatePerson bool `toml:"create_person"` - UpdatePerson bool `toml:"update_person"` - DeletePerson bool `toml:"delete_person"` - ListPeople bool `toml:"list_people"` - ShowPerson bool `toml:"show_person"` - PersonTimeline bool `toml:"person_timeline"` - - 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"` + GetTimestamp bool `toml:"get_timestamp"` + AddTimelineNote bool `toml:"add_timeline_note"` + TrackHabit bool `toml:"track_habit"` Create bool `toml:"create"` // task, note, person, journal Update bool `toml:"update"` // task, note, person @@ -92,42 +67,19 @@ func (c *MCPConfig) MCPDefaults() { } // ApplyDefaults enables all tools if none are explicitly configured. -// Note: "show" tools and config-based "list" tools are default-disabled -// because they are fallbacks for agents that don't support MCP resources. -// -//nolint:cyclop // Complexity from repetitive boolean checks; structurally simple. +// Note: Query is default-disabled because it's a fallback for agents +// that don't support MCP resources. func (t *ToolsConfig) ApplyDefaults() { - // If all are false (zero value), enable everything except resource fallbacks - if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask && - !t.ListTasks && !t.ShowTask && !t.CreateNote && !t.UpdateNote && - !t.DeleteNote && !t.ListNotes && !t.ShowNote && !t.CreatePerson && - !t.UpdatePerson && !t.DeletePerson && !t.ListPeople && !t.ShowPerson && - !t.PersonTimeline && !t.TrackHabit && !t.ListHabits && !t.ListNotebooks && - !t.ListAreas && !t.ListGoals && !t.CreateJournal && + // If all are false (zero value), enable everything except Query + if !t.GetTimestamp && !t.AddTimelineNote && !t.TrackHabit && !t.Create && !t.Update && !t.Delete && !t.Query { t.GetTimestamp = true - t.CreateTask = true - t.UpdateTask = true - t.DeleteTask = true - // ListTasks: default-disabled (fallback for lunatask://tasks/* resources) - // ShowTask: default-disabled (fallback for lunatask://task/{id} resource) - t.CreateNote = true - t.UpdateNote = true - t.DeleteNote = 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 - // ListPeople: default-disabled (fallback for lunatask://people/* resources) - // ShowPerson: default-disabled (fallback for lunatask://person/{id} resource) - t.PersonTimeline = true + t.AddTimelineNote = true t.TrackHabit = true - // ListHabits: default-disabled (fallback for lunatask://habits resource) - t.CreateJournal = true t.Create = true t.Update = true t.Delete = true + // Query: default-disabled (fallback for agents without resource support) } } diff --git a/internal/mcp/tools/area/list.go b/internal/mcp/tools/area/list.go deleted file mode 100644 index 101546a46aecb6311a9aa3d462517caa3a01b713..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/area/list.go +++ /dev/null @@ -1,96 +0,0 @@ -// 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/crud/create.go b/internal/mcp/tools/crud/create.go index 97f60991f4fa2caaf7799aa88ec3448ed05696cf..88116b7f899e4eaee24c2a547716d836a13d4c82 100644 --- a/internal/mcp/tools/crud/create.go +++ b/internal/mcp/tools/crud/create.go @@ -10,7 +10,9 @@ import ( "git.secluded.site/go-lunatask" "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/dateutil" "git.secluded.site/lune/internal/mcp/shared" + "git.secluded.site/lune/internal/validate" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -74,7 +76,7 @@ Returns the created entity's ID and deep link.` // CreateInput is the input schema for the consolidated create tool. type CreateInput struct { - Entity string `json:"entity" jsonschema:"required,enum=task,enum=note,enum=person,enum=journal"` + Entity string `json:"entity" jsonschema:"required"` // Common fields Name *string `json:"name,omitempty"` @@ -160,34 +162,303 @@ func (h *Handler) HandleCreate( } } +// parsedTaskCreateInput holds validated and parsed task create input fields. +type parsedTaskCreateInput struct { + Name string + AreaID string + GoalID *string + Status *lunatask.TaskStatus + Note *string + Priority *lunatask.Priority + Estimate *int + Motivation *lunatask.Motivation + Important *bool + Urgent *bool + ScheduledOn *lunatask.Date +} + func (h *Handler) createTask( - _ context.Context, + ctx context.Context, input CreateInput, ) (*mcp.CallToolResult, CreateOutput, error) { - return shared.ErrorResult("task creation not yet implemented"), - CreateOutput{Entity: input.Entity}, nil + parsed, errResult := h.parseTaskCreateInput(input) + if errResult != nil { + return errResult, CreateOutput{Entity: input.Entity}, nil + } + + builder := h.client.NewTask(parsed.Name) + applyToTaskBuilder(builder, parsed) + + task, err := builder.Create(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Task created: " + deepLink, + }}, + }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: task.ID}, nil +} + +//nolint:cyclop,funlen +func (h *Handler) parseTaskCreateInput(input CreateInput) (*parsedTaskCreateInput, *mcp.CallToolResult) { + if input.Name == nil || *input.Name == "" { + return nil, shared.ErrorResult("name is required for task creation") + } + + if input.AreaID == nil || *input.AreaID == "" { + return nil, shared.ErrorResult("area_id is required for task creation") + } + + parsed := &parsedTaskCreateInput{ + Name: *input.Name, + Note: input.Note, + Estimate: input.Estimate, + Important: input.Important, + Urgent: input.Urgent, + } + + areaID, err := validate.AreaRef(h.cfg, *input.AreaID) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.AreaID = areaID + + if input.GoalID != nil { + goalID, err := validate.GoalRef(h.cfg, parsed.AreaID, *input.GoalID) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.GoalID = &goalID + } + + if input.Estimate != nil { + if err := shared.ValidateEstimate(*input.Estimate); err != nil { + return nil, shared.ErrorResult(err.Error()) + } + } + + if input.Status != nil { + status, err := lunatask.ParseTaskStatus(*input.Status) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.Status = &status + } + + if input.Priority != nil { + priority, err := lunatask.ParsePriority(*input.Priority) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.Priority = &priority + } + + if input.Motivation != nil { + motivation, err := lunatask.ParseMotivation(*input.Motivation) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.Motivation = &motivation + } + + if input.ScheduledOn != nil { + date, err := dateutil.Parse(*input.ScheduledOn) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.ScheduledOn = &date + } + + return parsed, nil +} + +//nolint:cyclop +func applyToTaskBuilder(builder *lunatask.TaskBuilder, parsed *parsedTaskCreateInput) { + builder.InArea(parsed.AreaID) + + if parsed.GoalID != nil { + builder.InGoal(*parsed.GoalID) + } + + if parsed.Status != nil { + builder.WithStatus(*parsed.Status) + } + + if parsed.Note != nil { + builder.WithNote(*parsed.Note) + } + + if parsed.Priority != nil { + builder.Priority(*parsed.Priority) + } + + if parsed.Estimate != nil { + builder.WithEstimate(*parsed.Estimate) + } + + if parsed.Motivation != nil { + builder.WithMotivation(*parsed.Motivation) + } + + if parsed.Important != nil { + if *parsed.Important { + builder.Important() + } else { + builder.NotImportant() + } + } + + if parsed.Urgent != nil { + if *parsed.Urgent { + builder.Urgent() + } else { + builder.NotUrgent() + } + } + + if parsed.ScheduledOn != nil { + builder.ScheduledOn(*parsed.ScheduledOn) + } } func (h *Handler) createNote( - _ context.Context, + ctx context.Context, input CreateInput, ) (*mcp.CallToolResult, CreateOutput, error) { - return shared.ErrorResult("note creation not yet implemented"), - CreateOutput{Entity: input.Entity}, nil + if input.NotebookID != nil { + if err := lunatask.ValidateUUID(*input.NotebookID); err != nil { + return shared.ErrorResult("invalid notebook_id: expected UUID"), + CreateOutput{Entity: input.Entity}, nil + } + } + + builder := h.client.NewNote() + + if input.Name != nil { + builder.WithName(*input.Name) + } + + if input.NotebookID != nil { + builder.InNotebook(*input.NotebookID) + } + + if input.Content != nil { + builder.WithContent(*input.Content) + } + + if input.Source != nil && input.SourceID != nil { + builder.FromSource(*input.Source, *input.SourceID) + } + + note, err := builder.Create(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil + } + + if note == nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Note already exists (duplicate source)", + }}, + }, CreateOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Note created: " + deepLink, + }}, + }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: note.ID}, nil } func (h *Handler) createPerson( - _ context.Context, + ctx context.Context, input CreateInput, ) (*mcp.CallToolResult, CreateOutput, error) { - return shared.ErrorResult("person creation not yet implemented"), - CreateOutput{Entity: input.Entity}, nil + if input.FirstName == nil || *input.FirstName == "" { + return shared.ErrorResult("first_name is required for person creation"), + CreateOutput{Entity: input.Entity}, nil + } + + lastName := "" + if input.LastName != nil { + lastName = *input.LastName + } + + builder := h.client.NewPerson(*input.FirstName, lastName) + + if input.Relationship != nil { + rel, err := lunatask.ParseRelationshipStrength(*input.Relationship) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil + } + + builder.WithRelationshipStrength(rel) + } + + if input.Source != nil && input.SourceID != nil { + builder.FromSource(*input.Source, *input.SourceID) + } + + person, err := builder.Create(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Person created: " + deepLink, + }}, + }, CreateOutput{Entity: input.Entity, DeepLink: deepLink, ID: person.ID}, nil } func (h *Handler) createJournal( - _ context.Context, + ctx context.Context, input CreateInput, ) (*mcp.CallToolResult, CreateOutput, error) { - return shared.ErrorResult("journal creation not yet implemented"), - CreateOutput{Entity: input.Entity}, nil + dateStr := "" + if input.Date != nil { + dateStr = *input.Date + } + + date, err := dateutil.Parse(dateStr) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil + } + + builder := h.client.NewJournalEntry(date) + + if input.Content != nil { + builder.WithContent(*input.Content) + } + + if input.Name != nil { + builder.WithName(*input.Name) + } + + entry, err := builder.Create(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), CreateOutput{Entity: input.Entity}, nil + } + + formattedDate := date.Format("2006-01-02") + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Journal entry created for " + formattedDate, + }}, + }, CreateOutput{Entity: input.Entity, ID: entry.ID}, nil } diff --git a/internal/mcp/tools/crud/delete.go b/internal/mcp/tools/crud/delete.go index 2837c10240ef3cf91f28273b86d05bd053110bdf..a75610d3a52d215395cddc5b78df9dff4c79d4eb 100644 --- a/internal/mcp/tools/crud/delete.go +++ b/internal/mcp/tools/crud/delete.go @@ -7,6 +7,7 @@ package crud import ( "context" + "git.secluded.site/go-lunatask" "git.secluded.site/lune/internal/mcp/shared" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -27,7 +28,7 @@ Returns confirmation of deletion with the entity's deep link.` // DeleteInput is the input schema for the consolidated delete tool. type DeleteInput struct { - Entity string `json:"entity" jsonschema:"required,enum=task,enum=note,enum=person"` + Entity string `json:"entity" jsonschema:"required"` ID string `json:"id" jsonschema:"required"` } @@ -58,25 +59,72 @@ func (h *Handler) HandleDelete( } func (h *Handler) deleteTask( - _ context.Context, + ctx context.Context, input DeleteInput, ) (*mcp.CallToolResult, DeleteOutput, error) { - return shared.ErrorResult("task deletion not yet implemented"), - DeleteOutput{Entity: input.Entity}, nil + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + DeleteOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, id) + + if _, err := h.client.DeleteTask(ctx, id); err != nil { + return shared.ErrorResult(err.Error()), DeleteOutput{Entity: input.Entity}, nil + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Task deleted: " + deepLink, + }}, + }, DeleteOutput{Entity: input.Entity, DeepLink: deepLink, Success: true}, nil } func (h *Handler) deleteNote( - _ context.Context, + ctx context.Context, input DeleteInput, ) (*mcp.CallToolResult, DeleteOutput, error) { - return shared.ErrorResult("note deletion not yet implemented"), - DeleteOutput{Entity: input.Entity}, nil + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + DeleteOutput{Entity: input.Entity}, nil + } + + note, err := h.client.DeleteNote(ctx, id) + if err != nil { + return shared.ErrorResult(err.Error()), DeleteOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Note deleted: " + deepLink, + }}, + }, DeleteOutput{Entity: input.Entity, DeepLink: deepLink, Success: true}, nil } func (h *Handler) deletePerson( - _ context.Context, + ctx context.Context, input DeleteInput, ) (*mcp.CallToolResult, DeleteOutput, error) { - return shared.ErrorResult("person deletion not yet implemented"), - DeleteOutput{Entity: input.Entity}, nil + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + DeleteOutput{Entity: input.Entity}, nil + } + + person, err := h.client.DeletePerson(ctx, id) + if err != nil { + return shared.ErrorResult(err.Error()), DeleteOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Person deleted: " + deepLink, + }}, + }, DeleteOutput{Entity: input.Entity, DeepLink: deepLink, Success: true}, nil } diff --git a/internal/mcp/tools/crud/query.go b/internal/mcp/tools/crud/query.go index c8ad2837b69d3509ca4e0d0b9cacbd1bec0bc0c0..ab6597e85219ff992940dea14cb7e3a81e696264 100644 --- a/internal/mcp/tools/crud/query.go +++ b/internal/mcp/tools/crud/query.go @@ -6,7 +6,11 @@ package crud import ( "context" + "fmt" + "strings" + "time" + "git.secluded.site/go-lunatask" "git.secluded.site/lune/internal/mcp/shared" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -45,11 +49,11 @@ When id is omitted, returns a list with optional filters: **area, notebook, habit**: No filters (returns all from config) Note: Due to end-to-end encryption, names and content are not available -for list operations — only metadata is returned. Use id parameter for details.` +for list operations. Only metadata is returned. Use id parameter for details.` // QueryInput is the input schema for the consolidated query tool. type QueryInput struct { - Entity string `json:"entity" jsonschema:"required,enum=task,enum=note,enum=person,enum=area,enum=goal,enum=notebook,enum=habit"` //nolint:lll // JSON schema enum list + Entity string `json:"entity" jsonschema:"required"` ID *string `json:"id,omitempty"` // Task/Goal filters @@ -104,28 +108,703 @@ func (h *Handler) HandleQuery( } } +// TaskSummary represents a task in list output. +type TaskSummary struct { + DeepLink string `json:"deep_link"` + Status *string `json:"status,omitempty"` + Priority *int `json:"priority,omitempty"` + ScheduledOn *string `json:"scheduled_on,omitempty"` + CreatedAt string `json:"created_at"` + AreaID *string `json:"area_id,omitempty"` + GoalID *string `json:"goal_id,omitempty"` +} + +// TaskDetail represents detailed task information. +type TaskDetail struct { + DeepLink string `json:"deep_link"` + Status *string `json:"status,omitempty"` + Priority *int `json:"priority,omitempty"` + Estimate *int `json:"estimate,omitempty"` + ScheduledOn *string `json:"scheduled_on,omitempty"` + CompletedAt *string `json:"completed_at,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + AreaID *string `json:"area_id,omitempty"` + GoalID *string `json:"goal_id,omitempty"` + Important *bool `json:"important,omitempty"` + Urgent *bool `json:"urgent,omitempty"` +} + func (h *Handler) queryTask( - _ context.Context, + ctx context.Context, input QueryInput, ) (*mcp.CallToolResult, QueryOutput, error) { - return shared.ErrorResult("task query not yet implemented"), - QueryOutput{Entity: input.Entity}, nil + if input.ID != nil { + return h.showTask(ctx, *input.ID) + } + + return h.listTasks(ctx, input) +} + +func (h *Handler) showTask( + ctx context.Context, + id string, +) (*mcp.CallToolResult, QueryOutput, error) { + _, taskID, err := lunatask.ParseReference(id) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + QueryOutput{Entity: EntityTask}, nil + } + + task, err := h.client.GetTask(ctx, taskID) + if err != nil { + return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityTask}, nil + } + + detail := TaskDetail{ + CreatedAt: task.CreatedAt.Format(time.RFC3339), + UpdatedAt: task.UpdatedAt.Format(time.RFC3339), + AreaID: task.AreaID, + GoalID: task.GoalID, + } + + detail.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + + if task.Status != nil { + s := string(*task.Status) + detail.Status = &s + } + + if task.Priority != nil { + p := int(*task.Priority) + detail.Priority = &p + } + + if task.Estimate != nil { + detail.Estimate = task.Estimate + } + + if task.ScheduledOn != nil { + s := task.ScheduledOn.Format("2006-01-02") + detail.ScheduledOn = &s + } + + if task.CompletedAt != nil { + s := task.CompletedAt.Format(time.RFC3339) + detail.CompletedAt = &s + } + + if task.Eisenhower != nil { + important := task.Eisenhower.IsImportant() + urgent := task.Eisenhower.IsUrgent() + detail.Important = &important + detail.Urgent = &urgent + } + + text := formatTaskShowText(detail) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{Entity: EntityTask, DeepLink: detail.DeepLink, Items: detail}, nil +} + +func (h *Handler) listTasks( + ctx context.Context, + input QueryInput, +) (*mcp.CallToolResult, QueryOutput, error) { + if input.AreaID != nil { + if err := lunatask.ValidateUUID(*input.AreaID); err != nil { + return shared.ErrorResult("invalid area_id: expected UUID"), + QueryOutput{Entity: EntityTask}, nil + } + } + + if input.Status != nil { + if _, err := lunatask.ParseTaskStatus(*input.Status); err != nil { + return shared.ErrorResult("invalid status: must be later, next, started, waiting, or completed"), + QueryOutput{Entity: EntityTask}, nil + } + } + + tasks, err := h.client.ListTasks(ctx, nil) + if err != nil { + return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityTask}, nil + } + + opts := &lunatask.TaskFilterOptions{ + AreaID: input.AreaID, + IncludeCompleted: input.IncludeCompleted != nil && *input.IncludeCompleted, + Today: time.Now(), + } + + if input.Status != nil { + s := lunatask.TaskStatus(*input.Status) + opts.Status = &s + } + + filtered := lunatask.FilterTasks(tasks, opts) + summaries := buildTaskSummaries(filtered) + text := formatTaskListText(summaries) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{ + Entity: EntityTask, + Items: summaries, + Count: len(summaries), + }, nil +} + +func buildTaskSummaries(tasks []lunatask.Task) []TaskSummary { + summaries := make([]TaskSummary, 0, len(tasks)) + + for _, task := range tasks { + summary := TaskSummary{ + CreatedAt: task.CreatedAt.Format(time.RFC3339), + AreaID: task.AreaID, + GoalID: task.GoalID, + } + + summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + + if task.Status != nil { + s := string(*task.Status) + summary.Status = &s + } + + if task.Priority != nil { + p := int(*task.Priority) + summary.Priority = &p + } + + if task.ScheduledOn != nil { + s := task.ScheduledOn.Format("2006-01-02") + summary.ScheduledOn = &s + } + + summaries = append(summaries, summary) + } + + return summaries +} + +func formatTaskListText(summaries []TaskSummary) string { + if len(summaries) == 0 { + return "No tasks found." + } + + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Found %d task(s):\n", len(summaries))) + + for _, summary := range summaries { + status := "unknown" + if summary.Status != nil { + status = *summary.Status + } + + builder.WriteString(fmt.Sprintf("- %s (%s)\n", summary.DeepLink, status)) + } + + builder.WriteString("\nUse query with id for full details.") + + return builder.String() +} + +func formatTaskShowText(detail TaskDetail) string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Task: %s\n", detail.DeepLink)) + writeOptionalField(&builder, "Status", detail.Status) + writeOptionalIntField(&builder, "Priority", detail.Priority) + writeOptionalField(&builder, "Scheduled", detail.ScheduledOn) + writeOptionalMinutesField(&builder, "Estimate", detail.Estimate) + writeEisenhowerField(&builder, detail.Important, detail.Urgent) + builder.WriteString(fmt.Sprintf("Created: %s\n", detail.CreatedAt)) + builder.WriteString("Updated: " + detail.UpdatedAt) + writeOptionalField(&builder, "\nCompleted", detail.CompletedAt) + + return builder.String() +} + +func writeOptionalField(builder *strings.Builder, label string, value *string) { + if value != nil { + fmt.Fprintf(builder, "%s: %s\n", label, *value) + } +} + +func writeOptionalIntField(builder *strings.Builder, label string, value *int) { + if value != nil { + fmt.Fprintf(builder, "%s: %d\n", label, *value) + } +} + +func writeOptionalMinutesField(builder *strings.Builder, label string, value *int) { + if value != nil { + fmt.Fprintf(builder, "%s: %d min\n", label, *value) + } +} + +func writeEisenhowerField(builder *strings.Builder, important, urgent *bool) { + var parts []string + + if important != nil && *important { + parts = append(parts, "important") + } + + if urgent != nil && *urgent { + parts = append(parts, "urgent") + } + + if len(parts) > 0 { + fmt.Fprintf(builder, "Eisenhower: %s\n", strings.Join(parts, ", ")) + } +} + +// 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"` +} + +// NoteSource represents a source reference in note output. +type NoteSource struct { + Source string `json:"source"` + SourceID string `json:"source_id"` +} + +// NoteDetail represents detailed note information. +type NoteDetail struct { + DeepLink string `json:"deep_link"` + NotebookID *string `json:"notebook_id,omitempty"` + DateOn *string `json:"date_on,omitempty"` + Pinned bool `json:"pinned"` + Sources []NoteSource `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } func (h *Handler) queryNote( - _ context.Context, + ctx context.Context, + input QueryInput, +) (*mcp.CallToolResult, QueryOutput, error) { + if input.ID != nil { + return h.showNote(ctx, *input.ID) + } + + return h.listNotes(ctx, input) +} + +func (h *Handler) showNote( + ctx context.Context, + id string, +) (*mcp.CallToolResult, QueryOutput, error) { + _, noteID, err := lunatask.ParseReference(id) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + QueryOutput{Entity: EntityNote}, nil + } + + note, err := h.client.GetNote(ctx, noteID) + if err != nil { + return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityNote}, nil + } + + detail := NoteDetail{ + NotebookID: note.NotebookID, + Pinned: note.Pinned, + CreatedAt: note.CreatedAt.Format(time.RFC3339), + UpdatedAt: note.UpdatedAt.Format(time.RFC3339), + } + + detail.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) + + if note.DateOn != nil { + s := note.DateOn.Format("2006-01-02") + detail.DateOn = &s + } + + if len(note.Sources) > 0 { + detail.Sources = make([]NoteSource, 0, len(note.Sources)) + for _, src := range note.Sources { + detail.Sources = append(detail.Sources, NoteSource{ + Source: src.Source, + SourceID: src.SourceID, + }) + } + } + + text := formatNoteShowText(detail, h.notebooks) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{Entity: EntityNote, DeepLink: detail.DeepLink, Items: detail}, nil +} + +func (h *Handler) listNotes( + ctx context.Context, input QueryInput, ) (*mcp.CallToolResult, QueryOutput, error) { - return shared.ErrorResult("note query not yet implemented"), - QueryOutput{Entity: input.Entity}, nil + if input.NotebookID != nil { + if err := lunatask.ValidateUUID(*input.NotebookID); err != nil { + return shared.ErrorResult("invalid notebook_id: expected UUID"), + QueryOutput{Entity: EntityNote}, nil + } + } + + opts := buildNoteListOptions(input) + + notes, err := h.client.ListNotes(ctx, opts) + if err != nil { + return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityNote}, nil + } + + if input.NotebookID != nil { + notes = filterNotesByNotebook(notes, *input.NotebookID) + } + + summaries := buildNoteSummaries(notes) + text := formatNoteListText(summaries, h.notebooks) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{ + Entity: EntityNote, + Items: summaries, + Count: len(summaries), + }, nil +} + +func buildNoteListOptions(input QueryInput) *lunatask.ListNotesOptions { + if input.Source == nil && input.SourceID == nil { + return nil + } + + opts := &lunatask.ListNotesOptions{} + + if input.Source != nil { + opts.Source = input.Source + } + + if input.SourceID != nil { + opts.SourceID = input.SourceID + } + + return opts +} + +func filterNotesByNotebook(notes []lunatask.Note, notebookID string) []lunatask.Note { + filtered := make([]lunatask.Note, 0, len(notes)) + + for _, note := range notes { + if note.NotebookID != nil && *note.NotebookID == notebookID { + filtered = append(filtered, note) + } + } + + return filtered +} + +func buildNoteSummaries(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("2006-01-02"), + } + + summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) + + if note.DateOn != nil { + dateStr := note.DateOn.Format("2006-01-02") + summary.DateOn = &dateStr + } + + summaries = append(summaries, summary) + } + + return summaries +} + +func formatNoteListText(summaries []NoteSummary, notebooks []shared.NotebookProvider) string { + if len(summaries) == 0 { + return "No notes found" + } + + var text strings.Builder + + text.WriteString(fmt.Sprintf("Found %d note(s):\n", len(summaries))) + + for _, item := range summaries { + text.WriteString("- ") + text.WriteString(item.DeepLink) + + var details []string + + if item.NotebookID != nil { + nbName := *item.NotebookID + for _, nb := range notebooks { + if nb.ID == *item.NotebookID { + nbName = nb.Key + + break + } + } + + details = append(details, "notebook: "+nbName) + } + + if item.Pinned { + details = append(details, "pinned") + } + + if len(details) > 0 { + text.WriteString(" (") + text.WriteString(strings.Join(details, ", ")) + text.WriteString(")") + } + + text.WriteString("\n") + } + + text.WriteString("\nUse query with id for full details.") + + return text.String() +} + +func formatNoteShowText(detail NoteDetail, notebooks []shared.NotebookProvider) string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Note: %s\n", detail.DeepLink)) + + if detail.NotebookID != nil { + nbName := *detail.NotebookID + for _, nb := range notebooks { + if nb.ID == *detail.NotebookID { + nbName = nb.Key + + break + } + } + + builder.WriteString(fmt.Sprintf("Notebook: %s\n", nbName)) + } + + if detail.DateOn != nil { + builder.WriteString(fmt.Sprintf("Date: %s\n", *detail.DateOn)) + } + + if detail.Pinned { + builder.WriteString("Pinned: yes\n") + } + + if len(detail.Sources) > 0 { + builder.WriteString("Sources:\n") + + for _, src := range detail.Sources { + builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID)) + } + } + + builder.WriteString(fmt.Sprintf("Created: %s\n", detail.CreatedAt)) + builder.WriteString("Updated: " + detail.UpdatedAt) + + return builder.String() } func (h *Handler) queryPerson( - _ context.Context, + ctx context.Context, + input QueryInput, +) (*mcp.CallToolResult, QueryOutput, error) { + if input.ID != nil { + return h.showPerson(ctx, *input.ID) + } + + return h.listPeople(ctx, input) +} + +// 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"` +} + +// PersonSource represents a source reference in person output. +type PersonSource struct { + Source string `json:"source"` + SourceID string `json:"source_id"` +} + +// PersonDetail represents detailed person information. +type PersonDetail struct { + DeepLink string `json:"deep_link"` + RelationshipStrength *string `json:"relationship_strength,omitempty"` + Sources []PersonSource `json:"sources,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (h *Handler) showPerson( + ctx context.Context, + id string, +) (*mcp.CallToolResult, QueryOutput, error) { + _, personID, err := lunatask.ParseReference(id) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + QueryOutput{Entity: EntityPerson}, nil + } + + person, err := h.client.GetPerson(ctx, personID) + if err != nil { + return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityPerson}, nil + } + + detail := PersonDetail{ + CreatedAt: person.CreatedAt.Format(time.RFC3339), + UpdatedAt: person.UpdatedAt.Format(time.RFC3339), + } + + detail.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + if person.RelationshipStrength != nil { + s := string(*person.RelationshipStrength) + detail.RelationshipStrength = &s + } + + if len(person.Sources) > 0 { + detail.Sources = make([]PersonSource, 0, len(person.Sources)) + for _, src := range person.Sources { + detail.Sources = append(detail.Sources, PersonSource{ + Source: src.Source, + SourceID: src.SourceID, + }) + } + } + + text := formatPersonShowText(detail) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{Entity: EntityPerson, DeepLink: detail.DeepLink, Items: detail}, nil +} + +func (h *Handler) listPeople( + ctx context.Context, input QueryInput, ) (*mcp.CallToolResult, QueryOutput, error) { - return shared.ErrorResult("person query not yet implemented"), - QueryOutput{Entity: input.Entity}, nil + opts := buildPeopleListOptions(input) + + people, err := h.client.ListPeople(ctx, opts) + if err != nil { + return shared.ErrorResult(err.Error()), QueryOutput{Entity: EntityPerson}, nil + } + + summaries := buildPersonSummaries(people) + text := formatPeopleListText(summaries) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{ + Entity: EntityPerson, + Items: summaries, + Count: len(summaries), + }, nil +} + +func buildPeopleListOptions(input QueryInput) *lunatask.ListPeopleOptions { + if input.Source == nil && input.SourceID == nil { + return nil + } + + opts := &lunatask.ListPeopleOptions{} + + if input.Source != nil { + opts.Source = input.Source + } + + if input.SourceID != nil { + opts.SourceID = input.SourceID + } + + return opts +} + +func buildPersonSummaries(people []lunatask.Person) []PersonSummary { + summaries := make([]PersonSummary, 0, len(people)) + + for _, person := range people { + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + summary := PersonSummary{ + DeepLink: deepLink, + CreatedAt: person.CreatedAt.Format("2006-01-02"), + } + + if person.RelationshipStrength != nil { + rel := string(*person.RelationshipStrength) + summary.RelationshipStrength = &rel + } + + summaries = append(summaries, summary) + } + + return summaries +} + +func formatPeopleListText(summaries []PersonSummary) string { + if len(summaries) == 0 { + return "No people found" + } + + var text strings.Builder + + text.WriteString(fmt.Sprintf("Found %d person(s):\n", len(summaries))) + + for _, item := range summaries { + text.WriteString("- ") + text.WriteString(item.DeepLink) + + if item.RelationshipStrength != nil { + text.WriteString(" (") + text.WriteString(*item.RelationshipStrength) + text.WriteString(")") + } + + text.WriteString("\n") + } + + text.WriteString("\nUse query with id for full details.") + + return text.String() +} + +func formatPersonShowText(detail PersonDetail) string { + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Person: %s\n", detail.DeepLink)) + + if detail.RelationshipStrength != nil { + builder.WriteString(fmt.Sprintf("Relationship: %s\n", *detail.RelationshipStrength)) + } + + if len(detail.Sources) > 0 { + builder.WriteString("Sources:\n") + + for _, src := range detail.Sources { + builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID)) + } + } + + builder.WriteString(fmt.Sprintf("Created: %s\n", detail.CreatedAt)) + builder.WriteString("Updated: " + detail.UpdatedAt) + + return builder.String() } func (h *Handler) queryArea( @@ -137,8 +816,50 @@ func (h *Handler) queryArea( QueryOutput{Entity: input.Entity}, nil } - return shared.ErrorResult("area query not yet implemented"), - QueryOutput{Entity: input.Entity}, nil + summaries := make([]AreaSummary, 0, len(h.areas)) + + for _, area := range h.areas { + summaries = append(summaries, AreaSummary{ + ID: area.ID, + Name: area.Name, + Key: area.Key, + Workflow: string(area.Workflow), + }) + } + + text := formatAreaListText(summaries) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{ + Entity: EntityArea, + Items: summaries, + Count: len(summaries), + }, nil +} + +// AreaSummary represents an area in list output. +type AreaSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + Workflow string `json:"workflow"` +} + +func formatAreaListText(areas []AreaSummary) 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() } func (h *Handler) queryGoal( @@ -150,8 +871,81 @@ func (h *Handler) queryGoal( QueryOutput{Entity: input.Entity}, nil } - return shared.ErrorResult("goal query not yet implemented"), - QueryOutput{Entity: input.Entity}, nil + if input.AreaID == nil { + return shared.ErrorResult("area_id is required for goal query"), + QueryOutput{Entity: input.Entity}, nil + } + + area := h.resolveAreaRef(*input.AreaID) + if area == nil { + return shared.ErrorResult("unknown area: " + *input.AreaID), + QueryOutput{Entity: input.Entity}, nil + } + + summaries := make([]GoalSummary, 0, len(area.Goals)) + + for _, goal := range area.Goals { + summaries = append(summaries, GoalSummary{ + ID: goal.ID, + Name: goal.Name, + Key: goal.Key, + }) + } + + text := formatGoalListText(summaries, area.Name) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{ + Entity: EntityGoal, + Items: summaries, + Count: len(summaries), + }, nil +} + +// GoalSummary represents a goal in list output. +type GoalSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` +} + +// 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 formatGoalListText(goals []GoalSummary, 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() } func (h *Handler) queryNotebook( @@ -163,8 +957,48 @@ func (h *Handler) queryNotebook( QueryOutput{Entity: input.Entity}, nil } - return shared.ErrorResult("notebook query not yet implemented"), - QueryOutput{Entity: input.Entity}, nil + summaries := make([]NotebookSummary, 0, len(h.notebooks)) + + for _, nb := range h.notebooks { + summaries = append(summaries, NotebookSummary{ + ID: nb.ID, + Name: nb.Name, + Key: nb.Key, + }) + } + + text := formatNotebookListText(summaries) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{ + Entity: EntityNotebook, + Items: summaries, + Count: len(summaries), + }, nil +} + +// NotebookSummary represents a notebook in list output. +type NotebookSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` +} + +func formatNotebookListText(notebooks []NotebookSummary) 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() } func (h *Handler) queryHabit( @@ -176,6 +1010,46 @@ func (h *Handler) queryHabit( QueryOutput{Entity: input.Entity}, nil } - return shared.ErrorResult("habit query not yet implemented"), - QueryOutput{Entity: input.Entity}, nil + summaries := make([]HabitSummary, 0, len(h.habits)) + + for _, habit := range h.habits { + summaries = append(summaries, HabitSummary{ + ID: habit.ID, + Name: habit.Name, + Key: habit.Key, + }) + } + + text := formatHabitListText(summaries) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, QueryOutput{ + Entity: EntityHabit, + Items: summaries, + Count: len(summaries), + }, nil +} + +// HabitSummary represents a habit in list output. +type HabitSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` +} + +func formatHabitListText(habits []HabitSummary) 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/crud/update.go b/internal/mcp/tools/crud/update.go index df9f4e57eb2504fd932e72a2d82d52914fe8a7f0..32efc1d6949604eb07cba53f1b15c392495e33e4 100644 --- a/internal/mcp/tools/crud/update.go +++ b/internal/mcp/tools/crud/update.go @@ -7,7 +7,10 @@ package crud import ( "context" + "git.secluded.site/go-lunatask" + "git.secluded.site/lune/internal/dateutil" "git.secluded.site/lune/internal/mcp/shared" + "git.secluded.site/lune/internal/validate" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -52,7 +55,7 @@ Returns the updated entity's deep link.` // UpdateInput is the input schema for the consolidated update tool. type UpdateInput struct { - Entity string `json:"entity" jsonschema:"required,enum=task,enum=note,enum=person"` + Entity string `json:"entity" jsonschema:"required"` ID string `json:"id" jsonschema:"required"` // Common fields @@ -106,26 +109,280 @@ func (h *Handler) HandleUpdate( } } +// parsedTaskUpdateInput holds validated and parsed task update input fields. +type parsedTaskUpdateInput struct { + ID string + Name *string + AreaID *string + GoalID *string + Status *lunatask.TaskStatus + Note *string + Priority *lunatask.Priority + Estimate *int + Motivation *lunatask.Motivation + Important *bool + Urgent *bool + ScheduledOn *lunatask.Date +} + func (h *Handler) updateTask( - _ context.Context, + ctx context.Context, input UpdateInput, ) (*mcp.CallToolResult, UpdateOutput, error) { - return shared.ErrorResult("task update not yet implemented"), - UpdateOutput{Entity: input.Entity}, nil + parsed, errResult := h.parseTaskUpdateInput(input) + if errResult != nil { + return errResult, UpdateOutput{Entity: input.Entity}, nil + } + + builder := h.client.NewTaskUpdate(parsed.ID) + applyToTaskUpdateBuilder(builder, parsed) + + task, err := builder.Update(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Task updated: " + deepLink, + }}, + }, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil +} + +//nolint:cyclop,funlen +func (h *Handler) parseTaskUpdateInput(input UpdateInput) (*parsedTaskUpdateInput, *mcp.CallToolResult) { + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return nil, shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link") + } + + parsed := &parsedTaskUpdateInput{ + ID: id, + Name: input.Name, + Note: input.Note, + Estimate: input.Estimate, + Important: input.Important, + Urgent: input.Urgent, + } + + if input.AreaID != nil { + areaID, err := validate.AreaRef(h.cfg, *input.AreaID) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.AreaID = &areaID + } + + if input.GoalID != nil { + areaID := "" + if parsed.AreaID != nil { + areaID = *parsed.AreaID + } + + goalID, err := validate.GoalRef(h.cfg, areaID, *input.GoalID) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.GoalID = &goalID + } + + if input.Estimate != nil { + if err := shared.ValidateEstimate(*input.Estimate); err != nil { + return nil, shared.ErrorResult(err.Error()) + } + } + + if input.Status != nil { + status, err := lunatask.ParseTaskStatus(*input.Status) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.Status = &status + } + + if input.Priority != nil { + priority, err := lunatask.ParsePriority(*input.Priority) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.Priority = &priority + } + + if input.Motivation != nil { + motivation, err := lunatask.ParseMotivation(*input.Motivation) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.Motivation = &motivation + } + + if input.ScheduledOn != nil { + date, err := dateutil.Parse(*input.ScheduledOn) + if err != nil { + return nil, shared.ErrorResult(err.Error()) + } + + parsed.ScheduledOn = &date + } + + return parsed, nil +} + +//nolint:cyclop +func applyToTaskUpdateBuilder(builder *lunatask.TaskUpdateBuilder, parsed *parsedTaskUpdateInput) { + if parsed.Name != nil { + builder.Name(*parsed.Name) + } + + if parsed.AreaID != nil { + builder.InArea(*parsed.AreaID) + } + + if parsed.GoalID != nil { + builder.InGoal(*parsed.GoalID) + } + + if parsed.Status != nil { + builder.WithStatus(*parsed.Status) + } + + if parsed.Note != nil { + builder.WithNote(*parsed.Note) + } + + if parsed.Priority != nil { + builder.Priority(*parsed.Priority) + } + + if parsed.Estimate != nil { + builder.WithEstimate(*parsed.Estimate) + } + + if parsed.Motivation != nil { + builder.WithMotivation(*parsed.Motivation) + } + + if parsed.Important != nil { + if *parsed.Important { + builder.Important() + } else { + builder.NotImportant() + } + } + + if parsed.Urgent != nil { + if *parsed.Urgent { + builder.Urgent() + } else { + builder.NotUrgent() + } + } + + if parsed.ScheduledOn != nil { + builder.ScheduledOn(*parsed.ScheduledOn) + } } func (h *Handler) updateNote( - _ context.Context, + ctx context.Context, input UpdateInput, ) (*mcp.CallToolResult, UpdateOutput, error) { - return shared.ErrorResult("note update not yet implemented"), - UpdateOutput{Entity: input.Entity}, nil + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + UpdateOutput{Entity: input.Entity}, nil + } + + if input.NotebookID != nil { + if err := lunatask.ValidateUUID(*input.NotebookID); err != nil { + return shared.ErrorResult("invalid notebook_id: expected UUID"), + UpdateOutput{Entity: input.Entity}, nil + } + } + + builder := h.client.NewNoteUpdate(id) + + if input.Name != nil { + builder.WithName(*input.Name) + } + + if input.NotebookID != nil { + builder.InNotebook(*input.NotebookID) + } + + if input.Content != nil { + builder.WithContent(*input.Content) + } + + if input.Date != nil { + date, err := dateutil.Parse(*input.Date) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil + } + + builder.OnDate(date) + } + + note, err := builder.Update(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Note updated: " + deepLink, + }}, + }, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil } func (h *Handler) updatePerson( - _ context.Context, + ctx context.Context, input UpdateInput, ) (*mcp.CallToolResult, UpdateOutput, error) { - return shared.ErrorResult("person update not yet implemented"), - UpdateOutput{Entity: input.Entity}, nil + _, id, err := lunatask.ParseReference(input.ID) + if err != nil { + return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), + UpdateOutput{Entity: input.Entity}, nil + } + + builder := h.client.NewPersonUpdate(id) + + if input.FirstName != nil { + builder.FirstName(*input.FirstName) + } + + if input.LastName != nil { + builder.LastName(*input.LastName) + } + + if input.Relationship != nil { + rel, err := lunatask.ParseRelationshipStrength(*input.Relationship) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil + } + + builder.WithRelationshipStrength(rel) + } + + person, err := builder.Update(ctx) + if err != nil { + return shared.ErrorResult(err.Error()), UpdateOutput{Entity: input.Entity}, nil + } + + deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) + + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Person updated: " + deepLink, + }}, + }, UpdateOutput{Entity: input.Entity, DeepLink: deepLink}, nil } diff --git a/internal/mcp/tools/goal/list.go b/internal/mcp/tools/goal/list.go deleted file mode 100644 index 545f5d8a8da6d20663adec6d789e2d7745bb57de..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/goal/list.go +++ /dev/null @@ -1,128 +0,0 @@ -// 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 deleted file mode 100644 index 0c9c080a89a3746ba0cf2b009748ec47fc2de1cc..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/habit/list.go +++ /dev/null @@ -1,82 +0,0 @@ -// 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/habit/track.go b/internal/mcp/tools/habit/track.go index 91340de227e611b8c440de7062d2fd3ae41e2334..d1d64632970ba171879de0d618107e7e7a0ea493 100644 --- a/internal/mcp/tools/habit/track.go +++ b/internal/mcp/tools/habit/track.go @@ -21,7 +21,7 @@ const TrackToolName = "track_habit" const TrackToolDescription = `Records that a habit was performed on a specific date. Required: -- habit_id: Habit UUID (get from lunatask://habits resource) +- habit_id: Habit UUID, deep link, or config key Optional: - performed_on: Date performed (YYYY-MM-DD or natural language, default: today) @@ -61,8 +61,9 @@ func (h *Handler) HandleTrack( _ *mcp.CallToolRequest, input TrackInput, ) (*mcp.CallToolResult, TrackOutput, error) { - if err := lunatask.ValidateUUID(input.HabitID); err != nil { - return shared.ErrorResult("invalid habit_id: expected UUID"), TrackOutput{}, nil + habitID := h.resolveHabitRef(input.HabitID) + if habitID == "" { + return shared.ErrorResult("unknown habit: " + input.HabitID), TrackOutput{}, nil } dateStr := "" @@ -79,14 +80,32 @@ func (h *Handler) HandleTrack( PerformedOn: performedOn, } - _, err = h.client.TrackHabitActivity(ctx, input.HabitID, req) + _, err = h.client.TrackHabitActivity(ctx, habitID, req) if err != nil { return shared.ErrorResult(err.Error()), TrackOutput{}, nil } return nil, TrackOutput{ Success: true, - HabitID: input.HabitID, + HabitID: habitID, PerformedOn: performedOn.Format("2006-01-02"), }, nil } + +// resolveHabitRef resolves a habit reference to a UUID. +// Accepts config key, UUID, or deep link. +func (h *Handler) resolveHabitRef(input string) string { + // Try UUID or deep link first + if _, id, err := lunatask.ParseReference(input); err == nil { + return id + } + + // Try config key lookup + for _, habit := range h.habits { + if habit.Key == input { + return habit.ID + } + } + + return "" +} diff --git a/internal/mcp/tools/journal/create.go b/internal/mcp/tools/journal/create.go deleted file mode 100644 index 60c8ab359cca32a4078a17b154e5408fa70248c1..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/journal/create.go +++ /dev/null @@ -1,102 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package journal provides MCP tools for Lunatask journal operations. -package journal - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/dateutil" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// CreateToolName is the name of the create journal entry tool. -const CreateToolName = "add_journal_entry" - -// CreateToolDescription describes the create journal entry tool for LLMs. -const CreateToolDescription = `Creates a journal entry in Lunatask for daily reflection. - -Optional: -- content: Markdown content for the entry -- name: Entry title (defaults to weekday name if omitted) -- date: Entry date (YYYY-MM-DD or natural language, default: today) - -Entries are date-keyed. If no date is provided, uses today. -Content supports Markdown formatting. - -Common uses: -- End-of-day summaries -- Reflection on completed work -- Recording thoughts or learnings` - -// CreateInput is the input schema for creating a journal entry. -type CreateInput struct { - Content *string `json:"content,omitempty"` - Name *string `json:"name,omitempty"` - Date *string `json:"date,omitempty"` -} - -// CreateOutput is the output schema for creating a journal entry. -type CreateOutput struct { - ID string `json:"id"` - Date string `json:"date"` -} - -// Handler handles journal-related MCP tool requests. -type Handler struct { - client *lunatask.Client -} - -// NewHandler creates a new journal handler. -func NewHandler(accessToken string) *Handler { - return &Handler{ - client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), - } -} - -// HandleCreate creates a new journal entry. -func (h *Handler) HandleCreate( - ctx context.Context, - _ *mcp.CallToolRequest, - input CreateInput, -) (*mcp.CallToolResult, CreateOutput, error) { - dateStr := "" - if input.Date != nil { - dateStr = *input.Date - } - - date, err := dateutil.Parse(dateStr) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil - } - - builder := h.client.NewJournalEntry(date) - - if input.Content != nil { - builder.WithContent(*input.Content) - } - - if input.Name != nil { - builder.WithName(*input.Name) - } - - entry, err := builder.Create(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil - } - - formattedDate := date.Format("2006-01-02") - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Journal entry created for " + formattedDate, - }}, - }, CreateOutput{ - ID: entry.ID, - Date: formattedDate, - }, nil -} diff --git a/internal/mcp/tools/note/create.go b/internal/mcp/tools/note/create.go deleted file mode 100644 index 05e0a314f92a4a5c153a2c42f9109d5b2c7c9d9a..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/note/create.go +++ /dev/null @@ -1,113 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package note provides MCP tools for Lunatask note operations. -package note - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// CreateToolName is the name of the create note tool. -const CreateToolName = "create_note" - -// CreateToolDescription describes the create note tool for LLMs. -const CreateToolDescription = `Creates a new note in Lunatask. - -Optional: -- name: Note title -- notebook_id: Notebook UUID (get from lunatask://notebooks resource) -- content: Markdown content -- source: Origin identifier for integrations -- source_id: Source-specific ID (requires source) - -All fields are optional — can create an empty note. -Returns the deep link to the created note. - -Note: If a note with the same source/source_id already exists, -the API returns a duplicate warning instead of creating a new note.` - -// CreateInput is the input schema for creating a note. -type CreateInput struct { - Name *string `json:"name,omitempty"` - NotebookID *string `json:"notebook_id,omitempty"` - Content *string `json:"content,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` -} - -// CreateOutput is the output schema for creating a note. -type CreateOutput struct { - DeepLink string `json:"deep_link"` -} - -// Handler handles note-related MCP tool requests. -type Handler struct { - client *lunatask.Client - notebooks []shared.NotebookProvider -} - -// NewHandler creates a new note handler. -func NewHandler(accessToken string, notebooks []shared.NotebookProvider) *Handler { - return &Handler{ - client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), - notebooks: notebooks, - } -} - -// HandleCreate creates a new note. -func (h *Handler) HandleCreate( - ctx context.Context, - _ *mcp.CallToolRequest, - input CreateInput, -) (*mcp.CallToolResult, CreateOutput, error) { - if input.NotebookID != nil { - if err := lunatask.ValidateUUID(*input.NotebookID); err != nil { - return shared.ErrorResult("invalid notebook_id: expected UUID"), CreateOutput{}, nil - } - } - - builder := h.client.NewNote() - - if input.Name != nil { - builder.WithName(*input.Name) - } - - if input.NotebookID != nil { - builder.InNotebook(*input.NotebookID) - } - - if input.Content != nil { - builder.WithContent(*input.Content) - } - - if input.Source != nil && input.SourceID != nil { - builder.FromSource(*input.Source, *input.SourceID) - } - - note, err := builder.Create(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil - } - - if note == nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Note already exists (duplicate source)", - }}, - }, CreateOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Note created: " + deepLink, - }}, - }, CreateOutput{DeepLink: deepLink}, nil -} diff --git a/internal/mcp/tools/note/delete.go b/internal/mcp/tools/note/delete.go deleted file mode 100644 index 3cf3f2480c8bb9d2c12aab128c40ef8fee1dab1f..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/note/delete.go +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package note - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// DeleteToolName is the name of the delete note tool. -const DeleteToolName = "delete_note" - -// DeleteToolDescription describes the delete note tool for LLMs. -const DeleteToolDescription = `Deletes a note from Lunatask. - -Required: -- id: Note UUID or lunatask://note/... deep link - -This action is permanent and cannot be undone.` - -// DeleteInput is the input schema for deleting a note. -type DeleteInput struct { - ID string `json:"id" jsonschema:"required"` -} - -// DeleteOutput is the output schema for deleting a note. -type DeleteOutput struct { - Success bool `json:"success"` - DeepLink string `json:"deep_link"` -} - -// HandleDelete deletes a note. -func (h *Handler) HandleDelete( - ctx context.Context, - _ *mcp.CallToolRequest, - input DeleteInput, -) (*mcp.CallToolResult, DeleteOutput, error) { - id, err := parseReference(input.ID) - if err != nil { - return shared.ErrorResult(err.Error()), DeleteOutput{}, nil - } - - note, err := h.client.DeleteNote(ctx, id) - if err != nil { - return shared.ErrorResult(err.Error()), DeleteOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Note deleted: " + deepLink, - }}, - }, DeleteOutput{ - Success: true, - DeepLink: deepLink, - }, nil -} diff --git a/internal/mcp/tools/note/list.go b/internal/mcp/tools/note/list.go deleted file mode 100644 index 5f957e4e5af4d6a5a872e46f7cd4f65cf2f57b64..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/note/list.go +++ /dev/null @@ -1,185 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package note - -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 notes tool. -const ListToolName = "list_notes" - -// ListToolDescription describes the list notes tool for LLMs. -const ListToolDescription = `Lists notes from Lunatask. - -Optional: -- notebook_id: Filter by notebook UUID (from lunatask://notebooks) -- source: Filter by source identifier -- source_id: Filter by source-specific ID - -Returns note metadata (IDs, dates, pinned status). -Use lunatask://note/{id} resource for full note details. - -Note: Due to end-to-end encryption, note names and content -are not available in the list — only metadata is returned.` - -// ListInput is the input schema for listing notes. -type ListInput struct { - NotebookID *string `json:"notebook_id,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` -} - -// ListNoteItem represents a note in the list output. -type ListNoteItem 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"` -} - -// ListOutput is the output schema for listing notes. -type ListOutput struct { - Notes []ListNoteItem `json:"notes"` - Count int `json:"count"` -} - -// HandleList lists notes. -func (h *Handler) HandleList( - ctx context.Context, - _ *mcp.CallToolRequest, - input ListInput, -) (*mcp.CallToolResult, ListOutput, error) { - if input.NotebookID != nil { - if err := lunatask.ValidateUUID(*input.NotebookID); err != nil { - return shared.ErrorResult("invalid notebook_id: expected UUID"), ListOutput{}, nil - } - } - - opts := buildListOptions(input) - - notes, err := h.client.ListNotes(ctx, opts) - if err != nil { - return shared.ErrorResult(err.Error()), ListOutput{}, nil - } - - if input.NotebookID != nil { - notes = filterByNotebook(notes, *input.NotebookID) - } - - items := make([]ListNoteItem, 0, len(notes)) - - for _, note := range notes { - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) - - item := ListNoteItem{ - DeepLink: deepLink, - NotebookID: note.NotebookID, - Pinned: note.Pinned, - CreatedAt: note.CreatedAt.Format("2006-01-02"), - } - - if note.DateOn != nil { - dateStr := note.DateOn.Format("2006-01-02") - item.DateOn = &dateStr - } - - items = append(items, item) - } - - output := ListOutput{ - Notes: items, - Count: len(items), - } - - text := formatListText(items, h.notebooks) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - }, output, nil -} - -func buildListOptions(input ListInput) *lunatask.ListNotesOptions { - if input.Source == nil && input.SourceID == nil { - return nil - } - - opts := &lunatask.ListNotesOptions{} - - if input.Source != nil { - opts.Source = input.Source - } - - if input.SourceID != nil { - opts.SourceID = input.SourceID - } - - return opts -} - -func filterByNotebook(notes []lunatask.Note, notebookID string) []lunatask.Note { - filtered := make([]lunatask.Note, 0, len(notes)) - - for _, note := range notes { - if note.NotebookID != nil && *note.NotebookID == notebookID { - filtered = append(filtered, note) - } - } - - return filtered -} - -func formatListText(items []ListNoteItem, notebooks []shared.NotebookProvider) string { - if len(items) == 0 { - return "No notes found" - } - - var text strings.Builder - - text.WriteString(fmt.Sprintf("Found %d note(s):\n", len(items))) - - for _, item := range items { - text.WriteString("- ") - text.WriteString(item.DeepLink) - - var details []string - - if item.NotebookID != nil { - nbName := *item.NotebookID - for _, nb := range notebooks { - if nb.ID == *item.NotebookID { - nbName = nb.Key - - break - } - } - - details = append(details, "notebook: "+nbName) - } - - if item.Pinned { - details = append(details, "pinned") - } - - if len(details) > 0 { - text.WriteString(" (") - text.WriteString(strings.Join(details, ", ")) - text.WriteString(")") - } - - text.WriteString("\n") - } - - text.WriteString("\nUse lunatask://note/{id} resource for full details.") - - return text.String() -} diff --git a/internal/mcp/tools/note/show.go b/internal/mcp/tools/note/show.go deleted file mode 100644 index 68defc98919e79e9fcd9401e5832a02e98608260..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/note/show.go +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package note - -import ( - "context" - "fmt" - "strings" - "time" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// ShowToolName is the name of the show note tool. -const ShowToolName = "show_note" - -// ShowToolDescription describes the show note tool for LLMs. -const ShowToolDescription = `Shows metadata for a specific note from Lunatask. - -Required: -- id: Note UUID or lunatask:// deep link - -Note: Due to end-to-end encryption, note title and content are not available. -Only metadata (notebook, date, pinned status, sources) is returned.` - -// ShowInput is the input schema for showing a note. -type ShowInput struct { - ID string `json:"id" jsonschema:"required"` -} - -// ShowSource represents a source reference in the output. -type ShowSource struct { - Source string `json:"source"` - SourceID string `json:"source_id"` -} - -// ShowOutput is the output schema for showing a note. -type ShowOutput struct { - DeepLink string `json:"deep_link"` - NotebookID *string `json:"notebook_id,omitempty"` - DateOn *string `json:"date_on,omitempty"` - Pinned bool `json:"pinned"` - Sources []ShowSource `json:"sources,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -// HandleShow shows a note's details. -func (h *Handler) HandleShow( - ctx context.Context, - _ *mcp.CallToolRequest, - input ShowInput, -) (*mcp.CallToolResult, ShowOutput, error) { - _, id, err := lunatask.ParseReference(input.ID) - if err != nil { - return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), ShowOutput{}, nil - } - - note, err := h.client.GetNote(ctx, id) - if err != nil { - return shared.ErrorResult(err.Error()), ShowOutput{}, nil - } - - output := buildShowOutput(note) - text := formatNoteShowText(output, h.notebooks) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - }, output, nil -} - -func buildShowOutput(note *lunatask.Note) ShowOutput { - output := ShowOutput{ - NotebookID: note.NotebookID, - Pinned: note.Pinned, - CreatedAt: note.CreatedAt.Format(time.RFC3339), - UpdatedAt: note.UpdatedAt.Format(time.RFC3339), - } - - output.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) - - if note.DateOn != nil { - s := note.DateOn.Format("2006-01-02") - output.DateOn = &s - } - - if len(note.Sources) > 0 { - output.Sources = make([]ShowSource, 0, len(note.Sources)) - for _, src := range note.Sources { - output.Sources = append(output.Sources, ShowSource{ - Source: src.Source, - SourceID: src.SourceID, - }) - } - } - - return output -} - -func formatNoteShowText(output ShowOutput, notebooks []shared.NotebookProvider) string { - var builder strings.Builder - - builder.WriteString(fmt.Sprintf("Note: %s\n", output.DeepLink)) - - if output.NotebookID != nil { - nbName := *output.NotebookID - for _, nb := range notebooks { - if nb.ID == *output.NotebookID { - nbName = nb.Key - - break - } - } - - builder.WriteString(fmt.Sprintf("Notebook: %s\n", nbName)) - } - - if output.DateOn != nil { - builder.WriteString(fmt.Sprintf("Date: %s\n", *output.DateOn)) - } - - if output.Pinned { - builder.WriteString("Pinned: yes\n") - } - - if len(output.Sources) > 0 { - builder.WriteString("Sources:\n") - - for _, src := range output.Sources { - builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID)) - } - } - - builder.WriteString(fmt.Sprintf("Created: %s\n", output.CreatedAt)) - builder.WriteString("Updated: " + output.UpdatedAt) - - return builder.String() -} diff --git a/internal/mcp/tools/note/update.go b/internal/mcp/tools/note/update.go deleted file mode 100644 index c36d703d813ab9b96cfa7f8700163d052fd2f55e..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/note/update.go +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package note - -import ( - "context" - "fmt" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/dateutil" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// UpdateToolName is the name of the update note tool. -const UpdateToolName = "update_note" - -// UpdateToolDescription describes the update note tool for LLMs. -const UpdateToolDescription = `Updates an existing note in Lunatask. - -Required: -- id: Note UUID or lunatask://note/... deep link - -Optional: -- name: New note title -- notebook_id: Move to notebook (UUID from lunatask://notebooks) -- content: Replace content (Markdown) -- date: Note date (YYYY-MM-DD or natural language) - -Only provided fields are modified; other fields remain unchanged.` - -// UpdateInput is the input schema for updating a note. -type UpdateInput struct { - ID string `json:"id" jsonschema:"required"` - Name *string `json:"name,omitempty"` - NotebookID *string `json:"notebook_id,omitempty"` - Content *string `json:"content,omitempty"` - Date *string `json:"date,omitempty"` -} - -// UpdateOutput is the output schema for updating a note. -type UpdateOutput struct { - DeepLink string `json:"deep_link"` -} - -// HandleUpdate updates an existing note. -func (h *Handler) HandleUpdate( - ctx context.Context, - _ *mcp.CallToolRequest, - input UpdateInput, -) (*mcp.CallToolResult, UpdateOutput, error) { - id, err := parseReference(input.ID) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } - - if input.NotebookID != nil { - if err := lunatask.ValidateUUID(*input.NotebookID); err != nil { - return shared.ErrorResult("invalid notebook_id: expected UUID"), UpdateOutput{}, nil - } - } - - builder := h.client.NewNoteUpdate(id) - - if input.Name != nil { - builder.WithName(*input.Name) - } - - if input.NotebookID != nil { - builder.InNotebook(*input.NotebookID) - } - - if input.Content != nil { - builder.WithContent(*input.Content) - } - - if input.Date != nil { - date, err := dateutil.Parse(*input.Date) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } - - builder.OnDate(date) - } - - note, err := builder.Update(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Note updated: " + deepLink, - }}, - }, UpdateOutput{DeepLink: deepLink}, nil -} - -// parseReference extracts UUID from either a raw UUID or a lunatask:// deep link. -func parseReference(ref string) (string, error) { - _, id, err := lunatask.ParseReference(ref) - if err != nil { - return "", fmt.Errorf("invalid ID: %w", err) - } - - return id, nil -} diff --git a/internal/mcp/tools/notebook/list.go b/internal/mcp/tools/notebook/list.go deleted file mode 100644 index 071cc03f789679ab7174663e3840f32adfab9db6..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/notebook/list.go +++ /dev/null @@ -1,94 +0,0 @@ -// 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/create.go b/internal/mcp/tools/person/create.go deleted file mode 100644 index 49806be8f8cc76b2265e6789bb2ae3bac105e2cc..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/person/create.go +++ /dev/null @@ -1,99 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package person provides MCP tools for Lunatask person/relationship operations. -package person - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// CreateToolName is the name of the create person tool. -const CreateToolName = "create_person" - -// CreateToolDescription describes the create person tool for LLMs. -const CreateToolDescription = `Creates a new person in Lunatask's relationship tracker. - -Required: -- first_name: First name - -Optional: -- last_name: Last name -- relationship: Relationship strength (family, intimate-friends, close-friends, - casual-friends, acquaintances, business-contacts, almost-strangers) - Default: casual-friends -- source: Origin identifier for integrations -- source_id: Source-specific ID (requires source) - -Returns the deep link to the created person.` - -// CreateInput is the input schema for creating a person. -type CreateInput struct { - FirstName string `json:"first_name" jsonschema:"required"` - LastName *string `json:"last_name,omitempty"` - Relationship *string `json:"relationship,omitempty"` - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` -} - -// CreateOutput is the output schema for creating a person. -type CreateOutput struct { - DeepLink string `json:"deep_link"` -} - -// Handler handles person-related MCP tool requests. -type Handler struct { - client *lunatask.Client -} - -// NewHandler creates a new person handler. -func NewHandler(accessToken string) *Handler { - return &Handler{ - client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), - } -} - -// HandleCreate creates a new person. -func (h *Handler) HandleCreate( - ctx context.Context, - _ *mcp.CallToolRequest, - input CreateInput, -) (*mcp.CallToolResult, CreateOutput, error) { - lastName := "" - if input.LastName != nil { - lastName = *input.LastName - } - - builder := h.client.NewPerson(input.FirstName, lastName) - - if input.Relationship != nil { - rel, err := lunatask.ParseRelationshipStrength(*input.Relationship) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil - } - - builder.WithRelationshipStrength(rel) - } - - if input.Source != nil && input.SourceID != nil { - builder.FromSource(*input.Source, *input.SourceID) - } - - person, err := builder.Create(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Person created: " + deepLink, - }}, - }, CreateOutput{DeepLink: deepLink}, nil -} diff --git a/internal/mcp/tools/person/delete.go b/internal/mcp/tools/person/delete.go deleted file mode 100644 index 83ca0bdf428e17bf70bcf4719193d8aa0b8253c8..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/person/delete.go +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package person - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// DeleteToolName is the name of the delete person tool. -const DeleteToolName = "delete_person" - -// DeleteToolDescription describes the delete person tool for LLMs. -const DeleteToolDescription = `Deletes a person from Lunatask. - -Required: -- id: Person UUID or lunatask://person/... deep link - -This action is permanent and cannot be undone.` - -// DeleteInput is the input schema for deleting a person. -type DeleteInput struct { - ID string `json:"id" jsonschema:"required"` -} - -// DeleteOutput is the output schema for deleting a person. -type DeleteOutput struct { - Success bool `json:"success"` - DeepLink string `json:"deep_link"` -} - -// HandleDelete deletes a person. -func (h *Handler) HandleDelete( - ctx context.Context, - _ *mcp.CallToolRequest, - input DeleteInput, -) (*mcp.CallToolResult, DeleteOutput, error) { - id, err := parseReference(input.ID) - if err != nil { - return shared.ErrorResult(err.Error()), DeleteOutput{}, nil - } - - person, err := h.client.DeletePerson(ctx, id) - if err != nil { - return shared.ErrorResult(err.Error()), DeleteOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Person deleted: " + deepLink, - }}, - }, DeleteOutput{ - Success: true, - DeepLink: deepLink, - }, nil -} diff --git a/internal/mcp/tools/person/list.go b/internal/mcp/tools/person/list.go deleted file mode 100644 index 0f7e2f7d6d7aa26d78b01de5bea43a35cb4fae48..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/person/list.go +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package person - -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 people tool. -const ListToolName = "list_people" - -// ListToolDescription describes the list people tool for LLMs. -const ListToolDescription = `Lists people from Lunatask's relationship tracker. - -Optional: -- source: Filter by source identifier -- source_id: Filter by source-specific ID - -Returns person metadata (IDs, relationship strength, created dates). -Use lunatask://person/{id} resource for full person details. - -Note: Due to end-to-end encryption, names are not available -in the list — only metadata is returned.` - -// ListInput is the input schema for listing people. -type ListInput struct { - Source *string `json:"source,omitempty"` - SourceID *string `json:"source_id,omitempty"` -} - -// ListPersonItem represents a person in the list output. -type ListPersonItem struct { - DeepLink string `json:"deep_link"` - RelationshipStrength *string `json:"relationship_strength,omitempty"` - CreatedAt string `json:"created_at"` -} - -// ListOutput is the output schema for listing people. -type ListOutput struct { - People []ListPersonItem `json:"people"` - Count int `json:"count"` -} - -// HandleList lists people. -func (h *Handler) HandleList( - ctx context.Context, - _ *mcp.CallToolRequest, - input ListInput, -) (*mcp.CallToolResult, ListOutput, error) { - opts := buildListOptions(input) - - people, err := h.client.ListPeople(ctx, opts) - if err != nil { - return shared.ErrorResult(err.Error()), ListOutput{}, nil - } - - items := make([]ListPersonItem, 0, len(people)) - - for _, person := range people { - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) - - item := ListPersonItem{ - DeepLink: deepLink, - CreatedAt: person.CreatedAt.Format("2006-01-02"), - } - - if person.RelationshipStrength != nil { - rel := string(*person.RelationshipStrength) - item.RelationshipStrength = &rel - } - - items = append(items, item) - } - - output := ListOutput{ - People: items, - Count: len(items), - } - - text := formatListText(items) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - }, output, nil -} - -func buildListOptions(input ListInput) *lunatask.ListPeopleOptions { - if input.Source == nil && input.SourceID == nil { - return nil - } - - opts := &lunatask.ListPeopleOptions{} - - if input.Source != nil { - opts.Source = input.Source - } - - if input.SourceID != nil { - opts.SourceID = input.SourceID - } - - return opts -} - -func formatListText(items []ListPersonItem) string { - if len(items) == 0 { - return "No people found" - } - - var text strings.Builder - - text.WriteString(fmt.Sprintf("Found %d person(s):\n", len(items))) - - for _, item := range items { - text.WriteString("- ") - text.WriteString(item.DeepLink) - - if item.RelationshipStrength != nil { - text.WriteString(" (") - text.WriteString(*item.RelationshipStrength) - text.WriteString(")") - } - - text.WriteString("\n") - } - - text.WriteString("\nUse lunatask://person/{id} resource for full details.") - - return text.String() -} diff --git a/internal/mcp/tools/person/show.go b/internal/mcp/tools/person/show.go deleted file mode 100644 index c95b1768613bee1a2d3c9feded24c639393eb338..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/person/show.go +++ /dev/null @@ -1,121 +0,0 @@ -// 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() -} diff --git a/internal/mcp/tools/person/update.go b/internal/mcp/tools/person/update.go deleted file mode 100644 index 82dfe71a261d74a9bef6fe802c86c5154a0dc681..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/person/update.go +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package person - -import ( - "context" - "fmt" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// UpdateToolName is the name of the update person tool. -const UpdateToolName = "update_person" - -// UpdateToolDescription describes the update person tool for LLMs. -const UpdateToolDescription = `Updates an existing person in Lunatask. - -Required: -- id: Person UUID or lunatask://person/... deep link - -Optional: -- first_name: New first name -- last_name: New last name -- relationship: New relationship strength (family, intimate-friends, - close-friends, casual-friends, acquaintances, business-contacts, almost-strangers) - -Only provided fields are modified; other fields remain unchanged.` - -// UpdateInput is the input schema for updating a person. -type UpdateInput struct { - ID string `json:"id" jsonschema:"required"` - FirstName *string `json:"first_name,omitempty"` - LastName *string `json:"last_name,omitempty"` - Relationship *string `json:"relationship,omitempty"` -} - -// UpdateOutput is the output schema for updating a person. -type UpdateOutput struct { - DeepLink string `json:"deep_link"` -} - -// HandleUpdate updates an existing person. -func (h *Handler) HandleUpdate( - ctx context.Context, - _ *mcp.CallToolRequest, - input UpdateInput, -) (*mcp.CallToolResult, UpdateOutput, error) { - id, err := parseReference(input.ID) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } - - builder := h.client.NewPersonUpdate(id) - - if input.FirstName != nil { - builder.FirstName(*input.FirstName) - } - - if input.LastName != nil { - builder.LastName(*input.LastName) - } - - if input.Relationship != nil { - rel, err := lunatask.ParseRelationshipStrength(*input.Relationship) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } - - builder.WithRelationshipStrength(rel) - } - - person, err := builder.Update(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Person updated: " + deepLink, - }}, - }, UpdateOutput{DeepLink: deepLink}, nil -} - -// parseReference extracts UUID from either a raw UUID or a lunatask:// deep link. -func parseReference(ref string) (string, error) { - _, id, err := lunatask.ParseReference(ref) - if err != nil { - return "", fmt.Errorf("invalid ID: %w", err) - } - - return id, nil -} diff --git a/internal/mcp/tools/task/create.go b/internal/mcp/tools/task/create.go deleted file mode 100644 index e0e517d325ad17746ad6b9347b67fa0a12291133..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/task/create.go +++ /dev/null @@ -1,239 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package task provides MCP tools for Lunatask task operations. -package task - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/config" - "git.secluded.site/lune/internal/dateutil" - "git.secluded.site/lune/internal/mcp/shared" - "git.secluded.site/lune/internal/validate" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// CreateToolName is the name of the create task tool. -const CreateToolName = "create_task" - -// CreateToolDescription describes the create task tool for LLMs. -const CreateToolDescription = `Creates a new task in Lunatask. - -Required: -- name: Task title -- area_id: Area UUID, lunatask:// deep link, or config key - -Optional: -- goal_id: Goal UUID, lunatask:// deep link, or config key (requires area_id) -- status: later, next, started, waiting (default: later) -- note: Markdown note/description for the task -- priority: lowest, low, normal, high, highest -- estimate: Time estimate in minutes (0-720) -- motivation: must, should, want -- important: true/false for Eisenhower matrix -- urgent: true/false for Eisenhower matrix -- scheduled_on: Date to schedule (YYYY-MM-DD or natural language) - -Returns the created task's ID and deep link.` - -// CreateInput is the input schema for creating a task. -type CreateInput struct { - Name string `json:"name" jsonschema:"required"` - AreaID string `json:"area_id" jsonschema:"required"` - GoalID *string `json:"goal_id,omitempty"` - Status *string `json:"status,omitempty"` - Note *string `json:"note,omitempty"` - Priority *string `json:"priority,omitempty"` - Estimate *int `json:"estimate,omitempty"` - Motivation *string `json:"motivation,omitempty"` - Important *bool `json:"important,omitempty"` - Urgent *bool `json:"urgent,omitempty"` - ScheduledOn *string `json:"scheduled_on,omitempty"` -} - -// CreateOutput is the output schema for creating a task. -type CreateOutput struct { - DeepLink string `json:"deep_link"` -} - -// parsedCreateInput holds validated and parsed create input fields. -type parsedCreateInput struct { - Name string - AreaID string - GoalID *string - Status *lunatask.TaskStatus - Note *string - Priority *lunatask.Priority - Estimate *int - Motivation *lunatask.Motivation - Important *bool - Urgent *bool - ScheduledOn *lunatask.Date -} - -// Handler handles task-related MCP tool requests. -type Handler struct { - client *lunatask.Client - cfg *config.Config - areas []shared.AreaProvider -} - -// NewHandler creates a new task handler. -func NewHandler(accessToken string, cfg *config.Config, areas []shared.AreaProvider) *Handler { - return &Handler{ - client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), - cfg: cfg, - areas: areas, - } -} - -// HandleCreate creates a new task. -func (h *Handler) HandleCreate( - ctx context.Context, - _ *mcp.CallToolRequest, - input CreateInput, -) (*mcp.CallToolResult, CreateOutput, error) { - parsed, errResult := parseCreateInput(h.cfg, input) - if errResult != nil { - return errResult, CreateOutput{}, nil - } - - builder := h.client.NewTask(parsed.Name) - applyToTaskBuilder(builder, parsed) - - task, err := builder.Create(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), CreateOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Task created: " + deepLink, - }}, - }, CreateOutput{DeepLink: deepLink}, nil -} - -//nolint:cyclop,funlen -func parseCreateInput(cfg *config.Config, input CreateInput) (*parsedCreateInput, *mcp.CallToolResult) { - parsed := &parsedCreateInput{ - Name: input.Name, - Note: input.Note, - Estimate: input.Estimate, - Important: input.Important, - Urgent: input.Urgent, - } - - areaID, err := validate.AreaRef(cfg, input.AreaID) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.AreaID = areaID - - if input.GoalID != nil { - goalID, err := validate.GoalRef(cfg, parsed.AreaID, *input.GoalID) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.GoalID = &goalID - } - - if input.Estimate != nil { - if err := shared.ValidateEstimate(*input.Estimate); err != nil { - return nil, shared.ErrorResult(err.Error()) - } - } - - if input.Status != nil { - status, err := lunatask.ParseTaskStatus(*input.Status) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.Status = &status - } - - if input.Priority != nil { - priority, err := lunatask.ParsePriority(*input.Priority) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.Priority = &priority - } - - if input.Motivation != nil { - motivation, err := lunatask.ParseMotivation(*input.Motivation) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.Motivation = &motivation - } - - if input.ScheduledOn != nil { - date, err := dateutil.Parse(*input.ScheduledOn) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.ScheduledOn = &date - } - - return parsed, nil -} - -//nolint:cyclop -func applyToTaskBuilder(builder *lunatask.TaskBuilder, parsed *parsedCreateInput) { - builder.InArea(parsed.AreaID) - - if parsed.GoalID != nil { - builder.InGoal(*parsed.GoalID) - } - - if parsed.Status != nil { - builder.WithStatus(*parsed.Status) - } - - if parsed.Note != nil { - builder.WithNote(*parsed.Note) - } - - if parsed.Priority != nil { - builder.Priority(*parsed.Priority) - } - - if parsed.Estimate != nil { - builder.WithEstimate(*parsed.Estimate) - } - - if parsed.Motivation != nil { - builder.WithMotivation(*parsed.Motivation) - } - - if parsed.Important != nil { - if *parsed.Important { - builder.Important() - } else { - builder.NotImportant() - } - } - - if parsed.Urgent != nil { - if *parsed.Urgent { - builder.Urgent() - } else { - builder.NotUrgent() - } - } - - if parsed.ScheduledOn != nil { - builder.ScheduledOn(*parsed.ScheduledOn) - } -} diff --git a/internal/mcp/tools/task/delete.go b/internal/mcp/tools/task/delete.go deleted file mode 100644 index 2a3ce62fd3b290c5d1fc7d518eb1ff3b49301405..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/task/delete.go +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package task - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/mcp/shared" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// DeleteToolName is the name of the delete task tool. -const DeleteToolName = "delete_task" - -// DeleteToolDescription describes the delete task tool for LLMs. -const DeleteToolDescription = `Deletes a task from Lunatask. - -Required: -- id: Task UUID or lunatask:// deep link - -This action is permanent and cannot be undone.` - -// DeleteInput is the input schema for deleting a task. -type DeleteInput struct { - ID string `json:"id" jsonschema:"required"` -} - -// DeleteOutput is the output schema for deleting a task. -type DeleteOutput struct { - Success bool `json:"success"` - DeepLink string `json:"deep_link"` -} - -// HandleDelete deletes a task. -func (h *Handler) HandleDelete( - ctx context.Context, - _ *mcp.CallToolRequest, - input DeleteInput, -) (*mcp.CallToolResult, DeleteOutput, error) { - _, id, err := lunatask.ParseReference(input.ID) - if err != nil { - return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), DeleteOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, id) - - if _, err := h.client.DeleteTask(ctx, id); err != nil { - return shared.ErrorResult(err.Error()), DeleteOutput{}, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Task deleted: " + deepLink, - }}, - }, DeleteOutput{Success: true, DeepLink: deepLink}, nil -} diff --git a/internal/mcp/tools/task/list.go b/internal/mcp/tools/task/list.go deleted file mode 100644 index 5066e0d5657fd98e4539c43471c418b40f17dfca..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/task/list.go +++ /dev/null @@ -1,158 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package task - -import ( - "context" - "fmt" - "strings" - "time" - - "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 tasks tool. -const ListToolName = "list_tasks" - -// ListToolDescription describes the list tasks tool for LLMs. -const ListToolDescription = `Lists tasks from Lunatask. - -Optional filters: -- area_id: Filter by area UUID -- status: Filter by status (later, next, started, waiting, completed) -- include_completed: Include completed tasks (default: false, only shows today's) - -Note: Due to end-to-end encryption, task names and notes are not available. -Only metadata (ID, status, dates, priority, etc.) is returned. - -Returns a list of tasks with their metadata and deep links.` - -// ListInput is the input schema for listing tasks. -type ListInput struct { - AreaID *string `json:"area_id,omitempty"` - Status *string `json:"status,omitempty"` - IncludeCompleted *bool `json:"include_completed,omitempty"` -} - -// ListOutput is the output schema for listing tasks. -type ListOutput struct { - Tasks []Summary `json:"tasks"` - Count int `json:"count"` -} - -// Summary represents a task in list output. -type Summary struct { - DeepLink string `json:"deep_link"` - Status *string `json:"status,omitempty"` - Priority *int `json:"priority,omitempty"` - ScheduledOn *string `json:"scheduled_on,omitempty"` - CreatedAt string `json:"created_at"` - AreaID *string `json:"area_id,omitempty"` - GoalID *string `json:"goal_id,omitempty"` -} - -// HandleList lists tasks. -func (h *Handler) HandleList( - ctx context.Context, - _ *mcp.CallToolRequest, - input ListInput, -) (*mcp.CallToolResult, ListOutput, error) { - if input.AreaID != nil { - if err := lunatask.ValidateUUID(*input.AreaID); err != nil { - return shared.ErrorResult("invalid area_id: expected UUID"), ListOutput{}, nil - } - } - - if input.Status != nil { - if _, err := lunatask.ParseTaskStatus(*input.Status); err != nil { - return shared.ErrorResult("invalid status: must be later, next, started, waiting, or completed"), ListOutput{}, nil - } - } - - tasks, err := h.client.ListTasks(ctx, nil) - if err != nil { - return shared.ErrorResult(err.Error()), ListOutput{}, nil - } - - opts := &lunatask.TaskFilterOptions{ - AreaID: input.AreaID, - IncludeCompleted: input.IncludeCompleted != nil && *input.IncludeCompleted, - Today: time.Now(), - } - - if input.Status != nil { - s := lunatask.TaskStatus(*input.Status) - opts.Status = &s - } - - filtered := lunatask.FilterTasks(tasks, opts) - summaries := buildSummaries(filtered) - text := formatListText(summaries) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - }, ListOutput{ - Tasks: summaries, - Count: len(summaries), - }, nil -} - -func buildSummaries(tasks []lunatask.Task) []Summary { - summaries := make([]Summary, 0, len(tasks)) - - for _, task := range tasks { - summary := Summary{ - CreatedAt: task.CreatedAt.Format(time.RFC3339), - AreaID: task.AreaID, - GoalID: task.GoalID, - } - - summary.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) - - if task.Status != nil { - s := string(*task.Status) - summary.Status = &s - } - - if task.Priority != nil { - p := int(*task.Priority) - summary.Priority = &p - } - - if task.ScheduledOn != nil { - s := task.ScheduledOn.Format("2006-01-02") - summary.ScheduledOn = &s - } - - summaries = append(summaries, summary) - } - - return summaries -} - -func formatListText(summaries []Summary) string { - if len(summaries) == 0 { - return "No tasks found." - } - - var builder strings.Builder - - builder.WriteString(fmt.Sprintf("Found %d task(s):\n", len(summaries))) - - for _, summary := range summaries { - status := "unknown" - if summary.Status != nil { - status = *summary.Status - } - - builder.WriteString(fmt.Sprintf("- %s (%s)\n", summary.DeepLink, status)) - } - - builder.WriteString("\nUse show_task for full details.") - - return builder.String() -} diff --git a/internal/mcp/tools/task/show.go b/internal/mcp/tools/task/show.go deleted file mode 100644 index 12d4195b5a2bbe7014fb91b8859be1f42eff03db..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/task/show.go +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package task - -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 task tool. -const ShowToolName = "show_task" - -// ShowToolDescription describes the show task tool for LLMs. -const ShowToolDescription = `Shows details of a specific task from Lunatask. - -Required: -- id: Task UUID or lunatask:// deep link - -Note: Due to end-to-end encryption, task name and note content are not available. -Only metadata (ID, status, dates, priority, etc.) is returned.` - -// ShowInput is the input schema for showing a task. -type ShowInput struct { - ID string `json:"id" jsonschema:"required"` -} - -// ShowOutput is the output schema for showing a task. -type ShowOutput struct { - DeepLink string `json:"deep_link"` - Status *string `json:"status,omitempty"` - Priority *int `json:"priority,omitempty"` - Estimate *int `json:"estimate,omitempty"` - ScheduledOn *string `json:"scheduled_on,omitempty"` - CompletedAt *string `json:"completed_at,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - AreaID *string `json:"area_id,omitempty"` - GoalID *string `json:"goal_id,omitempty"` - Important *bool `json:"important,omitempty"` - Urgent *bool `json:"urgent,omitempty"` -} - -// HandleShow shows a task'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 - } - - task, err := h.client.GetTask(ctx, id) - if err != nil { - return shared.ErrorResult(err.Error()), ShowOutput{}, nil - } - - output := ShowOutput{ - CreatedAt: task.CreatedAt.Format(time.RFC3339), - UpdatedAt: task.UpdatedAt.Format(time.RFC3339), - AreaID: task.AreaID, - GoalID: task.GoalID, - } - - output.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) - - if task.Status != nil { - s := string(*task.Status) - output.Status = &s - } - - if task.Priority != nil { - p := int(*task.Priority) - output.Priority = &p - } - - if task.Estimate != nil { - output.Estimate = task.Estimate - } - - if task.ScheduledOn != nil { - s := task.ScheduledOn.Format("2006-01-02") - output.ScheduledOn = &s - } - - if task.CompletedAt != nil { - s := task.CompletedAt.Format(time.RFC3339) - output.CompletedAt = &s - } - - if task.Eisenhower != nil { - important := task.Eisenhower.IsImportant() - urgent := task.Eisenhower.IsUrgent() - output.Important = &important - output.Urgent = &urgent - } - - text := formatShowText(output) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - }, output, nil -} - -func formatShowText(output ShowOutput) string { - var builder strings.Builder - - builder.WriteString(fmt.Sprintf("Task: %s\n", output.DeepLink)) - writeOptionalField(&builder, "Status", output.Status) - writeOptionalIntField(&builder, "Priority", output.Priority) - writeOptionalField(&builder, "Scheduled", output.ScheduledOn) - writeOptionalMinutesField(&builder, "Estimate", output.Estimate) - writeEisenhowerField(&builder, output.Important, output.Urgent) - builder.WriteString(fmt.Sprintf("Created: %s\n", output.CreatedAt)) - builder.WriteString("Updated: " + output.UpdatedAt) - writeOptionalField(&builder, "\nCompleted", output.CompletedAt) - - return builder.String() -} - -func writeOptionalField(builder *strings.Builder, label string, value *string) { - if value != nil { - fmt.Fprintf(builder, "%s: %s\n", label, *value) - } -} - -func writeOptionalIntField(builder *strings.Builder, label string, value *int) { - if value != nil { - fmt.Fprintf(builder, "%s: %d\n", label, *value) - } -} - -func writeOptionalMinutesField(builder *strings.Builder, label string, value *int) { - if value != nil { - fmt.Fprintf(builder, "%s: %d min\n", label, *value) - } -} - -func writeEisenhowerField(builder *strings.Builder, important, urgent *bool) { - var parts []string - - if important != nil && *important { - parts = append(parts, "important") - } - - if urgent != nil && *urgent { - parts = append(parts, "urgent") - } - - if len(parts) > 0 { - fmt.Fprintf(builder, "Eisenhower: %s\n", strings.Join(parts, ", ")) - } -} diff --git a/internal/mcp/tools/task/update.go b/internal/mcp/tools/task/update.go deleted file mode 100644 index 3e918648089ca2b200b495ed774c143487c65c01..0000000000000000000000000000000000000000 --- a/internal/mcp/tools/task/update.go +++ /dev/null @@ -1,244 +0,0 @@ -// SPDX-FileCopyrightText: Amolith -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package task - -import ( - "context" - - "git.secluded.site/go-lunatask" - "git.secluded.site/lune/internal/config" - "git.secluded.site/lune/internal/dateutil" - "git.secluded.site/lune/internal/mcp/shared" - "git.secluded.site/lune/internal/validate" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// UpdateToolName is the name of the update task tool. -const UpdateToolName = "update_task" - -// UpdateToolDescription describes the update task tool for LLMs. -const UpdateToolDescription = `Updates an existing task in Lunatask. - -Required: -- id: Task UUID or lunatask:// deep link - -Optional (only specified fields are updated): -- name: New task title -- area_id: Move to area (UUID, lunatask:// deep link, or config key) -- goal_id: Move to goal (UUID, lunatask:// deep link, or config key; requires area_id) -- status: later, next, started, waiting, completed -- note: New markdown note (replaces existing) -- priority: lowest, low, normal, high, highest -- estimate: Time estimate in minutes (0-720) -- motivation: must, should, want -- important: true/false for Eisenhower matrix -- urgent: true/false for Eisenhower matrix -- scheduled_on: Date to schedule (YYYY-MM-DD) - -Returns the updated task's ID and deep link.` - -// UpdateInput is the input schema for updating a task. -type UpdateInput struct { - ID string `json:"id" jsonschema:"required"` - Name *string `json:"name,omitempty"` - AreaID *string `json:"area_id,omitempty"` - GoalID *string `json:"goal_id,omitempty"` - Status *string `json:"status,omitempty"` - Note *string `json:"note,omitempty"` - Priority *string `json:"priority,omitempty"` - Estimate *int `json:"estimate,omitempty"` - Motivation *string `json:"motivation,omitempty"` - Important *bool `json:"important,omitempty"` - Urgent *bool `json:"urgent,omitempty"` - ScheduledOn *string `json:"scheduled_on,omitempty"` -} - -// UpdateOutput is the output schema for updating a task. -type UpdateOutput struct { - DeepLink string `json:"deep_link"` -} - -// parsedUpdateInput holds validated and parsed update input fields. -type parsedUpdateInput struct { - ID string - Name *string - AreaID *string - GoalID *string - Status *lunatask.TaskStatus - Note *string - Priority *lunatask.Priority - Estimate *int - Motivation *lunatask.Motivation - Important *bool - Urgent *bool - ScheduledOn *lunatask.Date -} - -// HandleUpdate updates an existing task. -func (h *Handler) HandleUpdate( - ctx context.Context, - _ *mcp.CallToolRequest, - input UpdateInput, -) (*mcp.CallToolResult, UpdateOutput, error) { - parsed, errResult := parseUpdateInput(h.cfg, input) - if errResult != nil { - return errResult, UpdateOutput{}, nil - } - - builder := h.client.NewTaskUpdate(parsed.ID) - applyToTaskUpdateBuilder(builder, parsed) - - task, err := builder.Update(ctx) - if err != nil { - return shared.ErrorResult(err.Error()), UpdateOutput{}, nil - } - - deepLink, _ := lunatask.BuildDeepLink(lunatask.ResourceTask, task.ID) - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{ - Text: "Task updated: " + deepLink, - }}, - }, UpdateOutput{DeepLink: deepLink}, nil -} - -//nolint:cyclop,funlen -func parseUpdateInput(cfg *config.Config, input UpdateInput) (*parsedUpdateInput, *mcp.CallToolResult) { - _, id, err := lunatask.ParseReference(input.ID) - if err != nil { - return nil, shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link") - } - - parsed := &parsedUpdateInput{ - ID: id, - Name: input.Name, - Note: input.Note, - Estimate: input.Estimate, - Important: input.Important, - Urgent: input.Urgent, - } - - if input.AreaID != nil { - areaID, err := validate.AreaRef(cfg, *input.AreaID) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.AreaID = &areaID - } - - if input.GoalID != nil { - areaID := "" - if parsed.AreaID != nil { - areaID = *parsed.AreaID - } - - goalID, err := validate.GoalRef(cfg, areaID, *input.GoalID) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.GoalID = &goalID - } - - if input.Estimate != nil { - if err := shared.ValidateEstimate(*input.Estimate); err != nil { - return nil, shared.ErrorResult(err.Error()) - } - } - - if input.Status != nil { - status, err := lunatask.ParseTaskStatus(*input.Status) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.Status = &status - } - - if input.Priority != nil { - priority, err := lunatask.ParsePriority(*input.Priority) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.Priority = &priority - } - - if input.Motivation != nil { - motivation, err := lunatask.ParseMotivation(*input.Motivation) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.Motivation = &motivation - } - - if input.ScheduledOn != nil { - date, err := dateutil.Parse(*input.ScheduledOn) - if err != nil { - return nil, shared.ErrorResult(err.Error()) - } - - parsed.ScheduledOn = &date - } - - return parsed, nil -} - -//nolint:cyclop -func applyToTaskUpdateBuilder(builder *lunatask.TaskUpdateBuilder, parsed *parsedUpdateInput) { - if parsed.Name != nil { - builder.Name(*parsed.Name) - } - - if parsed.AreaID != nil { - builder.InArea(*parsed.AreaID) - } - - if parsed.GoalID != nil { - builder.InGoal(*parsed.GoalID) - } - - if parsed.Status != nil { - builder.WithStatus(*parsed.Status) - } - - if parsed.Note != nil { - builder.WithNote(*parsed.Note) - } - - if parsed.Priority != nil { - builder.Priority(*parsed.Priority) - } - - if parsed.Estimate != nil { - builder.WithEstimate(*parsed.Estimate) - } - - if parsed.Motivation != nil { - builder.WithMotivation(*parsed.Motivation) - } - - if parsed.Important != nil { - if *parsed.Important { - builder.Important() - } else { - builder.NotImportant() - } - } - - if parsed.Urgent != nil { - if *parsed.Urgent { - builder.Urgent() - } else { - builder.NotUrgent() - } - } - - if parsed.ScheduledOn != nil { - builder.ScheduledOn(*parsed.ScheduledOn) - } -} diff --git a/internal/mcp/tools/person/timeline.go b/internal/mcp/tools/timeline/handler.go similarity index 59% rename from internal/mcp/tools/person/timeline.go rename to internal/mcp/tools/timeline/handler.go index cae58317bd96046450c4bd2b8571a5f58cb84d96..79d25e7b69c21fde58e61d5894684bfa8c4c6a4e 100644 --- a/internal/mcp/tools/person/timeline.go +++ b/internal/mcp/tools/timeline/handler.go @@ -2,7 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -package person +// Package timeline provides the MCP tool for adding timeline notes to people. +package timeline import ( "context" @@ -13,11 +14,11 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// TimelineToolName is the name of the add timeline note tool. -const TimelineToolName = "add_timeline_note" +// ToolName is the name of the add timeline note tool. +const ToolName = "add_timeline_note" -// TimelineToolDescription describes the add timeline note tool for LLMs. -const TimelineToolDescription = `Adds a timeline note to a person's memory timeline in Lunatask. +// ToolDescription describes the add timeline note tool for LLMs. +const ToolDescription = `Adds a timeline note to a person's memory timeline in Lunatask. Required: - person_id: Person UUID or lunatask://person/... deep link @@ -29,29 +30,42 @@ Optional: This is append-only — adds to the person's memory timeline. Great for tracking when you last interacted with someone.` -// TimelineInput is the input schema for adding a timeline note. -type TimelineInput struct { +// Input is the input schema for adding a timeline note. +type Input struct { PersonID string `json:"person_id" jsonschema:"required"` Content *string `json:"content,omitempty"` Date *string `json:"date,omitempty"` } -// TimelineOutput is the output schema for adding a timeline note. -type TimelineOutput struct { +// Output is the output schema for adding a timeline note. +type Output struct { Success bool `json:"success"` PersonDeepLink string `json:"person_deep_link"` NoteID string `json:"note_id"` } -// HandleTimeline adds a timeline note to a person. -func (h *Handler) HandleTimeline( +// Handler handles timeline note MCP tool requests. +type Handler struct { + client *lunatask.Client +} + +// NewHandler creates a new timeline handler. +func NewHandler(accessToken string) *Handler { + return &Handler{ + client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")), + } +} + +// Handle adds a timeline note to a person. +func (h *Handler) Handle( ctx context.Context, _ *mcp.CallToolRequest, - input TimelineInput, -) (*mcp.CallToolResult, TimelineOutput, error) { - personID, err := parseReference(input.PersonID) + input Input, +) (*mcp.CallToolResult, Output, error) { + _, personID, err := lunatask.ParseReference(input.PersonID) if err != nil { - return shared.ErrorResult(err.Error()), TimelineOutput{}, nil + return shared.ErrorResult("invalid person_id: expected UUID or lunatask:// deep link"), + Output{}, nil } builder := h.client.NewTimelineNote(personID) @@ -63,7 +77,7 @@ func (h *Handler) HandleTimeline( if input.Date != nil { date, err := dateutil.Parse(*input.Date) if err != nil { - return shared.ErrorResult(err.Error()), TimelineOutput{}, nil + return shared.ErrorResult(err.Error()), Output{}, nil } builder.OnDate(date) @@ -71,7 +85,7 @@ func (h *Handler) HandleTimeline( note, err := builder.Create(ctx) if err != nil { - return shared.ErrorResult(err.Error()), TimelineOutput{}, nil + return shared.ErrorResult(err.Error()), Output{}, nil } personDeepLink, _ := lunatask.BuildDeepLink(lunatask.ResourcePerson, personID) @@ -87,7 +101,7 @@ func (h *Handler) HandleTimeline( Content: []mcp.Content{&mcp.TextContent{ Text: "Timeline note added for " + personDeepLink + " on " + dateStr, }}, - }, TimelineOutput{ + }, Output{ Success: true, PersonDeepLink: personDeepLink, NoteID: note.ID,