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}