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