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 Timezone string `toml:"timezone"`
52}
53
54var version = ""
55
56func main() {
57 configPath := "./config.toml"
58 for i, arg := range os.Args {
59 switch arg {
60 case "-v", "--version":
61 if version == "" {
62 version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
63 }
64 fmt.Println("lunatask-mcp-server:", version)
65 os.Exit(0)
66 case "-c", "--config":
67 if i+1 < len(os.Args) {
68 configPath = os.Args[i+1]
69 }
70 }
71 }
72
73 if _, err := os.Stat(configPath); os.IsNotExist(err) {
74 createDefaultConfigFile(configPath)
75 }
76
77 var config Config
78 if _, err := toml.DecodeFile(configPath, &config); err != nil {
79 log.Fatalf("Failed to load config file %s: %v", configPath, err)
80 }
81
82 if config.AccessToken == "" || len(config.Areas) == 0 {
83 log.Fatalf("Config file must provide access_token and at least one area.")
84 }
85
86 for i, area := range config.Areas {
87 if area.Name == "" || area.ID == "" {
88 log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
89 }
90 for j, goal := range area.Goals {
91 if goal.Name == "" || goal.ID == "" {
92 log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j)
93 }
94 }
95 }
96
97 // Validate timezone config on startup
98 if _, err := loadLocation(config.Timezone); err != nil {
99 log.Fatalf("Timezone validation failed: %v", err)
100 }
101
102 mcpServer := NewMCPServer(&config)
103
104 baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
105 sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
106 listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
107 log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
108 if err := sseServer.Start(listenAddr); err != nil {
109 log.Fatalf("Server error: %v", err)
110 }
111}
112
113// loadLocation loads a timezone location string, returning a *time.Location or error
114func loadLocation(timezone string) (*time.Location, error) {
115 if timezone == "" {
116 return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
117 }
118 loc, err := time.LoadLocation(timezone)
119 if err != nil {
120 return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err)
121 }
122 return loc, nil
123}
124
125// closeResponseBody properly closes an HTTP response body, handling any errors
126func closeResponseBody(resp *http.Response) {
127 err := resp.Body.Close()
128 if err != nil {
129 log.Printf("Error closing response body: %v", err)
130 }
131}
132
133// closeFile properly closes a file, handling any errors
134func closeFile(f *os.File) {
135 err := f.Close()
136 if err != nil {
137 log.Printf("Error closing file: %v", err)
138 }
139}
140
141func NewMCPServer(config *Config) *server.MCPServer {
142 hooks := &server.Hooks{}
143
144 hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
145 fmt.Printf("beforeAny: %s, %v, %v\n", method, id, message)
146 })
147 hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
148 fmt.Printf("onSuccess: %s, %v, %v, %v\n", method, id, message, result)
149 })
150 hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
151 fmt.Printf("onError: %s, %v, %v, %v\n", method, id, message, err)
152 })
153 hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
154 fmt.Printf("beforeInitialize: %v, %v\n", id, message)
155 })
156 hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
157 fmt.Printf("afterInitialize: %v, %v, %v\n", id, message, result)
158 })
159 hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
160 fmt.Printf("afterCallTool: %v, %v, %v\n", id, message, result)
161 })
162 hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
163 fmt.Printf("beforeCallTool: %v, %v\n", id, message)
164 })
165
166 mcpServer := server.NewMCPServer(
167 "Lunatask MCP Server",
168 "0.1.0",
169 server.WithHooks(hooks),
170 server.WithToolCapabilities(true),
171 )
172
173 mcpServer.AddTool(mcp.NewTool("get_task_timestamp",
174 mcp.WithDescription("Retrieves the formatted timestamp for a task"),
175 mcp.WithString("natural_language_date",
176 mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."),
177 mcp.Required(),
178 ),
179 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
180 natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
181 if !ok || natLangDate == "" {
182 return reportMCPError("Missing or invalid required argument: natural_language_date")
183 }
184 loc, err := loadLocation(config.Timezone)
185 if err != nil {
186 return reportMCPError(err.Error())
187 }
188 parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
189 if err != nil {
190 return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
191 }
192 return &mcp.CallToolResult{
193 Content: []mcp.Content{
194 mcp.TextContent{
195 Type: "text",
196 Text: parsedTime.Format(time.RFC3339),
197 },
198 },
199 }, nil
200 })
201
202 mcpServer.AddTool(
203 mcp.NewTool(
204 "list_areas_and_goals",
205 mcp.WithDescription("List areas and goals and their IDs."),
206 ),
207 func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
208 var b strings.Builder
209 for _, area := range config.Areas {
210 fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
211 for _, goal := range area.Goals {
212 fmt.Fprintf(&b, " - %s: %s\n", goal.Name, goal.ID)
213 }
214 }
215 return &mcp.CallToolResult{
216 Content: []mcp.Content{
217 mcp.TextContent{
218 Type: "text",
219 Text: b.String(),
220 },
221 },
222 }, nil
223 },
224 )
225
226 mcpServer.AddTool(mcp.NewTool("create_task",
227 mcp.WithDescription("Creates a new task"),
228 mcp.WithString("area_id",
229 mcp.Description("Area ID in which to create the task"),
230 mcp.Required(),
231 ),
232 mcp.WithString("goal_id",
233 mcp.Description("Goal ID, which must belong to the provided area, to associate the task with."),
234 ),
235 mcp.WithString("name",
236 mcp.Description("Plain text task name using sentence case."),
237 mcp.Required(),
238 ),
239 mcp.WithString("note",
240 mcp.Description("Note attached to the task, optionally Markdown-formatted"),
241 ),
242 mcp.WithNumber("estimate",
243 mcp.Description("Estimated time completion time in minutes"),
244 mcp.Min(0),
245 mcp.Max(1440),
246 ),
247 mcp.WithNumber("priority",
248 mcp.Description("Task priority, -2 being lowest, 0 being normal, and 2 being highest"),
249 mcp.Min(-2),
250 mcp.Max(2),
251 ),
252 mcp.WithString("motivation",
253 mcp.Description("Motivation driving task creation"),
254 mcp.Enum("must", "should", "want"),
255 ),
256 mcp.WithString("status",
257 mcp.Description("Task state, such as in progress, provided as 'started', already started, provided as 'started', soon, provided as 'next', blocked as waiting, omit unspecified, and so on. Intuit the task's status."),
258 mcp.Enum("later", "next", "started", "waiting", "completed"),
259 ),
260 mcp.WithString("scheduled_on",
261 mcp.Description("Formatted timestamp from get_task_timestamp tool"),
262 ),
263 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
264 return handleCreateTask(ctx, request, config)
265 })
266
267 return mcpServer
268}
269
270// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
271type LunataskCreateTaskRequest struct {
272 AreaID string `json:"area_id"`
273 GoalID string `json:"goal_id,omitempty" validate:"omitempty"`
274 Name string `json:"name" validate:"max=100"`
275 Note string `json:"note,omitempty" validate:"omitempty"`
276 Status string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
277 Motivation string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
278 Estimate int `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
279 Priority int `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
280 ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
281 CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
282 Source string `json:"source,omitempty" validate:"omitempty"`
283}
284
285// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
286type LunataskCreateTaskResponse struct {
287 Task struct {
288 ID string `json:"id"`
289 } `json:"task"`
290}
291
292func reportMCPError(msg string) (*mcp.CallToolResult, error) {
293 return &mcp.CallToolResult{
294 IsError: true,
295 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
296 }, nil
297}
298
299// handleCreateTask handles the creation of a task in Lunatask
300func handleCreateTask(
301 ctx context.Context,
302 request mcp.CallToolRequest,
303 config *Config,
304) (*mcp.CallToolResult, error) {
305 arguments := request.Params.Arguments
306
307 // Validate timezone before proceeding any further
308 if _, err := loadLocation(config.Timezone); err != nil {
309 return reportMCPError(err.Error())
310 }
311
312 areaID, ok := arguments["area_id"].(string)
313 if !ok || areaID == "" {
314 return reportMCPError("Missing or invalid required argument: area_id")
315 }
316
317 var area *Area
318 for i := range config.Areas {
319 if config.Areas[i].ID == areaID {
320 area = &config.Areas[i]
321 break
322 }
323 }
324 if area == nil {
325 return reportMCPError("Area not found for given area_id")
326 }
327
328 if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
329 found := false
330 for _, goal := range area.Goals {
331 if goal.ID == goalID {
332 found = true
333 break
334 }
335 }
336 if !found {
337 return reportMCPError("Goal not found in specified area for given goal_id")
338 }
339 }
340
341 if priorityVal, exists := arguments["priority"]; exists && priorityVal != nil {
342 if priority, ok := priorityVal.(float64); ok {
343 if priority < -2 || priority > 2 {
344 return reportMCPError("'priority' must be between -2 and 2 (inclusive)")
345 }
346 } else {
347 return reportMCPError("'priority' must be a number")
348 }
349 }
350
351 if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
352 if motivation, ok := motivationVal.(string); ok && motivation != "" {
353 validMotivations := map[string]bool{"must": true, "should": true, "want": true}
354 if !validMotivations[motivation] {
355 return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
356 }
357 } else if ok {
358 // empty string is allowed
359 } else {
360 return reportMCPError("'motivation' must be a string")
361 }
362 }
363
364 if statusVal, exists := arguments["status"]; exists && statusVal != nil {
365 if status, ok := statusVal.(string); ok && status != "" {
366 validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
367 if !validStatus[status] {
368 return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
369 }
370 } else if ok {
371 // empty string is allowed
372 } else {
373 return reportMCPError("'status' must be a string")
374 }
375 }
376
377 // Validate scheduled_on format if provided
378 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
379 if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
380 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
381 return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_task_timestamp tool first.", scheduledOnStr))
382 }
383 } else if !ok {
384 // It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
385 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
386 }
387 // If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
388 }
389
390 var payload LunataskCreateTaskRequest
391 argBytes, err := json.Marshal(arguments)
392 if err != nil {
393 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
394 }
395 if err := json.Unmarshal(argBytes, &payload); err != nil {
396 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
397 }
398
399 validate := validator.New()
400 if err := validate.Struct(payload); err != nil {
401 var invalidValidationError *validator.InvalidValidationError
402 if errors.As(err, &invalidValidationError) {
403 return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
404 }
405 var validationErrs validator.ValidationErrors
406 if errors.As(err, &validationErrs) {
407 var msgBuilder strings.Builder
408 msgBuilder.WriteString("task validation failed:")
409 for _, e := range validationErrs {
410 fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
411 }
412 return reportMCPError(msgBuilder.String())
413 }
414 return reportMCPError(fmt.Sprintf("Validation error: %v", err))
415 }
416
417 payloadBytes, err := json.Marshal(payload)
418 if err != nil {
419 return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
420 }
421
422 req, err := http.NewRequestWithContext(
423 ctx,
424 "POST",
425 "https://api.lunatask.app/v1/tasks",
426 bytes.NewBuffer(payloadBytes),
427 )
428 if err != nil {
429 return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
430 }
431
432 req.Header.Set("Content-Type", "application/json")
433 req.Header.Set("Authorization", "bearer "+config.AccessToken)
434
435 client := &http.Client{}
436 resp, err := client.Do(req)
437 if err != nil {
438 return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
439 }
440 defer closeResponseBody(resp)
441
442 if resp.StatusCode == http.StatusNoContent {
443 return &mcp.CallToolResult{
444 Content: []mcp.Content{
445 mcp.TextContent{
446 Type: "text",
447 Text: "Task already exists (not an error).",
448 },
449 },
450 }, nil
451 }
452
453 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
454 respBody, _ := io.ReadAll(resp.Body)
455 log.Printf("Lunatask API error (status %d): %s", resp.StatusCode, string(respBody))
456 return &mcp.CallToolResult{
457 IsError: true,
458 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
459 }, nil
460 }
461
462 var response LunataskCreateTaskResponse
463
464 respBody, err := io.ReadAll(resp.Body)
465 if err != nil {
466 return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
467 }
468
469 err = json.Unmarshal(respBody, &response)
470 if err != nil {
471 return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
472 }
473
474 return &mcp.CallToolResult{
475 Content: []mcp.Content{
476 mcp.TextContent{
477 Type: "text",
478 Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
479 },
480 },
481 }, nil
482}
483
484func createDefaultConfigFile(configPath string) {
485 defaultConfig := Config{
486 Server: ServerConfig{
487 Host: "localhost",
488 Port: 8080,
489 },
490 AccessToken: "",
491 Timezone: "UTC",
492 Areas: []Area{{
493 Name: "Example Area",
494 ID: "area-id-placeholder",
495 Goals: []Goal{{
496 Name: "Example Goal",
497 ID: "goal-id-placeholder",
498 }},
499 }},
500 }
501 file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
502 if err != nil {
503 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
504 }
505 defer closeFile(file)
506 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
507 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
508 }
509 fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token, correct area/goal IDs, and your timezone (IANA/Olson format, e.g. 'America/New_York'), then restart the server.\n", configPath)
510 os.Exit(1)
511}