1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package main
6
7import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "log"
15 "net/http"
16 "os"
17 "strings"
18 "time"
19
20 "github.com/go-playground/validator/v10"
21 "github.com/ijt/go-anytime"
22
23 "github.com/BurntSushi/toml"
24 "github.com/mark3labs/mcp-go/mcp"
25 "github.com/mark3labs/mcp-go/server"
26)
27
28// Goal represents a Lunatask goal with its name and ID
29type Goal struct {
30 Name string `toml:"name"`
31 ID string `toml:"id"`
32}
33
34// Area represents a Lunatask area with its name, ID, and its goals
35type Area struct {
36 Name string `toml:"name"`
37 ID string `toml:"id"`
38 Goals []Goal `toml:"goals"`
39}
40
41// Config holds the application's configuration loaded from TOML
42type ServerConfig struct {
43 Host string `toml:"host"`
44 Port int `toml:"port"`
45}
46
47type Config struct {
48 AccessToken string `toml:"access_token"`
49 Areas []Area `toml:"areas"`
50 Server ServerConfig `toml:"server"`
51}
52
53func main() {
54 configPath := "./config.toml"
55 for i, arg := range os.Args {
56 if arg == "-c" || arg == "--config" {
57 if i+1 < len(os.Args) {
58 configPath = os.Args[i+1]
59 }
60 }
61 }
62
63 if _, err := os.Stat(configPath); os.IsNotExist(err) {
64 createDefaultConfigFile(configPath)
65 }
66
67 var config Config
68 if _, err := toml.DecodeFile(configPath, &config); err != nil {
69 log.Fatalf("Failed to load config file %s: %v", configPath, err)
70 }
71
72 if config.AccessToken == "" || len(config.Areas) == 0 {
73 log.Fatalf("Config file must provide access_token and at least one area.")
74 }
75
76 for i, area := range config.Areas {
77 if area.Name == "" || area.ID == "" {
78 log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
79 }
80 for j, goal := range area.Goals {
81 if goal.Name == "" || goal.ID == "" {
82 log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
83 }
84 }
85 }
86
87 mcpServer := NewMCPServer(&config)
88
89 baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
90 sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
91 listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
92 log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
93 if err := sseServer.Start(listenAddr); err != nil {
94 log.Fatalf("Server error: %v", err)
95 }
96}
97
98func NewMCPServer(config *Config) *server.MCPServer {
99 hooks := &server.Hooks{}
100
101 hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
102 fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
103 })
104 hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
105 fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
106 })
107 hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
108 fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
109 })
110 hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
111 fmt.Printf("beforeInitialize: %v, %v\n", id, message)
112 })
113 hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
114 fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
115 })
116 hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
117 fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
118 })
119 hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
120 fmt.Printf("beforeCallTool: %v, %v\n", id, message)
121 })
122
123 mcpServer := server.NewMCPServer(
124 "Lunatask MCP Server",
125 "0.1.0",
126 server.WithHooks(hooks),
127 )
128
129 mcpServer.AddTool(mcp.NewTool("get_task_timestamp",
130 mcp.WithDescription("Retrieves the formatted timestamp for a task"),
131 mcp.WithString("natural_language_date",
132 mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."),
133 mcp.Required(),
134 ),
135 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
136 natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
137 if !ok || natLangDate == "" {
138 return reportMCPError("Missing or invalid required argument: natural_language_date")
139 }
140 parsedTime, err := anytime.Parse(natLangDate, time.Now())
141 if err != nil {
142 return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
143 }
144 return &mcp.CallToolResult{
145 Content: []mcp.Content{
146 mcp.TextContent{
147 Type: "text",
148 Text: parsedTime.Format(time.RFC3339),
149 },
150 },
151 }, nil
152 })
153
154 mcpServer.AddTool(
155 mcp.NewTool(
156 "list_areas_and_goals",
157 mcp.WithDescription("List areas and goals and their IDs."),
158 ),
159 func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
160 var b strings.Builder
161 for _, area := range config.Areas {
162 fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
163 for _, goal := range area.Goals {
164 fmt.Fprintf(&b, " - %s: %s\n", goal.Name, goal.ID)
165 }
166 }
167 return &mcp.CallToolResult{
168 Content: []mcp.Content{
169 mcp.TextContent{
170 Type: "text",
171 Text: b.String(),
172 },
173 },
174 }, nil
175 },
176 )
177
178 mcpServer.AddTool(mcp.NewTool("create_task",
179 mcp.WithDescription("Creates a new task"),
180 mcp.WithString("area_id",
181 mcp.Description("ID of the area in which to create the task"),
182 mcp.Required(),
183 ),
184 mcp.WithString("goal_id",
185 mcp.Description("ID of the goal, which must belong to the specified area, that the task should be associated with."),
186 ),
187 mcp.WithString("name",
188 mcp.Description("Plain text task name using sentence case."),
189 mcp.Required(),
190 ),
191 mcp.WithString("note",
192 mcp.Description("Note attached to the task, optionally Markdown-formatted"),
193 ),
194 mcp.WithNumber("estimate",
195 mcp.Description("Estimated time to completion in minutes"),
196 ),
197 mcp.WithString("scheduled_on",
198 mcp.Description("Formatted timestamp"),
199 ),
200 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
201 return handleCreateTask(ctx, request, config)
202 })
203
204 return mcpServer
205}
206
207// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
208type LunataskCreateTaskRequest struct {
209 AreaID string `json:"area_id"`
210 GoalID string `json:"goal_id,omitempty" validate:"omitempty"`
211 Name string `json:"name" validate:"max=100"`
212 Note string `json:"note,omitempty" validate:"omitempty"`
213 Status string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
214 Motivation string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
215 Estimate int `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
216 Priority int `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
217 ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
218 CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
219 Source string `json:"source,omitempty" validate:"omitempty"`
220}
221
222// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
223type LunataskCreateTaskResponse struct {
224 Task struct {
225 ID string `json:"id"`
226 } `json:"task"`
227}
228
229func reportMCPError(msg string) (*mcp.CallToolResult, error) {
230 return &mcp.CallToolResult{
231 IsError: true,
232 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
233 }, nil
234}
235
236// handleCreateTask handles the creation of a task in Lunatask
237func handleCreateTask(
238 ctx context.Context,
239 request mcp.CallToolRequest,
240 config *Config,
241) (*mcp.CallToolResult, error) {
242 arguments := request.Params.Arguments
243
244 areaID, ok := arguments["area_id"].(string)
245 if !ok || areaID == "" {
246 return reportMCPError("Missing or invalid required argument: area_id")
247 }
248
249 var area *Area
250 for i := range config.Areas {
251 if config.Areas[i].ID == areaID {
252 area = &config.Areas[i]
253 break
254 }
255 }
256 if area == nil {
257 return reportMCPError("Area not found for given area_id")
258 }
259
260 if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
261 found := false
262 for _, goal := range area.Goals {
263 if goal.ID == goalID {
264 found = true
265 break
266 }
267 }
268 if !found {
269 return reportMCPError("Goal not found in specified area for given goal_id")
270 }
271 }
272
273 var payload LunataskCreateTaskRequest
274 argBytes, err := json.Marshal(arguments)
275 if err != nil {
276 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
277 }
278 if err := json.Unmarshal(argBytes, &payload); err != nil {
279 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
280 }
281
282 validate := validator.New()
283 if err := validate.Struct(payload); err != nil {
284 var invalidValidationError *validator.InvalidValidationError
285 if errors.As(err, &invalidValidationError) {
286 return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
287 }
288 var validationErrs validator.ValidationErrors
289 if errors.As(err, &validationErrs) {
290 var msgBuilder strings.Builder
291 msgBuilder.WriteString("task validation failed:")
292 for _, e := range validationErrs {
293 fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
294 }
295 return reportMCPError(msgBuilder.String())
296 }
297 return reportMCPError(fmt.Sprintf("Validation error: %v", err))
298 }
299
300 payloadBytes, err := json.Marshal(payload)
301 if err != nil {
302 return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
303 }
304
305 req, err := http.NewRequestWithContext(
306 ctx,
307 "POST",
308 "https://api.lunatask.app/v1/tasks",
309 bytes.NewBuffer(payloadBytes),
310 )
311 if err != nil {
312 return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
313 }
314
315 req.Header.Set("Content-Type", "application/json")
316 req.Header.Set("Authorization", "bearer "+config.AccessToken)
317
318 client := &http.Client{}
319 resp, err := client.Do(req)
320 if err != nil {
321 return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
322 }
323 defer resp.Body.Close()
324
325 if resp.StatusCode == http.StatusNoContent {
326 return &mcp.CallToolResult{
327 Content: []mcp.Content{
328 mcp.TextContent{
329 Type: "text",
330 Text: "Task already exists (not an error).",
331 },
332 },
333 }, nil
334 }
335
336 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
337 respBody, _ := io.ReadAll(resp.Body)
338 log.Printf("Lunatask API error (status %d): %s", resp.StatusCode, string(respBody))
339 return &mcp.CallToolResult{
340 IsError: true,
341 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
342 }, nil
343 }
344
345 var response LunataskCreateTaskResponse
346
347 respBody, err := io.ReadAll(resp.Body)
348 if err != nil {
349 return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
350 }
351
352 err = json.Unmarshal(respBody, &response)
353 if err != nil {
354 return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
355 }
356
357 return &mcp.CallToolResult{
358 Content: []mcp.Content{
359 mcp.TextContent{
360 Type: "text",
361 Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
362 },
363 },
364 }, nil
365}
366
367func createDefaultConfigFile(configPath string) {
368 defaultConfig := Config{
369 Server: ServerConfig{
370 Host: "localhost",
371 Port: 8080,
372 },
373 AccessToken: "",
374 Areas: []Area{{
375 Name: "Example Area",
376 ID: "area-id-placeholder",
377 Goals: []Goal{{
378 Name: "Example Goal",
379 ID: "goal-id-placeholder",
380 }},
381 }},
382 }
383 file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
384 if err != nil {
385 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
386 }
387 defer file.Close()
388 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
389 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
390 }
391 fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token and correct area/goal IDs, then restart the server.\n", configPath)
392 os.Exit(1)
393}