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