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