serve.go

  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}