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	"os"
 12
 13	"git.secluded.site/lune/internal/client"
 14	"git.secluded.site/lune/internal/config"
 15	"git.secluded.site/lune/internal/mcp/tools/crud"
 16	"git.secluded.site/lune/internal/mcp/tools/habit"
 17	"git.secluded.site/lune/internal/mcp/tools/timeline"
 18	"git.secluded.site/lune/internal/mcp/tools/timestamp"
 19	mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
 20	"github.com/spf13/cobra"
 21)
 22
 23// Transport constants.
 24const (
 25	TransportStdio = "stdio"
 26	TransportSSE   = "sse"
 27	TransportHTTP  = "http"
 28)
 29
 30var (
 31	errUnknownTransport  = errors.New("unknown transport; use stdio, sse, or http")
 32	errNoToken           = errors.New("no access token; run 'lune init' first")
 33	errMutuallyExclusive = errors.New("--enabled-tools and --disabled-tools are mutually exclusive")
 34	errUnknownTool       = errors.New("unknown tool name")
 35)
 36
 37var (
 38	transport     string
 39	host          string
 40	port          int
 41	enabledTools  []string
 42	disabledTools []string
 43)
 44
 45// Cmd is the mcp command for starting the MCP server.
 46var Cmd = &cobra.Command{
 47	Use:   "mcp",
 48	Short: "Start the MCP server",
 49	Long: `Start a Model Context Protocol server for LLM tool integration.
 50
 51The MCP server exposes Lunatask resources and tools that can be used by
 52LLM assistants (like Claude) to interact with your Lunatask data.
 53
 54Transports:
 55  stdio  - Standard input/output (default, for local integrations)
 56  sse    - Server-sent events over HTTP
 57  http   - Streamable HTTP
 58
 59Examples:
 60  lune mcp                    # Start with stdio (default)
 61  lune mcp -t sse             # Start SSE server on configured host:port
 62  lune mcp -t sse --port 9000 # Override port`,
 63	RunE: runMCP,
 64}
 65
 66func init() {
 67	Cmd.Flags().StringVarP(&transport, "transport", "t", "",
 68		"Transport type: stdio, sse, http (default: stdio or config)")
 69	Cmd.Flags().StringVar(&host, "host", "", "Server host (for sse/http)")
 70	Cmd.Flags().IntVar(&port, "port", 0, "Server port (for sse/http)")
 71	Cmd.Flags().StringSliceVar(&enabledTools, "enabled-tools", nil,
 72		"Enable only these tools (comma-separated); overrides config")
 73	Cmd.Flags().StringSliceVar(&disabledTools, "disabled-tools", nil,
 74		"Disable these tools (comma-separated); overrides config")
 75}
 76
 77func runMCP(cmd *cobra.Command, _ []string) error {
 78	if len(enabledTools) > 0 && len(disabledTools) > 0 {
 79		return errMutuallyExclusive
 80	}
 81
 82	cfg, err := loadConfig()
 83	if err != nil {
 84		return err
 85	}
 86
 87	if err := resolveTools(cfg); err != nil {
 88		return err
 89	}
 90
 91	// Try keyring first, fall back to env var if keyring fails or is empty
 92	token, _ := client.GetToken() // ignore keyring errors (e.g., no dbus in containers)
 93	if token == "" {
 94		token = os.Getenv("LUNE_ACCESS_TOKEN")
 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	timeline.ToolName:   func(t *config.ToolsConfig, v bool) { t.AddTimelineNote = v },
162	habit.TrackToolName: func(t *config.ToolsConfig, v bool) { t.TrackHabit = v },
163	crud.CreateToolName: func(t *config.ToolsConfig, v bool) { t.Create = v },
164	crud.UpdateToolName: func(t *config.ToolsConfig, v bool) { t.Update = v },
165	crud.DeleteToolName: func(t *config.ToolsConfig, v bool) { t.Delete = v },
166	crud.QueryToolName:  func(t *config.ToolsConfig, v bool) { t.Query = v },
167}
168
169// resolveTools modifies cfg.MCP.Tools based on CLI flags.
170// If --enabled-tools is set, only those tools are enabled.
171// If --disabled-tools is set, all tools except those are enabled.
172// If neither is set, config values are used unchanged.
173func resolveTools(cfg *config.Config) error {
174	if len(enabledTools) > 0 {
175		// Validate all tool names first
176		for _, name := range enabledTools {
177			if _, ok := validToolNames[name]; !ok {
178				return fmt.Errorf("%w: %s", errUnknownTool, name)
179			}
180		}
181
182		// Start with everything disabled
183		cfg.MCP.Tools = config.ToolsConfig{}
184
185		// Enable only specified tools
186		for _, name := range enabledTools {
187			validToolNames[name](&cfg.MCP.Tools, true)
188		}
189
190		return nil
191	}
192
193	if len(disabledTools) > 0 {
194		// Validate all tool names first
195		for _, name := range disabledTools {
196			if _, ok := validToolNames[name]; !ok {
197				return fmt.Errorf("%w: %s", errUnknownTool, name)
198			}
199		}
200
201		// Start with everything enabled
202		cfg.MCP.Tools = config.ToolsConfig{}
203		cfg.MCP.Tools.ApplyDefaults()
204
205		// Disable specified tools
206		for _, name := range disabledTools {
207			validToolNames[name](&cfg.MCP.Tools, false)
208		}
209
210		return nil
211	}
212
213	// Neither flag set, use config as-is
214	return nil
215}