diff --git a/.golangci.yaml b/.golangci.yaml index e7b91b6bf1c6f199827cb8efb449183d9dc798b5..62ea0344e39e45436dc1939f1ead4ddbdb34cef0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -63,7 +63,6 @@ linters: - interfacebloat - intrange - iotamixing - - ireturn - lll - loggercheck - maintidx @@ -119,6 +118,7 @@ linters: disable: - depguard + - ireturn settings: exhaustruct: @@ -134,11 +134,11 @@ linters: - git.secluded.site/go-lunatask.CreateTaskRequest - git.secluded.site/go-lunatask.UpdateTaskRequest # Internal output types where some fields are optional - - git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks.CreateOutput - - git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks.UpdateOutput - - git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks.DeleteOutput - - git.sr.ht/~amolith/lunatask-mcp-server/tools/habits.TrackOutput - - git.sr.ht/~amolith/lunatask-mcp-server/tools/timestamp.Output + - git.secluded.site/lunatask-mcp-server/tools/tasks.CreateOutput + - git.secluded.site/lunatask-mcp-server/tools/tasks.UpdateOutput + - git.secluded.site/lunatask-mcp-server/tools/tasks.DeleteOutput + - git.secluded.site/lunatask-mcp-server/tools/habits.TrackOutput + - git.secluded.site/lunatask-mcp-server/tools/timestamp.Output tagliatelle: case: rules: diff --git a/cmd/config/config.go b/cmd/config/config.go index 27f36a433e4e16637891a13629cf49c531de5bf7..810f5f429abd90830e00532796bb6ed92bd3b056 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -118,6 +118,7 @@ func runFreshSetup(cmd *cobra.Command, cfg *config.Config) error { steps := []wizardStep{ func() wizardNav { return runServerStep(cfg) }, + func() wizardNav { return runToolsStep(cfg) }, func() wizardNav { return runAreasStep(cfg) }, func() wizardNav { return runHabitsStep(cfg) }, func() wizardNav { return runAccessTokenStep(cmd) }, @@ -171,6 +172,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error { "areas": func() error { return manageAreas(cfg) }, "habits": func() error { return manageHabits(cfg) }, "server": func() error { return configureServer(cfg) }, + "tools": func() error { return configureTools(cfg) }, "token": func() error { return configureAccessToken(cmd) }, "reset": func() error { return resetConfig(cmd, cfg) }, } @@ -184,6 +186,7 @@ func runReconfigure(cmd *cobra.Command, cfg *config.Config) error { huh.NewOption("Manage areas & goals", "areas"), huh.NewOption("Manage habits", "habits"), huh.NewOption("Server settings", "server"), + huh.NewOption("Enabled tools", "tools"), huh.NewOption("Access token", "token"), huh.NewOption("Reset all configuration", "reset"), huh.NewOption("Done", choiceDone), @@ -220,11 +223,39 @@ func printConfigSummary(out io.Writer, cfg *config.Config) { _, _ = fmt.Fprintln(out, ui.Bold.Render("Configuration summary:")) _, _ = fmt.Fprintf(out, " Areas: %d (%d goals)\n", len(cfg.Areas), goalCount) _, _ = fmt.Fprintf(out, " Habits: %d\n", len(cfg.Habits)) + _, _ = fmt.Fprintf(out, " Tools: %s\n", formatToolsSummary(&cfg.Tools)) _, _ = fmt.Fprintf(out, " Server: %s:%d (%s)\n", cfg.Server.Host, cfg.Server.Port, cfg.Server.Transport) _, _ = fmt.Fprintf(out, " Timezone: %s\n", cfg.Timezone) _, _ = fmt.Fprintln(out) } +func formatToolsSummary(tools *config.ToolsConfig) string { + enabled := 0 + total := 5 + + if tools.GetTimestamp { + enabled++ + } + + if tools.CreateTask { + enabled++ + } + + if tools.UpdateTask { + enabled++ + } + + if tools.DeleteTask { + enabled++ + } + + if tools.TrackHabitActivity { + enabled++ + } + + return fmt.Sprintf("%d/%d enabled", enabled, total) +} + func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error { path, err := config.Path() if err != nil { @@ -279,6 +310,14 @@ transport = "stdio" # Timezone for date parsing (IANA format) timezone = "UTC" +# Enable or disable individual MCP tools (all enabled by default) +[tools] +get_timestamp = true +create_task = true +update_task = true +delete_task = true +track_habit_activity = true + # Areas of life from Lunatask # Find IDs in Lunatask: Open area settings → "Copy Area ID" (bottom left) # diff --git a/cmd/config/tools.go b/cmd/config/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..494cab5f329e63a64d788ce9487f3c615a53f6aa --- /dev/null +++ b/cmd/config/tools.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package config + +import ( + "errors" + + "github.com/charmbracelet/huh" + + "git.secluded.site/lunatask-mcp-server/internal/config" +) + +// Tool identifiers for the multi-select. +const ( + toolGetTimestamp = "get_timestamp" + toolCreateTask = "create_task" + toolUpdateTask = "update_task" + toolDeleteTask = "delete_task" + toolTrackHabitActivity = "track_habit_activity" +) + +// runToolsStep runs the tools configuration step. +func runToolsStep(cfg *config.Config) wizardNav { + cfg.Tools.ApplyDefaults() + + selected := toolsToSlice(&cfg.Tools) + + err := huh.NewMultiSelect[string](). + Title("Enable MCP tools"). + Description("Space to toggle, Enter to confirm."). + Options( + huh.NewOption("get_timestamp - Parse natural language dates", toolGetTimestamp). + Selected(cfg.Tools.GetTimestamp), + huh.NewOption("create_task - Create new tasks", toolCreateTask). + Selected(cfg.Tools.CreateTask), + huh.NewOption("update_task - Modify existing tasks", toolUpdateTask). + Selected(cfg.Tools.UpdateTask), + huh.NewOption("delete_task - Permanently delete tasks", toolDeleteTask). + Selected(cfg.Tools.DeleteTask), + huh.NewOption("track_habit_activity - Record habit completions", toolTrackHabitActivity). + Selected(cfg.Tools.TrackHabitActivity), + ). + Value(&selected). + Run() + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return navQuit + } + + return navQuit + } + + sliceToTools(selected, &cfg.Tools) + + return navNext +} + +// configureTools runs the tools configuration from the main menu. +func configureTools(cfg *config.Config) error { + nav := runToolsStep(cfg) + if nav == navQuit { + return errQuit + } + + return nil +} + +// toolsToSlice converts ToolsConfig bools to a slice of enabled tool names. +func toolsToSlice(tools *config.ToolsConfig) []string { + var enabled []string + + if tools.GetTimestamp { + enabled = append(enabled, toolGetTimestamp) + } + + if tools.CreateTask { + enabled = append(enabled, toolCreateTask) + } + + if tools.UpdateTask { + enabled = append(enabled, toolUpdateTask) + } + + if tools.DeleteTask { + enabled = append(enabled, toolDeleteTask) + } + + if tools.TrackHabitActivity { + enabled = append(enabled, toolTrackHabitActivity) + } + + return enabled +} + +// sliceToTools converts a slice of tool names back to ToolsConfig bools. +func sliceToTools(selected []string, tools *config.ToolsConfig) { + enabledSet := make(map[string]bool) + for _, s := range selected { + enabledSet[s] = true + } + + tools.GetTimestamp = enabledSet[toolGetTimestamp] + tools.CreateTask = enabledSet[toolCreateTask] + tools.UpdateTask = enabledSet[toolUpdateTask] + tools.DeleteTask = enabledSet[toolDeleteTask] + tools.TrackHabitActivity = enabledSet[toolTrackHabitActivity] +} diff --git a/cmd/serve.go b/cmd/serve.go index 4466ff87bb838a0c11e1a655a3b3c09199a613ec..75576745ba6b75a71beeb20345e9a4f6bb7de3ac 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -174,9 +174,9 @@ func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server { habitProviders := toHabitProviders(cfg.Habits) registerResources(mcpServer, areaProviders, habitProviders) - registerTimestampTool(mcpServer, cfg.Timezone) - registerTaskTools(mcpServer, accessToken, cfg.Timezone, areaProviders) - registerHabitTool(mcpServer, accessToken, habitProviders) + registerTimestampTool(mcpServer, cfg) + registerTaskTools(mcpServer, cfg, accessToken, areaProviders) + registerHabitTool(mcpServer, cfg, accessToken, habitProviders) return mcpServer } @@ -309,8 +309,12 @@ func registerResources( }, habitsResourceHandler.HandleRead) } -func registerTimestampTool(mcpServer *mcp.Server, timezone string) { - handler := timestamp.NewHandler(timezone) +func registerTimestampTool(mcpServer *mcp.Server, cfg *config.Config) { + if !cfg.Tools.GetTimestamp { + return + } + + handler := timestamp.NewHandler(cfg.Timezone) mcp.AddTool(mcpServer, &mcp.Tool{ Name: "get_timestamp", @@ -320,33 +324,48 @@ func registerTimestampTool(mcpServer *mcp.Server, timezone string) { func registerTaskTools( mcpServer *mcp.Server, + cfg *config.Config, accessToken string, - timezone string, areaProviders []shared.AreaProvider, ) { - handler := tasks.NewHandler(accessToken, timezone, areaProviders) + if !cfg.Tools.CreateTask && !cfg.Tools.UpdateTask && !cfg.Tools.DeleteTask { + return + } - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: "create_task", - Description: tasks.CreateToolDescription, - }, handler.HandleCreate) + handler := tasks.NewHandler(accessToken, cfg.Timezone, areaProviders) - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: "update_task", - Description: tasks.UpdateToolDescription, - }, handler.HandleUpdate) + if cfg.Tools.CreateTask { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: "create_task", + Description: tasks.CreateToolDescription, + }, handler.HandleCreate) + } - mcp.AddTool(mcpServer, &mcp.Tool{ - Name: "delete_task", - Description: tasks.DeleteToolDescription, - }, handler.HandleDelete) + if cfg.Tools.UpdateTask { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: "update_task", + Description: tasks.UpdateToolDescription, + }, handler.HandleUpdate) + } + + if cfg.Tools.DeleteTask { + mcp.AddTool(mcpServer, &mcp.Tool{ + Name: "delete_task", + Description: tasks.DeleteToolDescription, + }, handler.HandleDelete) + } } func registerHabitTool( mcpServer *mcp.Server, + cfg *config.Config, accessToken string, habitProviders []shared.HabitProvider, ) { + if !cfg.Tools.TrackHabitActivity { + return + } + handler := habits.NewHandler(accessToken, habitProviders) mcp.AddTool(mcpServer, &mcp.Tool{ diff --git a/internal/config/config.go b/internal/config/config.go index 8dcc709345ef90cde6126248647230e616b19b8e..9abce80c56fd4ba0e05a48392b3effd848c9b811 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ var ErrNotFound = errors.New("config file not found") // Config represents the lunatask-mcp-server configuration file structure. type Config struct { Server ServerConfig `toml:"server"` + Tools ToolsConfig `toml:"tools"` Timezone string `toml:"timezone"` Areas []Area `toml:"areas"` Habits []Habit `toml:"habits"` @@ -32,6 +33,15 @@ type ServerConfig struct { Transport string `toml:"transport"` } +// ToolsConfig controls which MCP tools are enabled. +type ToolsConfig struct { + GetTimestamp bool `toml:"get_timestamp"` + CreateTask bool `toml:"create_task"` + UpdateTask bool `toml:"update_task"` + DeleteTask bool `toml:"delete_task"` + TrackHabitActivity bool `toml:"track_habit_activity"` +} + // Area represents a Lunatask area of life with its goals. type Area struct { ID string `json:"id" toml:"id"` @@ -257,4 +267,19 @@ func (c *Config) applyDefaults() { if c.Timezone == "" { c.Timezone = "UTC" } + + c.Tools.ApplyDefaults() +} + +// ApplyDefaults enables all tools by default. +func (t *ToolsConfig) ApplyDefaults() { + // Use a marker to detect if tools section was present in config. + // If all are false, apply defaults (all true). + if !t.GetTimestamp && !t.CreateTask && !t.UpdateTask && !t.DeleteTask && !t.TrackHabitActivity { + t.GetTimestamp = true + t.CreateTask = true + t.UpdateTask = true + t.DeleteTask = true + t.TrackHabitActivity = true + } } diff --git a/tools/tasks/handler.go b/tools/tasks/handler.go index 4fd78db4a6e48a62f480079d8aab3d4bbc91d8ea..9ce9f21b68290488d1fd7d7b17f9b4e7177623f8 100644 --- a/tools/tasks/handler.go +++ b/tools/tasks/handler.go @@ -59,6 +59,7 @@ func (h *Handler) HandleCreate( // Resolve goal key to ID if provided var goalID string + if input.GoalID != nil && *input.GoalID != "" { goal := shared.GetGoalInArea(area, *input.GoalID) if goal == nil { @@ -68,6 +69,7 @@ func (h *Handler) HandleCreate( area.GetName(), ) } + goalID = goal.GetID() } @@ -224,8 +226,10 @@ func (h *Handler) applyCreateOptions(builder *lunatask.TaskBuilder, input Create // //nolint:funlen,gocognit // each field handling is straightforward func (h *Handler) applyUpdateOptions(builder *lunatask.TaskUpdateBuilder, input UpdateInput) error { - var resolvedAreaID string - var resolvedGoalID string + var ( + resolvedAreaID string + resolvedGoalID string + ) if input.AreaID != nil && *input.AreaID != "" { area := shared.FindArea(h.areas, *input.AreaID) @@ -242,6 +246,7 @@ func (h *Handler) applyUpdateOptions(builder *lunatask.TaskUpdateBuilder, input if goal == nil { return fmt.Errorf("goal %s not found in area %s", *input.GoalID, area.GetName()) } + resolvedGoalID = goal.GetID() } } @@ -255,6 +260,7 @@ func (h *Handler) applyUpdateOptions(builder *lunatask.TaskUpdateBuilder, input for _, area := range h.areas { if goal := shared.GetGoalInArea(area, *input.GoalID); goal != nil { builder.InGoal(goal.GetID()) + break } }