feat(mcp): add tool enable/disable CLI flags

Amolith created

Mutually exclusive CLI flags for runtime control over MCP tool exposure:

- --enabled-tools a,b,c: ignore config, enable only listed tools
- --disabled-tools x,y,z: ignore config, enable everything except listed

Tool names match the MCP tool names exactly (what the model sees).
Validates tool names against known set before starting server.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/mcp/mcp.go | 114 ++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 107 insertions(+), 7 deletions(-)

Detailed changes

cmd/mcp/mcp.go 🔗

@@ -11,6 +11,13 @@ import (
 
 	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/config"
+	"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/person"
+	"git.secluded.site/lune/internal/mcp/tools/task"
+	"git.secluded.site/lune/internal/mcp/tools/timestamp"
+	mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
 	"github.com/spf13/cobra"
 )
 
@@ -22,14 +29,18 @@ const (
 )
 
 var (
-	errUnknownTransport = errors.New("unknown transport; use stdio, sse, or http")
-	errNoToken          = errors.New("no access token; run 'lune init' first")
+	errUnknownTransport  = errors.New("unknown transport; use stdio, sse, or http")
+	errNoToken           = errors.New("no access token; run 'lune init' first")
+	errMutuallyExclusive = errors.New("--enabled-tools and --disabled-tools are mutually exclusive")
+	errUnknownTool       = errors.New("unknown tool name")
 )
 
 var (
-	transport string
-	host      string
-	port      int
+	transport     string
+	host          string
+	port          int
+	enabledTools  []string
+	disabledTools []string
 )
 
 // Cmd is the mcp command for starting the MCP server.
@@ -58,14 +69,26 @@ func init() {
 		"Transport type: stdio, sse, http (default: stdio or config)")
 	Cmd.Flags().StringVar(&host, "host", "", "Server host (for sse/http)")
 	Cmd.Flags().IntVar(&port, "port", 0, "Server port (for sse/http)")
+	Cmd.Flags().StringSliceVar(&enabledTools, "enabled-tools", nil,
+		"Enable only these tools (comma-separated); overrides config")
+	Cmd.Flags().StringSliceVar(&disabledTools, "disabled-tools", nil,
+		"Disable these tools (comma-separated); overrides config")
 }
 
 func runMCP(cmd *cobra.Command, _ []string) error {
+	if len(enabledTools) > 0 && len(disabledTools) > 0 {
+		return errMutuallyExclusive
+	}
+
 	cfg, err := loadConfig()
 	if err != nil {
 		return err
 	}
 
+	if err := resolveTools(cfg); err != nil {
+		return err
+	}
+
 	token, err := client.GetToken()
 	if err != nil {
 		return fmt.Errorf("getting access token: %w", err)
@@ -77,9 +100,11 @@ func runMCP(cmd *cobra.Command, _ []string) error {
 
 	mcpServer := newMCPServer(cfg, token)
 
-	effectiveTransport := resolveTransport(cfg)
+	return runTransport(cmd, mcpServer, cfg)
+}
 
-	switch effectiveTransport {
+func runTransport(cmd *cobra.Command, mcpServer *mcpsdk.Server, cfg *config.Config) error {
+	switch resolveTransport(cfg) {
 	case TransportStdio:
 		return runStdio(mcpServer)
 	case TransportSSE:
@@ -129,3 +154,78 @@ func resolvePort(cfg *config.Config) int {
 
 	return cfg.MCP.Port
 }
+
+// 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 },
+	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 },
+	habit.TrackToolName:     func(t *config.ToolsConfig, v bool) { t.TrackHabit = v },
+	journal.CreateToolName:  func(t *config.ToolsConfig, v bool) { t.CreateJournal = v },
+	// TODO: Add these once implemented:
+	// - show_note (ShowNote)
+	// - show_person (ShowPerson)
+	// - list_habits (ListHabits)
+	// - list_areas (ListAreas) - needs config field
+	// - list_goals (ListGoals) - needs config field
+}
+
+// resolveTools modifies cfg.MCP.Tools based on CLI flags.
+// If --enabled-tools is set, only those tools are enabled.
+// If --disabled-tools is set, all tools except those are enabled.
+// If neither is set, config values are used unchanged.
+func resolveTools(cfg *config.Config) error {
+	if len(enabledTools) > 0 {
+		// Validate all tool names first
+		for _, name := range enabledTools {
+			if _, ok := validToolNames[name]; !ok {
+				return fmt.Errorf("%w: %s", errUnknownTool, name)
+			}
+		}
+
+		// Start with everything disabled
+		cfg.MCP.Tools = config.ToolsConfig{}
+
+		// Enable only specified tools
+		for _, name := range enabledTools {
+			validToolNames[name](&cfg.MCP.Tools, true)
+		}
+
+		return nil
+	}
+
+	if len(disabledTools) > 0 {
+		// Validate all tool names first
+		for _, name := range disabledTools {
+			if _, ok := validToolNames[name]; !ok {
+				return fmt.Errorf("%w: %s", errUnknownTool, name)
+			}
+		}
+
+		// Start with everything enabled
+		cfg.MCP.Tools = config.ToolsConfig{}
+		cfg.MCP.Tools.ApplyDefaults()
+
+		// Disable specified tools
+		for _, name := range disabledTools {
+			validToolNames[name](&cfg.MCP.Tools, false)
+		}
+
+		return nil
+	}
+
+	// Neither flag set, use config as-is
+	return nil
+}