mcp.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5// Package mcp provides the MCP server command for lune.
  6package mcp
  7
  8import (
  9	"errors"
 10	"fmt"
 11
 12	"git.secluded.site/lune/internal/client"
 13	"git.secluded.site/lune/internal/config"
 14	"git.secluded.site/lune/internal/mcp/tools/habit"
 15	"git.secluded.site/lune/internal/mcp/tools/journal"
 16	"git.secluded.site/lune/internal/mcp/tools/note"
 17	"git.secluded.site/lune/internal/mcp/tools/person"
 18	"git.secluded.site/lune/internal/mcp/tools/task"
 19	"git.secluded.site/lune/internal/mcp/tools/timestamp"
 20	mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
 21	"github.com/spf13/cobra"
 22)
 23
 24// Transport constants.
 25const (
 26	TransportStdio = "stdio"
 27	TransportSSE   = "sse"
 28	TransportHTTP  = "http"
 29)
 30
 31var (
 32	errUnknownTransport  = errors.New("unknown transport; use stdio, sse, or http")
 33	errNoToken           = errors.New("no access token; run 'lune init' first")
 34	errMutuallyExclusive = errors.New("--enabled-tools and --disabled-tools are mutually exclusive")
 35	errUnknownTool       = errors.New("unknown tool name")
 36)
 37
 38var (
 39	transport     string
 40	host          string
 41	port          int
 42	enabledTools  []string
 43	disabledTools []string
 44)
 45
 46// Cmd is the mcp command for starting the MCP server.
 47var Cmd = &cobra.Command{
 48	Use:   "mcp",
 49	Short: "Start the MCP server",
 50	Long: `Start a Model Context Protocol server for LLM tool integration.
 51
 52The MCP server exposes Lunatask resources and tools that can be used by
 53LLM assistants (like Claude) to interact with your Lunatask data.
 54
 55Transports:
 56  stdio  - Standard input/output (default, for local integrations)
 57  sse    - Server-sent events over HTTP
 58  http   - Streamable HTTP
 59
 60Examples:
 61  lune mcp                    # Start with stdio (default)
 62  lune mcp -t sse             # Start SSE server on configured host:port
 63  lune mcp -t sse --port 9000 # Override port`,
 64	RunE: runMCP,
 65}
 66
 67func init() {
 68	Cmd.Flags().StringVarP(&transport, "transport", "t", "",
 69		"Transport type: stdio, sse, http (default: stdio or config)")
 70	Cmd.Flags().StringVar(&host, "host", "", "Server host (for sse/http)")
 71	Cmd.Flags().IntVar(&port, "port", 0, "Server port (for sse/http)")
 72	Cmd.Flags().StringSliceVar(&enabledTools, "enabled-tools", nil,
 73		"Enable only these tools (comma-separated); overrides config")
 74	Cmd.Flags().StringSliceVar(&disabledTools, "disabled-tools", nil,
 75		"Disable these tools (comma-separated); overrides config")
 76}
 77
 78func runMCP(cmd *cobra.Command, _ []string) error {
 79	if len(enabledTools) > 0 && len(disabledTools) > 0 {
 80		return errMutuallyExclusive
 81	}
 82
 83	cfg, err := loadConfig()
 84	if err != nil {
 85		return err
 86	}
 87
 88	if err := resolveTools(cfg); err != nil {
 89		return err
 90	}
 91
 92	token, err := client.GetToken()
 93	if err != nil {
 94		return fmt.Errorf("getting access token: %w", err)
 95	}
 96
 97	if token == "" {
 98		return errNoToken
 99	}
100
101	mcpServer := newMCPServer(cfg, token)
102
103	return runTransport(cmd, mcpServer, cfg)
104}
105
106func runTransport(cmd *cobra.Command, mcpServer *mcpsdk.Server, cfg *config.Config) error {
107	switch resolveTransport(cfg) {
108	case TransportStdio:
109		return runStdio(mcpServer)
110	case TransportSSE:
111		return runSSE(cmd, mcpServer, cfg)
112	case TransportHTTP:
113		return runHTTP(cmd, mcpServer, cfg)
114	default:
115		return errUnknownTransport
116	}
117}
118
119func loadConfig() (*config.Config, error) {
120	cfg, err := config.Load()
121	if err != nil {
122		if errors.Is(err, config.ErrNotFound) {
123			cfg = &config.Config{}
124		} else {
125			return nil, fmt.Errorf("loading config: %w", err)
126		}
127	}
128
129	cfg.MCP.MCPDefaults()
130
131	return cfg, nil
132}
133
134func resolveTransport(_ *config.Config) string {
135	if transport != "" {
136		return transport
137	}
138
139	return TransportStdio
140}
141
142func resolveHost(cfg *config.Config) string {
143	if host != "" {
144		return host
145	}
146
147	return cfg.MCP.Host
148}
149
150func resolvePort(cfg *config.Config) int {
151	if port != 0 {
152		return port
153	}
154
155	return cfg.MCP.Port
156}
157
158// validToolNames maps MCP tool names to their ToolsConfig field setters.
159var validToolNames = map[string]func(*config.ToolsConfig, bool){
160	timestamp.ToolName:      func(t *config.ToolsConfig, v bool) { t.GetTimestamp = v },
161	task.CreateToolName:     func(t *config.ToolsConfig, v bool) { t.CreateTask = v },
162	task.UpdateToolName:     func(t *config.ToolsConfig, v bool) { t.UpdateTask = v },
163	task.DeleteToolName:     func(t *config.ToolsConfig, v bool) { t.DeleteTask = v },
164	task.ListToolName:       func(t *config.ToolsConfig, v bool) { t.ListTasks = v },
165	task.ShowToolName:       func(t *config.ToolsConfig, v bool) { t.ShowTask = v },
166	note.CreateToolName:     func(t *config.ToolsConfig, v bool) { t.CreateNote = v },
167	note.UpdateToolName:     func(t *config.ToolsConfig, v bool) { t.UpdateNote = v },
168	note.DeleteToolName:     func(t *config.ToolsConfig, v bool) { t.DeleteNote = v },
169	note.ListToolName:       func(t *config.ToolsConfig, v bool) { t.ListNotes = v },
170	note.ShowToolName:       func(t *config.ToolsConfig, v bool) { t.ShowNote = v },
171	person.CreateToolName:   func(t *config.ToolsConfig, v bool) { t.CreatePerson = v },
172	person.UpdateToolName:   func(t *config.ToolsConfig, v bool) { t.UpdatePerson = v },
173	person.DeleteToolName:   func(t *config.ToolsConfig, v bool) { t.DeletePerson = v },
174	person.ListToolName:     func(t *config.ToolsConfig, v bool) { t.ListPeople = v },
175	person.TimelineToolName: func(t *config.ToolsConfig, v bool) { t.PersonTimeline = v },
176	habit.TrackToolName:     func(t *config.ToolsConfig, v bool) { t.TrackHabit = v },
177	journal.CreateToolName:  func(t *config.ToolsConfig, v bool) { t.CreateJournal = v },
178	// TODO: Add these once implemented:
179	// - show_person (ShowPerson)
180	// - list_habits (ListHabits)
181	// - list_areas (ListAreas) - needs config field
182	// - list_goals (ListGoals) - needs config field
183}
184
185// resolveTools modifies cfg.MCP.Tools based on CLI flags.
186// If --enabled-tools is set, only those tools are enabled.
187// If --disabled-tools is set, all tools except those are enabled.
188// If neither is set, config values are used unchanged.
189func resolveTools(cfg *config.Config) error {
190	if len(enabledTools) > 0 {
191		// Validate all tool names first
192		for _, name := range enabledTools {
193			if _, ok := validToolNames[name]; !ok {
194				return fmt.Errorf("%w: %s", errUnknownTool, name)
195			}
196		}
197
198		// Start with everything disabled
199		cfg.MCP.Tools = config.ToolsConfig{}
200
201		// Enable only specified tools
202		for _, name := range enabledTools {
203			validToolNames[name](&cfg.MCP.Tools, true)
204		}
205
206		return nil
207	}
208
209	if len(disabledTools) > 0 {
210		// Validate all tool names first
211		for _, name := range disabledTools {
212			if _, ok := validToolNames[name]; !ok {
213				return fmt.Errorf("%w: %s", errUnknownTool, name)
214			}
215		}
216
217		// Start with everything enabled
218		cfg.MCP.Tools = config.ToolsConfig{}
219		cfg.MCP.Tools.ApplyDefaults()
220
221		// Disable specified tools
222		for _, name := range disabledTools {
223			validToolNames[name](&cfg.MCP.Tools, false)
224		}
225
226		return nil
227	}
228
229	// Neither flag set, use config as-is
230	return nil
231}