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 "git.secluded.site/lune/internal/mcp/resources/notebooks"
18 "git.secluded.site/lune/internal/mcp/shared"
19 "git.secluded.site/lune/internal/mcp/tools/habit"
20 "git.secluded.site/lune/internal/mcp/tools/task"
21 "git.secluded.site/lune/internal/mcp/tools/timestamp"
22 "github.com/modelcontextprotocol/go-sdk/mcp"
23 "github.com/spf13/cobra"
24)
25
26var version = "dev"
27
28func newMCPServer(cfg *config.Config, accessToken string) *mcp.Server {
29 mcpServer := mcp.NewServer(
30 &mcp.Implementation{
31 Name: "lune",
32 Version: version,
33 },
34 nil,
35 )
36
37 areaProviders := toAreaProviders(cfg.Areas)
38 habitProviders := shared.ToHabitProviders(cfg.Habits)
39 notebookProviders := shared.ToNotebookProviders(cfg.Notebooks)
40
41 registerResources(mcpServer, areaProviders, habitProviders, notebookProviders)
42 registerTools(mcpServer, cfg, accessToken, areaProviders, habitProviders)
43
44 return mcpServer
45}
46
47func toAreaProviders(cfgAreas []config.Area) []shared.AreaProvider {
48 providers := make([]shared.AreaProvider, 0, len(cfgAreas))
49
50 for _, area := range cfgAreas {
51 providers = append(providers, shared.AreaProvider{
52 ID: area.ID,
53 Name: area.Name,
54 Key: area.Key,
55 Goals: shared.ToGoalProviders(area.Goals),
56 })
57 }
58
59 return providers
60}
61
62func registerResources(
63 mcpServer *mcp.Server,
64 areaProviders []shared.AreaProvider,
65 habitProviders []shared.HabitProvider,
66 notebookProviders []shared.NotebookProvider,
67) {
68 areasHandler := areas.NewHandler(areaProviders)
69 mcpServer.AddResource(&mcp.Resource{
70 Name: "areas",
71 URI: areas.ResourceURI,
72 Description: areas.ResourceDescription,
73 MIMEType: "application/json",
74 }, areasHandler.HandleRead)
75
76 habitsHandler := habits.NewHandler(habitProviders)
77 mcpServer.AddResource(&mcp.Resource{
78 Name: "habits",
79 URI: habits.ResourceURI,
80 Description: habits.ResourceDescription,
81 MIMEType: "application/json",
82 }, habitsHandler.HandleRead)
83
84 notebooksHandler := notebooks.NewHandler(notebookProviders)
85 mcpServer.AddResource(&mcp.Resource{
86 Name: "notebooks",
87 URI: notebooks.ResourceURI,
88 Description: notebooks.ResourceDescription,
89 MIMEType: "application/json",
90 }, notebooksHandler.HandleRead)
91}
92
93func registerTools(
94 mcpServer *mcp.Server,
95 cfg *config.Config,
96 accessToken string,
97 areaProviders []shared.AreaProvider,
98 habitProviders []shared.HabitProvider,
99) {
100 tools := &cfg.MCP.Tools
101
102 if tools.GetTimestamp {
103 tsHandler := timestamp.NewHandler(cfg.MCP.Timezone)
104 mcp.AddTool(mcpServer, &mcp.Tool{
105 Name: timestamp.ToolName,
106 Description: timestamp.ToolDescription,
107 }, tsHandler.Handle)
108 }
109
110 taskHandler := task.NewHandler(accessToken, areaProviders)
111
112 if tools.CreateTask {
113 mcp.AddTool(mcpServer, &mcp.Tool{
114 Name: task.CreateToolName,
115 Description: task.CreateToolDescription,
116 }, taskHandler.HandleCreate)
117 }
118
119 if tools.UpdateTask {
120 mcp.AddTool(mcpServer, &mcp.Tool{
121 Name: task.UpdateToolName,
122 Description: task.UpdateToolDescription,
123 }, taskHandler.HandleUpdate)
124 }
125
126 if tools.DeleteTask {
127 mcp.AddTool(mcpServer, &mcp.Tool{
128 Name: task.DeleteToolName,
129 Description: task.DeleteToolDescription,
130 }, taskHandler.HandleDelete)
131 }
132
133 if tools.ListTasks {
134 mcp.AddTool(mcpServer, &mcp.Tool{
135 Name: task.ListToolName,
136 Description: task.ListToolDescription,
137 }, taskHandler.HandleList)
138 }
139
140 if tools.ShowTask {
141 mcp.AddTool(mcpServer, &mcp.Tool{
142 Name: task.ShowToolName,
143 Description: task.ShowToolDescription,
144 }, taskHandler.HandleShow)
145 }
146
147 if tools.TrackHabit {
148 habitHandler := habit.NewHandler(accessToken, habitProviders)
149 mcp.AddTool(mcpServer, &mcp.Tool{
150 Name: habit.TrackToolName,
151 Description: habit.TrackToolDescription,
152 }, habitHandler.HandleTrack)
153 }
154}
155
156func runStdio(mcpServer *mcp.Server) error {
157 if err := mcpServer.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
158 return fmt.Errorf("stdio server error: %w", err)
159 }
160
161 return nil
162}
163
164func runSSE(cmd *cobra.Command, mcpServer *mcp.Server, cfg *config.Config) error {
165 hostPort := net.JoinHostPort(resolveHost(cfg), strconv.Itoa(resolvePort(cfg)))
166 handler := mcp.NewSSEHandler(func(_ *http.Request) *mcp.Server {
167 return mcpServer
168 }, nil)
169
170 fmt.Fprintf(cmd.OutOrStdout(), "SSE server listening on %s\n", hostPort)
171
172 //nolint:gosec // MCP SDK controls server lifecycle; timeouts not applicable
173 if err := http.ListenAndServe(hostPort, handler); err != nil {
174 return fmt.Errorf("SSE server error: %w", err)
175 }
176
177 return nil
178}
179
180func runHTTP(cmd *cobra.Command, mcpServer *mcp.Server, cfg *config.Config) error {
181 hostPort := net.JoinHostPort(resolveHost(cfg), strconv.Itoa(resolvePort(cfg)))
182 handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server {
183 return mcpServer
184 }, nil)
185
186 fmt.Fprintf(cmd.OutOrStdout(), "HTTP server listening on %s\n", hostPort)
187
188 //nolint:gosec // MCP SDK controls server lifecycle; timeouts not applicable
189 if err := http.ListenAndServe(hostPort, handler); err != nil {
190 return fmt.Errorf("HTTP server error: %w", err)
191 }
192
193 return nil
194}