1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package mcp
6
7import (
8 "context"
9 "fmt"
10 "net"
11 "net/http"
12 "strconv"
13
14 "git.secluded.site/lune/internal/config"
15 "git.secluded.site/lune/internal/mcp/resources/areas"
16 "git.secluded.site/lune/internal/mcp/resources/habits"
17 noters "git.secluded.site/lune/internal/mcp/resources/note"
18 "git.secluded.site/lune/internal/mcp/resources/notebooks"
19 "git.secluded.site/lune/internal/mcp/resources/notes"
20 "git.secluded.site/lune/internal/mcp/resources/people"
21 personrs "git.secluded.site/lune/internal/mcp/resources/person"
22 taskrs "git.secluded.site/lune/internal/mcp/resources/task"
23 "git.secluded.site/lune/internal/mcp/resources/tasks"
24 "git.secluded.site/lune/internal/mcp/shared"
25 "git.secluded.site/lune/internal/mcp/tools/crud"
26 "git.secluded.site/lune/internal/mcp/tools/habit"
27 "git.secluded.site/lune/internal/mcp/tools/timeline"
28 "git.secluded.site/lune/internal/mcp/tools/timestamp"
29 "github.com/modelcontextprotocol/go-sdk/mcp"
30 "github.com/spf13/cobra"
31)
32
33var version = "dev"
34
35func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server {
36 mcpServer := mcp.NewServer(
37 &mcp.Implementation{
38 Name: "lune",
39 Version: version,
40 },
41 nil,
42 )
43
44 areaProviders := toAreaProviders(cfg.Areas)
45 habitProviders := shared.ToHabitProviders(cfg.Habits)
46 notebookProviders := shared.ToNotebookProviders(cfg.Notebooks)
47
48 registerResources(mcpServer, accessToken, areaProviders, habitProviders, notebookProviders)
49 registerResourceTemplates(mcpServer, accessToken, areaProviders, notebookProviders)
50 registerTools(mcpServer, cfg, accessToken, areaProviders, habitProviders, notebookProviders)
51
52 return mcpServer
53}
54
55func toAreaProviders(cfgAreas []config.Area) []shared.AreaProvider {
56 providers := make([]shared.AreaProvider, 0, len(cfgAreas))
57
58 for _, area := range cfgAreas {
59 providers = append(providers, shared.AreaProvider{
60 ID: area.ID,
61 Name: area.Name,
62 Key: area.Key,
63 Workflow: area.Workflow,
64 Goals: shared.ToGoalProviders(area.Goals),
65 })
66 }
67
68 return providers
69}
70
71func registerResources(
72 mcpServer *mcp.Server,
73 accessToken string,
74 areaProviders []shared.AreaProvider,
75 habitProviders []shared.HabitProvider,
76 notebookProviders []shared.NotebookProvider,
77) {
78 areasHandler := areas.NewHandler(areaProviders)
79 mcpServer.AddResource(&mcp.Resource{
80 Name: "areas",
81 URI: areas.ResourceURI,
82 Description: areas.ResourceDescription,
83 MIMEType: "application/json",
84 }, areasHandler.HandleRead)
85
86 habitsHandler := habits.NewHandler(habitProviders)
87 mcpServer.AddResource(&mcp.Resource{
88 Name: "habits",
89 URI: habits.ResourceURI,
90 Description: habits.ResourceDescription,
91 MIMEType: "application/json",
92 }, habitsHandler.HandleRead)
93
94 notebooksHandler := notebooks.NewHandler(notebookProviders)
95 mcpServer.AddResource(&mcp.Resource{
96 Name: "notebooks",
97 URI: notebooks.ResourceURI,
98 Description: notebooks.ResourceDescription,
99 MIMEType: "application/json",
100 }, notebooksHandler.HandleRead)
101
102 registerTaskListResources(mcpServer, accessToken, areaProviders)
103 registerNoteListResources(mcpServer, accessToken, notebookProviders)
104 registerPeopleListResources(mcpServer, accessToken)
105}
106
107func registerResourceTemplates(
108 mcpServer *mcp.Server,
109 accessToken string,
110 areaProviders []shared.AreaProvider,
111 notebookProviders []shared.NotebookProvider,
112) {
113 taskHandler := taskrs.NewHandler(accessToken)
114 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
115 Name: "task",
116 URITemplate: taskrs.ResourceTemplate,
117 Description: taskrs.ResourceDescription,
118 MIMEType: "application/json",
119 }, taskHandler.HandleRead)
120
121 noteHandler := noters.NewHandler(accessToken)
122 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
123 Name: "note",
124 URITemplate: noters.ResourceTemplate,
125 Description: noters.ResourceDescription,
126 MIMEType: "application/json",
127 }, noteHandler.HandleRead)
128
129 personHandler := personrs.NewHandler(accessToken)
130 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
131 Name: "person",
132 URITemplate: personrs.ResourceTemplate,
133 Description: personrs.ResourceDescription,
134 MIMEType: "application/json",
135 }, personHandler.HandleRead)
136
137 registerAreaTaskTemplates(mcpServer, accessToken, areaProviders)
138 registerNotebookNoteTemplates(mcpServer, accessToken, notebookProviders)
139}
140
141func registerTaskListResources(
142 mcpServer *mcp.Server,
143 accessToken string,
144 areaProviders []shared.AreaProvider,
145) {
146 handler := tasks.NewHandler(accessToken, areaProviders)
147
148 mcpServer.AddResource(&mcp.Resource{
149 Name: "tasks-all",
150 URI: tasks.ResourceURIAll,
151 Description: tasks.AllDescription,
152 MIMEType: "application/json",
153 }, handler.HandleReadAll)
154
155 mcpServer.AddResource(&mcp.Resource{
156 Name: "tasks-today",
157 URI: tasks.ResourceURIToday,
158 Description: tasks.TodayDescription,
159 MIMEType: "application/json",
160 }, handler.HandleReadToday)
161
162 mcpServer.AddResource(&mcp.Resource{
163 Name: "tasks-overdue",
164 URI: tasks.ResourceURIOverdue,
165 Description: tasks.OverdueDescription,
166 MIMEType: "application/json",
167 }, handler.HandleReadOverdue)
168
169 mcpServer.AddResource(&mcp.Resource{
170 Name: "tasks-next-7-days",
171 URI: tasks.ResourceURINext7Days,
172 Description: tasks.Next7DaysDescription,
173 MIMEType: "application/json",
174 }, handler.HandleReadNext7Days)
175
176 mcpServer.AddResource(&mcp.Resource{
177 Name: "tasks-high-priority",
178 URI: tasks.ResourceURIHighPriority,
179 Description: tasks.HighPriorityDescription,
180 MIMEType: "application/json",
181 }, handler.HandleReadHighPriority)
182
183 mcpServer.AddResource(&mcp.Resource{
184 Name: "tasks-now",
185 URI: tasks.ResourceURINow,
186 Description: tasks.NowDescription,
187 MIMEType: "application/json",
188 }, handler.HandleReadNow)
189
190 mcpServer.AddResource(&mcp.Resource{
191 Name: "tasks-recent-completions",
192 URI: tasks.ResourceURIRecentCompletions,
193 Description: tasks.RecentCompletionsDescription,
194 MIMEType: "application/json",
195 }, handler.HandleReadRecentCompletions)
196}
197
198func registerAreaTaskTemplates(
199 mcpServer *mcp.Server,
200 accessToken string,
201 areaProviders []shared.AreaProvider,
202) {
203 handler := tasks.NewHandler(accessToken, areaProviders)
204
205 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
206 Name: "area-tasks",
207 URITemplate: tasks.AreaTasksTemplate,
208 Description: tasks.AreaTasksDescription,
209 MIMEType: "application/json",
210 }, handler.HandleReadAreaTasks)
211
212 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
213 Name: "area-tasks-today",
214 URITemplate: tasks.AreaTodayTemplate,
215 Description: tasks.AreaFilteredDescription,
216 MIMEType: "application/json",
217 }, handler.HandleReadAreaTasks)
218
219 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
220 Name: "area-tasks-overdue",
221 URITemplate: tasks.AreaOverdueTemplate,
222 Description: tasks.AreaFilteredDescription,
223 MIMEType: "application/json",
224 }, handler.HandleReadAreaTasks)
225
226 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
227 Name: "area-tasks-next-7-days",
228 URITemplate: tasks.AreaNext7DaysTemplate,
229 Description: tasks.AreaFilteredDescription,
230 MIMEType: "application/json",
231 }, handler.HandleReadAreaTasks)
232
233 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
234 Name: "area-tasks-high-priority",
235 URITemplate: tasks.AreaHighPriorityTemplate,
236 Description: tasks.AreaFilteredDescription,
237 MIMEType: "application/json",
238 }, handler.HandleReadAreaTasks)
239
240 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
241 Name: "area-tasks-now",
242 URITemplate: tasks.AreaNowTemplate,
243 Description: tasks.AreaFilteredDescription,
244 MIMEType: "application/json",
245 }, handler.HandleReadAreaTasks)
246
247 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
248 Name: "area-tasks-recent-completions",
249 URITemplate: tasks.AreaRecentCompletionsTempl,
250 Description: tasks.AreaFilteredDescription,
251 MIMEType: "application/json",
252 }, handler.HandleReadAreaTasks)
253}
254
255func registerNoteListResources(
256 mcpServer *mcp.Server,
257 accessToken string,
258 notebookProviders []shared.NotebookProvider,
259) {
260 handler := notes.NewHandler(accessToken, notebookProviders)
261
262 mcpServer.AddResource(&mcp.Resource{
263 Name: "notes-all",
264 URI: notes.ResourceURIAll,
265 Description: notes.AllDescription,
266 MIMEType: "application/json",
267 }, handler.HandleReadAll)
268
269 mcpServer.AddResource(&mcp.Resource{
270 Name: "notes-pinned",
271 URI: notes.ResourceURIPinned,
272 Description: notes.PinnedDescription,
273 MIMEType: "application/json",
274 }, handler.HandleReadPinned)
275
276 mcpServer.AddResource(&mcp.Resource{
277 Name: "notes-recent",
278 URI: notes.ResourceURIRecent,
279 Description: notes.RecentDescription,
280 MIMEType: "application/json",
281 }, handler.HandleReadRecent)
282}
283
284func registerPeopleListResources(
285 mcpServer *mcp.Server,
286 accessToken string,
287) {
288 handler := people.NewHandler(accessToken)
289
290 mcpServer.AddResource(&mcp.Resource{
291 Name: "people-all",
292 URI: people.ResourceURIAll,
293 Description: people.AllDescription,
294 MIMEType: "application/json",
295 }, handler.HandleReadAll)
296
297 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
298 Name: "people-by-relationship",
299 URITemplate: people.RelationshipTemplate,
300 Description: people.RelationshipDescription,
301 MIMEType: "application/json",
302 }, handler.HandleReadByRelationship)
303}
304
305func registerNotebookNoteTemplates(
306 mcpServer *mcp.Server,
307 accessToken string,
308 notebookProviders []shared.NotebookProvider,
309) {
310 handler := notes.NewHandler(accessToken, notebookProviders)
311
312 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
313 Name: "notebook-notes",
314 URITemplate: notes.NotebookNotesTemplate,
315 Description: notes.NotebookNotesDescription,
316 MIMEType: "application/json",
317 }, handler.HandleReadNotebookNotes)
318
319 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
320 Name: "notebook-notes-pinned",
321 URITemplate: notes.NotebookNotesPinnedTemplate,
322 Description: notes.NotebookFilteredDescription,
323 MIMEType: "application/json",
324 }, handler.HandleReadNotebookNotes)
325
326 mcpServer.AddResourceTemplate(&mcp.ResourceTemplate{
327 Name: "notebook-notes-recent",
328 URITemplate: notes.NotebookNotesRecentTemplate,
329 Description: notes.NotebookFilteredDescription,
330 MIMEType: "application/json",
331 }, handler.HandleReadNotebookNotes)
332}
333
334func registerTools(
335 mcpServer *mcp.Server,
336 cfg *config.Config,
337 accessToken string,
338 areaProviders []shared.AreaProvider,
339 habitProviders []shared.HabitProvider,
340 notebookProviders []shared.NotebookProvider,
341) {
342 tools := &cfg.MCP.Tools
343
344 if tools.GetTimestamp {
345 tsHandler := timestamp.NewHandler(cfg.MCP.Timezone)
346 mcp.AddTool(mcpServer, &mcp.Tool{
347 Name: timestamp.ToolName,
348 Description: timestamp.ToolDescription,
349 }, tsHandler.Handle)
350 }
351
352 if tools.AddTimelineNote {
353 timelineHandler := timeline.NewHandler(accessToken)
354 mcp.AddTool(mcpServer, &mcp.Tool{
355 Name: timeline.ToolName,
356 Description: timeline.ToolDescription,
357 }, timelineHandler.Handle)
358 }
359
360 if tools.TrackHabit {
361 habitHandler := habit.NewHandler(accessToken, habitProviders)
362 mcp.AddTool(mcpServer, &mcp.Tool{
363 Name: habit.TrackToolName,
364 Description: habit.TrackToolDescription,
365 }, habitHandler.HandleTrack)
366 }
367
368 registerCRUDTools(mcpServer, cfg, tools, accessToken, areaProviders, habitProviders, notebookProviders)
369}
370
371func registerCRUDTools(
372 mcpServer *mcp.Server,
373 cfg *config.Config,
374 tools *config.ToolsConfig,
375 accessToken string,
376 areaProviders []shared.AreaProvider,
377 habitProviders []shared.HabitProvider,
378 notebookProviders []shared.NotebookProvider,
379) {
380 if !tools.Create && !tools.Update && !tools.Delete && !tools.Query {
381 return
382 }
383
384 crudHandler := crud.NewHandler(accessToken, cfg, areaProviders, habitProviders, notebookProviders)
385
386 if tools.Create {
387 mcp.AddTool(mcpServer, &mcp.Tool{
388 Name: crud.CreateToolName,
389 Description: crud.CreateToolDescription,
390 }, crudHandler.HandleCreate)
391 }
392
393 if tools.Update {
394 mcp.AddTool(mcpServer, &mcp.Tool{
395 Name: crud.UpdateToolName,
396 Description: crud.UpdateToolDescription,
397 }, crudHandler.HandleUpdate)
398 }
399
400 if tools.Delete {
401 mcp.AddTool(mcpServer, &mcp.Tool{
402 Name: crud.DeleteToolName,
403 Description: crud.DeleteToolDescription,
404 }, crudHandler.HandleDelete)
405 }
406
407 if tools.Query {
408 mcp.AddTool(mcpServer, &mcp.Tool{
409 Name: crud.QueryToolName,
410 Description: crud.QueryToolDescription,
411 }, crudHandler.HandleQuery)
412 }
413}
414
415func runStdio(mcpServer *mcp.Server) error {
416 if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
417 return fmt.Errorf("stdio server error: %w", err)
418 }
419
420 return nil
421}
422
423func runSSE(cmd *cobra.Command, mcpServer *mcp.Server, cfg *config.Config) error {
424 hostPort := net.JoinHostPort(resolveHost(cfg), strconv.Itoa(resolvePort(cfg)))
425 handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
426 return mcpServer
427 }, nil)
428
429 fmt.Fprintf(cmd.OutOrStdout(), "SSE server listening on %s\n", hostPort)
430
431 //nolint:gosec // MCP SDK controls server lifecycle; timeouts not applicable
432 if err := http.ListenAndServe(hostPort, handler); err != nil {
433 return fmt.Errorf("SSE server error: %w", err)
434 }
435
436 return nil
437}
438
439func runHTTP(cmd *cobra.Command, mcpServer *mcp.Server, cfg *config.Config) error {
440 hostPort := net.JoinHostPort(resolveHost(cfg), strconv.Itoa(resolvePort(cfg)))
441 handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
442 return mcpServer
443 }, nil)
444
445 fmt.Fprintf(cmd.OutOrStdout(), "HTTP server listening on %s\n", hostPort)
446
447 //nolint:gosec // MCP SDK controls server lifecycle; timeouts not applicable
448 if err := http.ListenAndServe(hostPort, handler); err != nil {
449 return fmt.Errorf("HTTP server error: %w", err)
450 }
451
452 return nil
453}