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