1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package cmd
6
7import (
8 "context"
9 "errors"
10 "fmt"
11 "log"
12 "log/slog"
13 "net"
14 "net/http"
15 "strconv"
16
17 "github.com/modelcontextprotocol/go-sdk/mcp"
18 "github.com/spf13/cobra"
19
20 "git.secluded.site/lunatask-mcp-server/internal/client"
21 "git.secluded.site/lunatask-mcp-server/internal/config"
22 "git.secluded.site/lunatask-mcp-server/tools/areas"
23 "git.secluded.site/lunatask-mcp-server/tools/habits"
24 "git.secluded.site/lunatask-mcp-server/tools/shared"
25 "git.secluded.site/lunatask-mcp-server/tools/tasks"
26 "git.secluded.site/lunatask-mcp-server/tools/timestamp"
27)
28
29// Serve errors.
30var (
31 errNoConfig = errors.New("config file not found; run 'lunatask-mcp-server config'")
32 errNoAreas = errors.New("config must have at least one area; run 'lunatask-mcp-server config'")
33 errInvalidArea = errors.New("area must have both name and id")
34 errInvalidGoal = errors.New("goal must have both name and id")
35 errUnknownTransport = errors.New("unknown transport (valid: stdio, sse, http)")
36)
37
38// Transport modes for the MCP server.
39const (
40 TransportStdio = "stdio"
41 TransportSSE = "sse"
42 TransportHTTP = "http"
43)
44
45//nolint:exhaustruct // cobra only requires a subset of fields
46var serveCmd = &cobra.Command{
47 Use: "serve",
48 Short: "Start the MCP server",
49 Long: `Start the MCP server using the configured transport mode.
50
51Transport modes:
52 stdio Communicate over stdin/stdout (default, for CLI tools like Crush)
53 sse Server-Sent Events over HTTP (for Home Assistant)
54 http Streamable HTTP transport
55
56The server uses configuration from:
57 1. Config file (~/.config/lunatask-mcp-server/config.toml)
58 2. Access token from LUNATASK_ACCESS_TOKEN env var or system keyring
59
60Run 'lunatask-mcp-server config' to set up configuration interactively.`,
61 RunE: runServe,
62}
63
64var (
65 configPath string
66 transport string
67)
68
69func init() {
70 serveCmd.Flags().StringVarP(
71 &configPath, "config", "c", "",
72 "path to config file (default: ~/.config/lunatask-mcp-server/config.toml)",
73 )
74 serveCmd.Flags().StringVarP(
75 &transport, "transport", "t", "",
76 "transport mode: stdio, sse, http (overrides config)",
77 )
78}
79
80func runServe(_ *cobra.Command, _ []string) error {
81 cfg, err := loadServerConfig()
82 if err != nil {
83 return err
84 }
85
86 accessToken, source, err := client.GetToken()
87 if err != nil {
88 return err
89 }
90
91 slog.Debug("access token loaded", "source", source.String())
92
93 if err := validateServerConfig(cfg); err != nil {
94 return err
95 }
96
97 mcpServer := newMCPServer(cfg, accessToken)
98
99 effectiveTransport := cfg.Server.Transport
100 if transport != "" {
101 effectiveTransport = transport
102 }
103
104 switch effectiveTransport {
105 case TransportStdio:
106 return runStdio(mcpServer)
107 case TransportSSE:
108 return runSSE(mcpServer, cfg)
109 case TransportHTTP:
110 return runHTTP(mcpServer, cfg)
111 default:
112 return errUnknownTransport
113 }
114}
115
116func loadServerConfig() (*config.Config, error) {
117 var cfg *config.Config
118
119 var err error
120
121 if configPath != "" {
122 cfg, err = config.LoadFrom(configPath)
123 } else {
124 cfg, err = config.Load()
125 }
126
127 if err != nil {
128 if errors.Is(err, config.ErrNotFound) {
129 return nil, errNoConfig
130 }
131
132 return nil, err
133 }
134
135 return cfg, nil
136}
137
138func validateServerConfig(cfg *config.Config) error {
139 if len(cfg.Areas) == 0 {
140 return errNoAreas
141 }
142
143 for _, area := range cfg.Areas {
144 if area.Name == "" || area.ID == "" {
145 return errInvalidArea
146 }
147
148 for _, goal := range area.Goals {
149 if goal.Name == "" || goal.ID == "" {
150 return errInvalidGoal
151 }
152 }
153 }
154
155 if _, err := shared.LoadLocation(cfg.Timezone); err != nil {
156 return err
157 }
158
159 return nil
160}
161
162func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server {
163 mcpServer := mcp.NewServer(
164 &mcp.Implementation{
165 Name: "Lunatask MCP Server",
166 Version: version,
167 },
168 nil,
169 )
170
171 mcpServer.AddReceivingMiddleware(loggingMiddleware)
172
173 areaProviders := toAreaProviders(cfg.Areas)
174 habitProviders := toHabitProviders(cfg.Habits)
175
176 registerResources(mcpServer, areaProviders, habitProviders)
177 registerTimestampTool(mcpServer, cfg)
178 registerTaskTools(mcpServer, cfg, accessToken, areaProviders)
179 registerHabitTool(mcpServer, cfg, accessToken, habitProviders)
180
181 return mcpServer
182}
183
184func loggingMiddleware(next mcp.MethodHandler) mcp.MethodHandler {
185 return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
186 slog.Debug("request", "method", method)
187
188 result, err := next(ctx, method, req)
189 if err != nil {
190 slog.Error("request failed", "method", method, "error", err)
191 } else {
192 slog.Debug("request succeeded", "method", method)
193 }
194
195 return result, err
196 }
197}
198
199func runStdio(mcpServer *mcp.Server) error {
200 if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
201 return fmt.Errorf("stdio server error: %w", err)
202 }
203
204 return nil
205}
206
207func runSSE(mcpServer *mcp.Server, cfg *config.Config) error {
208 hostPort := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
209 handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
210 return mcpServer
211 }, nil)
212
213 log.Printf("SSE server listening on %s", hostPort)
214
215 if err := http.ListenAndServe(hostPort, handler); err != nil { //nolint:gosec // config-provided host:port
216 return fmt.Errorf("SSE server error: %w", err)
217 }
218
219 return nil
220}
221
222func runHTTP(mcpServer *mcp.Server, cfg *config.Config) error {
223 hostPort := net.JoinHostPort(cfg.Server.Host, strconv.Itoa(cfg.Server.Port))
224 handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
225 return mcpServer
226 }, nil)
227
228 log.Printf("HTTP server listening on %s", hostPort)
229
230 if err := http.ListenAndServe(hostPort, handler); err != nil { //nolint:gosec // config-provided host:port
231 return fmt.Errorf("HTTP server error: %w", err)
232 }
233
234 return nil
235}
236
237// configArea wraps config.Area to implement shared.AreaProvider.
238type configArea struct {
239 area config.Area
240}
241
242func (a configArea) GetName() string { return a.area.Name }
243func (a configArea) GetID() string { return a.area.ID }
244func (a configArea) GetKey() string { return a.area.Key }
245func (a configArea) GetGoals() []shared.GoalProvider {
246 providers := make([]shared.GoalProvider, len(a.area.Goals))
247 for i, g := range a.area.Goals {
248 providers[i] = configGoal{goal: g}
249 }
250
251 return providers
252}
253
254// configGoal wraps config.Goal to implement shared.GoalProvider.
255type configGoal struct {
256 goal config.Goal
257}
258
259func (g configGoal) GetName() string { return g.goal.Name }
260func (g configGoal) GetID() string { return g.goal.ID }
261func (g configGoal) GetKey() string { return g.goal.Key }
262
263// configHabit wraps config.Habit to implement shared.HabitProvider.
264type configHabit struct {
265 habit config.Habit
266}
267
268func (h configHabit) GetName() string { return h.habit.Name }
269func (h configHabit) GetID() string { return h.habit.ID }
270func (h configHabit) GetKey() string { return h.habit.Key }
271
272func toAreaProviders(configAreas []config.Area) []shared.AreaProvider {
273 providers := make([]shared.AreaProvider, len(configAreas))
274 for idx, area := range configAreas {
275 providers[idx] = configArea{area: area}
276 }
277
278 return providers
279}
280
281func toHabitProviders(configHabits []config.Habit) []shared.HabitProvider {
282 providers := make([]shared.HabitProvider, len(configHabits))
283 for idx, habit := range configHabits {
284 providers[idx] = configHabit{habit: habit}
285 }
286
287 return providers
288}
289
290func registerResources(
291 mcpServer *mcp.Server,
292 areaProviders []shared.AreaProvider,
293 habitProviders []shared.HabitProvider,
294) {
295 areasHandler := areas.NewHandler(areaProviders)
296 mcpServer.AddResource(&mcp.Resource{
297 Name: "areas",
298 URI: areas.ResourceURI,
299 Description: areas.ResourceDescription,
300 MIMEType: "application/json",
301 }, areasHandler.HandleRead)
302
303 habitsResourceHandler := habits.NewResourceHandler(habitProviders)
304 mcpServer.AddResource(&mcp.Resource{
305 Name: "habits",
306 URI: habits.ResourceURI,
307 Description: habits.ResourceDescription,
308 MIMEType: "application/json",
309 }, habitsResourceHandler.HandleRead)
310}
311
312func registerTimestampTool(mcpServer *mcp.Server, cfg *config.Config) {
313 if !cfg.Tools.GetTimestamp {
314 return
315 }
316
317 handler := timestamp.NewHandler(cfg.Timezone)
318
319 mcp.AddTool(mcpServer, &mcp.Tool{
320 Name: "get_timestamp",
321 Description: timestamp.ToolDescription,
322 }, handler.Handle)
323}
324
325func registerTaskTools(
326 mcpServer *mcp.Server,
327 cfg *config.Config,
328 accessToken string,
329 areaProviders []shared.AreaProvider,
330) {
331 if !cfg.Tools.CreateTask && !cfg.Tools.UpdateTask && !cfg.Tools.DeleteTask {
332 return
333 }
334
335 handler := tasks.NewHandler(accessToken, cfg.Timezone, areaProviders)
336
337 if cfg.Tools.CreateTask {
338 mcp.AddTool(mcpServer, &mcp.Tool{
339 Name: "create_task",
340 Description: tasks.CreateToolDescription,
341 }, handler.HandleCreate)
342 }
343
344 if cfg.Tools.UpdateTask {
345 mcp.AddTool(mcpServer, &mcp.Tool{
346 Name: "update_task",
347 Description: tasks.UpdateToolDescription,
348 }, handler.HandleUpdate)
349 }
350
351 if cfg.Tools.DeleteTask {
352 mcp.AddTool(mcpServer, &mcp.Tool{
353 Name: "delete_task",
354 Description: tasks.DeleteToolDescription,
355 }, handler.HandleDelete)
356 }
357}
358
359func registerHabitTool(
360 mcpServer *mcp.Server,
361 cfg *config.Config,
362 accessToken string,
363 habitProviders []shared.HabitProvider,
364) {
365 if !cfg.Tools.TrackHabitActivity {
366 return
367 }
368
369 handler := habits.NewHandler(accessToken, habitProviders)
370
371 mcp.AddTool(mcpServer, &mcp.Tool{
372 Name: "track_habit_activity",
373 Description: habits.TrackToolDescription,
374 }, handler.HandleTrack)
375}