Detailed changes
@@ -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
@@ -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.
@@ -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)
@@ -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)
}
}
@@ -1,96 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-// Package area provides MCP tools for area operations.
-package area
-
-import (
- "context"
- "fmt"
- "strings"
-
- "git.secluded.site/lune/internal/mcp/shared"
- "github.com/modelcontextprotocol/go-sdk/mcp"
-)
-
-// ListToolName is the name of the list areas tool.
-const ListToolName = "list_areas"
-
-// ListToolDescription describes the list areas tool for LLMs.
-const ListToolDescription = `Lists configured areas. Fallback for lunatask://areas resource.
-
-Returns area metadata (id, name, key, workflow) from config.
-Use the lunatask://areas resource if your client supports MCP resources.`
-
-// ListInput is the input schema for listing areas.
-type ListInput struct{}
-
-// Summary represents an area in the list output.
-type Summary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Key string `json:"key"`
- Workflow string `json:"workflow"`
-}
-
-// ListOutput is the output schema for listing areas.
-type ListOutput struct {
- Areas []Summary `json:"areas"`
- Count int `json:"count"`
-}
-
-// Handler handles area tool requests.
-type Handler struct {
- areas []shared.AreaProvider
-}
-
-// NewHandler creates a new area tool handler.
-func NewHandler(areas []shared.AreaProvider) *Handler {
- return &Handler{areas: areas}
-}
-
-// HandleList lists configured areas.
-func (h *Handler) HandleList(
- _ context.Context,
- _ *mcp.CallToolRequest,
- _ ListInput,
-) (*mcp.CallToolResult, ListOutput, error) {
- summaries := make([]Summary, 0, len(h.areas))
-
- for _, area := range h.areas {
- summaries = append(summaries, Summary{
- ID: area.ID,
- Name: area.Name,
- Key: area.Key,
- Workflow: string(area.Workflow),
- })
- }
-
- output := ListOutput{
- Areas: summaries,
- Count: len(summaries),
- }
-
- text := formatListText(summaries)
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{&mcp.TextContent{Text: text}},
- }, output, nil
-}
-
-func formatListText(areas []Summary) string {
- if len(areas) == 0 {
- return "No areas configured"
- }
-
- var text strings.Builder
-
- text.WriteString(fmt.Sprintf("Found %d area(s):\n", len(areas)))
-
- for _, a := range areas {
- text.WriteString(fmt.Sprintf("- %s: %s (%s, workflow: %s)\n", a.Key, a.Name, a.ID, a.Workflow))
- }
-
- return text.String()
-}
@@ -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
}
@@ -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
}
@@ -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()
}
@@ -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
}
@@ -1,128 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-// Package goal provides MCP tools for goal operations.
-package goal
-
-import (
- "context"
- "fmt"
- "strings"
-
- "git.secluded.site/go-lunatask"
- "git.secluded.site/lune/internal/mcp/shared"
- "github.com/modelcontextprotocol/go-sdk/mcp"
-)
-
-// ListToolName is the name of the list goals tool.
-const ListToolName = "list_goals"
-
-// ListToolDescription describes the list goals tool for LLMs.
-const ListToolDescription = `Lists goals for an area. Use list_areas to find area IDs first.
-
-Required:
-- area_id: Area UUID, deep link, or config key
-
-Returns goal metadata (id, name, key) for the specified area.`
-
-// ListInput is the input schema for listing goals.
-type ListInput struct {
- AreaID string `json:"area_id" jsonschema:"required"`
-}
-
-// Summary represents a goal in the list output.
-type Summary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Key string `json:"key"`
-}
-
-// ListOutput is the output schema for listing goals.
-type ListOutput struct {
- Goals []Summary `json:"goals"`
- Count int `json:"count"`
- AreaID string `json:"area_id"`
-}
-
-// Handler handles goal tool requests.
-type Handler struct {
- areas []shared.AreaProvider
-}
-
-// NewHandler creates a new goal tool handler.
-func NewHandler(areas []shared.AreaProvider) *Handler {
- return &Handler{areas: areas}
-}
-
-// HandleList lists goals for an area.
-func (h *Handler) HandleList(
- _ context.Context,
- _ *mcp.CallToolRequest,
- input ListInput,
-) (*mcp.CallToolResult, ListOutput, error) {
- area := h.resolveAreaRef(input.AreaID)
- if area == nil {
- return shared.ErrorResult("unknown area: " + input.AreaID), ListOutput{}, nil
- }
-
- summaries := make([]Summary, 0, len(area.Goals))
-
- for _, goal := range area.Goals {
- summaries = append(summaries, Summary{
- ID: goal.ID,
- Name: goal.Name,
- Key: goal.Key,
- })
- }
-
- output := ListOutput{
- Goals: summaries,
- Count: len(summaries),
- AreaID: area.ID,
- }
-
- text := formatListText(summaries, area.Name)
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{&mcp.TextContent{Text: text}},
- }, output, nil
-}
-
-// resolveAreaRef resolves an area reference to an AreaProvider.
-// Accepts config key, UUID, or deep link.
-func (h *Handler) resolveAreaRef(input string) *shared.AreaProvider {
- // Try UUID or deep link first
- if _, id, err := lunatask.ParseReference(input); err == nil {
- for i := range h.areas {
- if h.areas[i].ID == id {
- return &h.areas[i]
- }
- }
- }
-
- // Try config key lookup
- for i := range h.areas {
- if h.areas[i].Key == input {
- return &h.areas[i]
- }
- }
-
- return nil
-}
-
-func formatListText(goals []Summary, areaName string) string {
- if len(goals) == 0 {
- return fmt.Sprintf("No goals configured for area %q", areaName)
- }
-
- var text strings.Builder
-
- text.WriteString(fmt.Sprintf("Found %d goal(s) in %q:\n", len(goals), areaName))
-
- for _, g := range goals {
- text.WriteString(fmt.Sprintf("- %s: %s (%s)\n", g.Key, g.Name, g.ID))
- }
-
- return text.String()
-}
@@ -1,82 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package habit
-
-import (
- "context"
- "fmt"
- "strings"
-
- "github.com/modelcontextprotocol/go-sdk/mcp"
-)
-
-// ListToolName is the name of the list habits tool.
-const ListToolName = "list_habits"
-
-// ListToolDescription describes the list habits tool for LLMs.
-const ListToolDescription = `Lists configured habits. Fallback for lunatask://habits resource.
-
-Returns habit metadata (id, name, key) from config.
-Use the lunatask://habits resource if your client supports MCP resources.`
-
-// ListInput is the input schema for listing habits.
-type ListInput struct{}
-
-// Summary represents a habit in the list output.
-type Summary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Key string `json:"key"`
-}
-
-// ListOutput is the output schema for listing habits.
-type ListOutput struct {
- Habits []Summary `json:"habits"`
- Count int `json:"count"`
-}
-
-// HandleList lists configured habits.
-func (h *Handler) HandleList(
- _ context.Context,
- _ *mcp.CallToolRequest,
- _ ListInput,
-) (*mcp.CallToolResult, ListOutput, error) {
- summaries := make([]Summary, 0, len(h.habits))
-
- for _, habit := range h.habits {
- summaries = append(summaries, Summary{
- ID: habit.ID,
- Name: habit.Name,
- Key: habit.Key,
- })
- }
-
- output := ListOutput{
- Habits: summaries,
- Count: len(summaries),
- }
-
- text := formatListText(summaries)
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{&mcp.TextContent{Text: text}},
- }, output, nil
-}
-
-func formatListText(habits []Summary) string {
- if len(habits) == 0 {
- return "No habits configured"
- }
-
- var text strings.Builder
-
- text.WriteString(fmt.Sprintf("Found %d habit(s):\n", len(habits)))
-
- for _, h := range habits {
- text.WriteString(fmt.Sprintf("- %s: %s (%s)\n", h.Key, h.Name, h.ID))
- }
-
- return text.String()
-}
@@ -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 ""
+}
@@ -1,102 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,113 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,63 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,185 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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()
-}
@@ -1,142 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package note
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "git.secluded.site/go-lunatask"
- "git.secluded.site/lune/internal/mcp/shared"
- "github.com/modelcontextprotocol/go-sdk/mcp"
-)
-
-// ShowToolName is the name of the show note tool.
-const ShowToolName = "show_note"
-
-// ShowToolDescription describes the show note tool for LLMs.
-const ShowToolDescription = `Shows metadata for a specific note from Lunatask.
-
-Required:
-- id: Note UUID or lunatask:// deep link
-
-Note: Due to end-to-end encryption, note title and content are not available.
-Only metadata (notebook, date, pinned status, sources) is returned.`
-
-// ShowInput is the input schema for showing a note.
-type ShowInput struct {
- ID string `json:"id" jsonschema:"required"`
-}
-
-// ShowSource represents a source reference in the output.
-type ShowSource struct {
- Source string `json:"source"`
- SourceID string `json:"source_id"`
-}
-
-// ShowOutput is the output schema for showing a note.
-type ShowOutput struct {
- DeepLink string `json:"deep_link"`
- NotebookID *string `json:"notebook_id,omitempty"`
- DateOn *string `json:"date_on,omitempty"`
- Pinned bool `json:"pinned"`
- Sources []ShowSource `json:"sources,omitempty"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
-}
-
-// HandleShow shows a note's details.
-func (h *Handler) HandleShow(
- ctx context.Context,
- _ *mcp.CallToolRequest,
- input ShowInput,
-) (*mcp.CallToolResult, ShowOutput, error) {
- _, id, err := lunatask.ParseReference(input.ID)
- if err != nil {
- return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), ShowOutput{}, nil
- }
-
- note, err := h.client.GetNote(ctx, id)
- if err != nil {
- return shared.ErrorResult(err.Error()), ShowOutput{}, nil
- }
-
- output := buildShowOutput(note)
- text := formatNoteShowText(output, h.notebooks)
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{&mcp.TextContent{Text: text}},
- }, output, nil
-}
-
-func buildShowOutput(note *lunatask.Note) ShowOutput {
- output := ShowOutput{
- NotebookID: note.NotebookID,
- Pinned: note.Pinned,
- CreatedAt: note.CreatedAt.Format(time.RFC3339),
- UpdatedAt: note.UpdatedAt.Format(time.RFC3339),
- }
-
- output.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourceNote, note.ID)
-
- if note.DateOn != nil {
- s := note.DateOn.Format("2006-01-02")
- output.DateOn = &s
- }
-
- if len(note.Sources) > 0 {
- output.Sources = make([]ShowSource, 0, len(note.Sources))
- for _, src := range note.Sources {
- output.Sources = append(output.Sources, ShowSource{
- Source: src.Source,
- SourceID: src.SourceID,
- })
- }
- }
-
- return output
-}
-
-func formatNoteShowText(output ShowOutput, notebooks []shared.NotebookProvider) string {
- var builder strings.Builder
-
- builder.WriteString(fmt.Sprintf("Note: %s\n", output.DeepLink))
-
- if output.NotebookID != nil {
- nbName := *output.NotebookID
- for _, nb := range notebooks {
- if nb.ID == *output.NotebookID {
- nbName = nb.Key
-
- break
- }
- }
-
- builder.WriteString(fmt.Sprintf("Notebook: %s\n", nbName))
- }
-
- if output.DateOn != nil {
- builder.WriteString(fmt.Sprintf("Date: %s\n", *output.DateOn))
- }
-
- if output.Pinned {
- builder.WriteString("Pinned: yes\n")
- }
-
- if len(output.Sources) > 0 {
- builder.WriteString("Sources:\n")
-
- for _, src := range output.Sources {
- builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID))
- }
- }
-
- builder.WriteString(fmt.Sprintf("Created: %s\n", output.CreatedAt))
- builder.WriteString("Updated: " + output.UpdatedAt)
-
- return builder.String()
-}
@@ -1,110 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,94 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-// Package notebook provides MCP tools for notebook operations.
-package notebook
-
-import (
- "context"
- "fmt"
- "strings"
-
- "git.secluded.site/lune/internal/mcp/shared"
- "github.com/modelcontextprotocol/go-sdk/mcp"
-)
-
-// ListToolName is the name of the list notebooks tool.
-const ListToolName = "list_notebooks"
-
-// ListToolDescription describes the list notebooks tool for LLMs.
-const ListToolDescription = `Lists configured notebooks. Fallback for lunatask://notebooks resource.
-
-Returns notebook metadata (IDs, names, keys).
-Use notebook IDs when creating or updating notes.`
-
-// ListInput is the input schema for listing notebooks.
-type ListInput struct{}
-
-// Summary represents a notebook in the list output.
-type Summary struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Key string `json:"key"`
-}
-
-// ListOutput is the output schema for listing notebooks.
-type ListOutput struct {
- Notebooks []Summary `json:"notebooks"`
- Count int `json:"count"`
-}
-
-// Handler handles notebook tool requests.
-type Handler struct {
- notebooks []shared.NotebookProvider
-}
-
-// NewHandler creates a new notebook tool handler.
-func NewHandler(notebooks []shared.NotebookProvider) *Handler {
- return &Handler{notebooks: notebooks}
-}
-
-// HandleList lists configured notebooks.
-func (h *Handler) HandleList(
- _ context.Context,
- _ *mcp.CallToolRequest,
- _ ListInput,
-) (*mcp.CallToolResult, ListOutput, error) {
- summaries := make([]Summary, 0, len(h.notebooks))
-
- for _, nb := range h.notebooks {
- summaries = append(summaries, Summary{
- ID: nb.ID,
- Name: nb.Name,
- Key: nb.Key,
- })
- }
-
- output := ListOutput{
- Notebooks: summaries,
- Count: len(summaries),
- }
-
- text := formatListText(summaries)
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{&mcp.TextContent{Text: text}},
- }, output, nil
-}
-
-func formatListText(notebooks []Summary) string {
- if len(notebooks) == 0 {
- return "No notebooks configured"
- }
-
- var text strings.Builder
-
- text.WriteString(fmt.Sprintf("Found %d notebook(s):\n", len(notebooks)))
-
- for _, nb := range notebooks {
- text.WriteString(fmt.Sprintf("- %s (key: %s, id: %s)\n", nb.Name, nb.Key, nb.ID))
- }
-
- return text.String()
-}
@@ -1,99 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,63 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,138 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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()
-}
@@ -1,121 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package person
-
-import (
- "context"
- "fmt"
- "strings"
- "time"
-
- "git.secluded.site/go-lunatask"
- "git.secluded.site/lune/internal/mcp/shared"
- "github.com/modelcontextprotocol/go-sdk/mcp"
-)
-
-// ShowToolName is the name of the show person tool.
-const ShowToolName = "show_person"
-
-// ShowToolDescription describes the show person tool for LLMs.
-const ShowToolDescription = `Shows metadata for a specific person from Lunatask.
-
-Required:
-- id: Person UUID or lunatask:// deep link
-
-Note: Due to end-to-end encryption, person name is not available.
-Only metadata (relationship strength, sources) is returned.`
-
-// ShowInput is the input schema for showing a person.
-type ShowInput struct {
- ID string `json:"id" jsonschema:"required"`
-}
-
-// ShowSource represents a source reference in the output.
-type ShowSource struct {
- Source string `json:"source"`
- SourceID string `json:"source_id"`
-}
-
-// ShowOutput is the output schema for showing a person.
-type ShowOutput struct {
- DeepLink string `json:"deep_link"`
- RelationshipStrength *string `json:"relationship_strength,omitempty"`
- Sources []ShowSource `json:"sources,omitempty"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
-}
-
-// HandleShow shows a person's details.
-func (h *Handler) HandleShow(
- ctx context.Context,
- _ *mcp.CallToolRequest,
- input ShowInput,
-) (*mcp.CallToolResult, ShowOutput, error) {
- _, id, err := lunatask.ParseReference(input.ID)
- if err != nil {
- return shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link"), ShowOutput{}, nil
- }
-
- person, err := h.client.GetPerson(ctx, id)
- if err != nil {
- return shared.ErrorResult(err.Error()), ShowOutput{}, nil
- }
-
- output := buildShowOutput(person)
- text := formatPersonShowText(output)
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{&mcp.TextContent{Text: text}},
- }, output, nil
-}
-
-func buildShowOutput(person *lunatask.Person) ShowOutput {
- output := ShowOutput{
- CreatedAt: person.CreatedAt.Format(time.RFC3339),
- UpdatedAt: person.UpdatedAt.Format(time.RFC3339),
- }
-
- output.DeepLink, _ = lunatask.BuildDeepLink(lunatask.ResourcePerson, person.ID)
-
- if person.RelationshipStrength != nil {
- s := string(*person.RelationshipStrength)
- output.RelationshipStrength = &s
- }
-
- if len(person.Sources) > 0 {
- output.Sources = make([]ShowSource, 0, len(person.Sources))
- for _, src := range person.Sources {
- output.Sources = append(output.Sources, ShowSource{
- Source: src.Source,
- SourceID: src.SourceID,
- })
- }
- }
-
- return output
-}
-
-func formatPersonShowText(output ShowOutput) string {
- var builder strings.Builder
-
- builder.WriteString(fmt.Sprintf("Person: %s\n", output.DeepLink))
-
- if output.RelationshipStrength != nil {
- builder.WriteString(fmt.Sprintf("Relationship: %s\n", *output.RelationshipStrength))
- }
-
- if len(output.Sources) > 0 {
- builder.WriteString("Sources:\n")
-
- for _, src := range output.Sources {
- builder.WriteString(fmt.Sprintf(" - %s: %s\n", src.Source, src.SourceID))
- }
- }
-
- builder.WriteString(fmt.Sprintf("Created: %s\n", output.CreatedAt))
- builder.WriteString("Updated: " + output.UpdatedAt)
-
- return builder.String()
-}
@@ -1,98 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,239 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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)
- }
-}
@@ -1,59 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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
-}
@@ -1,158 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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()
-}
@@ -1,162 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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, ", "))
- }
-}
@@ -1,244 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// 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)
- }
-}
@@ -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,