feat(config): add per-tool enable/disable toggles

Amolith created

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

Change summary

.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(-)

Detailed changes

.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:

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)
 #

cmd/config/tools.go 🔗

@@ -0,0 +1,109 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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]
+}

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{

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
+	}
 }

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
 				}
 			}