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/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}