From d280a458f3bc41c4843dd9e294809d14bed508c8 Mon Sep 17 00:00:00 2001 From: Amolith Date: Tue, 23 Dec 2025 14:32:46 -0700 Subject: [PATCH] feat(config): add per-tool enable/disable toggles Adds [tools] section to config with boolean toggles for each MCP tool. All tools enabled by default; conditionally registered based on config. Wizard uses multi-select checkbox UI for tool selection during fresh setup and reconfiguration. Example config and summary updated accordingly. Also fixes stale module paths in exhaustruct exclusions and disables ireturn linter (interface returns are intentional in shared package). Assisted-by: Claude Opus 4.5 via Crush --- .golangci.yaml | 12 ++--- cmd/config/config.go | 39 ++++++++++++++ cmd/config/tools.go | 109 ++++++++++++++++++++++++++++++++++++++ cmd/serve.go | 57 +++++++++++++------- internal/config/config.go | 25 +++++++++ tools/tasks/handler.go | 10 +++- 6 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 cmd/config/tools.go 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 } }