server.go

  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}