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}