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 )
171
172 mcpServer.AddTool(mcp.NewTool("get_task_timestamp",
173 mcp.WithDescription("Retrieves the formatted timestamp for a task"),
174 mcp.WithString("natural_language_date",
175 mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."),
176 mcp.Required(),
177 ),
178 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
179 natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
180 if !ok || natLangDate == "" {
181 return reportMCPError("Missing or invalid required argument: natural_language_date")
182 }
183 loc, err := loadLocation(config.Timezone)
184 if err != nil {
185 return reportMCPError(err.Error())
186 }
187 parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
188 if err != nil {
189 return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
190 }
191 return &mcp.CallToolResult{
192 Content: []mcp.Content{
193 mcp.TextContent{
194 Type: "text",
195 Text: parsedTime.Format(time.RFC3339),
196 },
197 },
198 }, nil
199 })
200
201 mcpServer.AddTool(
202 mcp.NewTool(
203 "list_areas_and_goals",
204 mcp.WithDescription("List areas and goals and their IDs."),
205 ),
206 func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
207 var b strings.Builder
208 for _, area := range config.Areas {
209 fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID)
210 for _, goal := range area.Goals {
211 fmt.Fprintf(&b, " - %s: %s\n", goal.Name, goal.ID)
212 }
213 }
214 return &mcp.CallToolResult{
215 Content: []mcp.Content{
216 mcp.TextContent{
217 Type: "text",
218 Text: b.String(),
219 },
220 },
221 }, nil
222 },
223 )
224
225 mcpServer.AddTool(mcp.NewTool("create_task",
226 mcp.WithDescription("Creates a new task"),
227 mcp.WithString("area_id",
228 mcp.Description("ID of the area in which to create the task"),
229 mcp.Required(),
230 ),
231 mcp.WithString("goal_id",
232 mcp.Description("ID of the goal, which must belong to the specified area, that the task should be associated with."),
233 ),
234 mcp.WithString("name",
235 mcp.Description("Plain text task name using sentence case."),
236 mcp.Required(),
237 ),
238 mcp.WithString("note",
239 mcp.Description("Note attached to the task, optionally Markdown-formatted"),
240 ),
241 mcp.WithNumber("estimate",
242 mcp.Description("Estimated time to completion in minutes"),
243 ),
244 mcp.WithString("scheduled_on",
245 mcp.Description("Formatted timestamp"),
246 ),
247 ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
248 return handleCreateTask(ctx, request, config)
249 })
250
251 return mcpServer
252}
253
254// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
255type LunataskCreateTaskRequest struct {
256 AreaID string `json:"area_id"`
257 GoalID string `json:"goal_id,omitempty" validate:"omitempty"`
258 Name string `json:"name" validate:"max=100"`
259 Note string `json:"note,omitempty" validate:"omitempty"`
260 Status string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"`
261 Motivation string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"`
262 Estimate int `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"`
263 Priority int `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"`
264 ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"`
265 CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"`
266 Source string `json:"source,omitempty" validate:"omitempty"`
267}
268
269// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
270type LunataskCreateTaskResponse struct {
271 Task struct {
272 ID string `json:"id"`
273 } `json:"task"`
274}
275
276func reportMCPError(msg string) (*mcp.CallToolResult, error) {
277 return &mcp.CallToolResult{
278 IsError: true,
279 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
280 }, nil
281}
282
283// handleCreateTask handles the creation of a task in Lunatask
284func handleCreateTask(
285 ctx context.Context,
286 request mcp.CallToolRequest,
287 config *Config,
288) (*mcp.CallToolResult, error) {
289 arguments := request.Params.Arguments
290
291 // Validate timezone before proceeding any further
292 if _, err := loadLocation(config.Timezone); err != nil {
293 return reportMCPError(err.Error())
294 }
295
296 areaID, ok := arguments["area_id"].(string)
297 if !ok || areaID == "" {
298 return reportMCPError("Missing or invalid required argument: area_id")
299 }
300
301 var area *Area
302 for i := range config.Areas {
303 if config.Areas[i].ID == areaID {
304 area = &config.Areas[i]
305 break
306 }
307 }
308 if area == nil {
309 return reportMCPError("Area not found for given area_id")
310 }
311
312 if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" {
313 found := false
314 for _, goal := range area.Goals {
315 if goal.ID == goalID {
316 found = true
317 break
318 }
319 }
320 if !found {
321 return reportMCPError("Goal not found in specified area for given goal_id")
322 }
323 }
324
325 // Validate scheduled_on format if provided
326 if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
327 if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" {
328 if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil {
329 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))
330 }
331 } else if !ok {
332 // It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway
333 return reportMCPError("Invalid type for scheduled_on argument: expected string.")
334 }
335 // If it's an empty string, it's handled by the API or omitempty later, no need to validate format.
336 }
337
338 var payload LunataskCreateTaskRequest
339 argBytes, err := json.Marshal(arguments)
340 if err != nil {
341 return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
342 }
343 if err := json.Unmarshal(argBytes, &payload); err != nil {
344 return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
345 }
346
347 validate := validator.New()
348 if err := validate.Struct(payload); err != nil {
349 var invalidValidationError *validator.InvalidValidationError
350 if errors.As(err, &invalidValidationError) {
351 return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
352 }
353 var validationErrs validator.ValidationErrors
354 if errors.As(err, &validationErrs) {
355 var msgBuilder strings.Builder
356 msgBuilder.WriteString("task validation failed:")
357 for _, e := range validationErrs {
358 fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
359 }
360 return reportMCPError(msgBuilder.String())
361 }
362 return reportMCPError(fmt.Sprintf("Validation error: %v", err))
363 }
364
365 payloadBytes, err := json.Marshal(payload)
366 if err != nil {
367 return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
368 }
369
370 req, err := http.NewRequestWithContext(
371 ctx,
372 "POST",
373 "https://api.lunatask.app/v1/tasks",
374 bytes.NewBuffer(payloadBytes),
375 )
376 if err != nil {
377 return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
378 }
379
380 req.Header.Set("Content-Type", "application/json")
381 req.Header.Set("Authorization", "bearer "+config.AccessToken)
382
383 client := &http.Client{}
384 resp, err := client.Do(req)
385 if err != nil {
386 return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
387 }
388 defer closeResponseBody(resp)
389
390 if resp.StatusCode == http.StatusNoContent {
391 return &mcp.CallToolResult{
392 Content: []mcp.Content{
393 mcp.TextContent{
394 Type: "text",
395 Text: "Task already exists (not an error).",
396 },
397 },
398 }, nil
399 }
400
401 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
402 respBody, _ := io.ReadAll(resp.Body)
403 log.Printf("Lunatask API error (status %d): %s", resp.StatusCode, string(respBody))
404 return &mcp.CallToolResult{
405 IsError: true,
406 Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
407 }, nil
408 }
409
410 var response LunataskCreateTaskResponse
411
412 respBody, err := io.ReadAll(resp.Body)
413 if err != nil {
414 return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
415 }
416
417 err = json.Unmarshal(respBody, &response)
418 if err != nil {
419 return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
420 }
421
422 return &mcp.CallToolResult{
423 Content: []mcp.Content{
424 mcp.TextContent{
425 Type: "text",
426 Text: fmt.Sprintf("Task created successfully with ID: %s", response.Task.ID),
427 },
428 },
429 }, nil
430}
431
432func createDefaultConfigFile(configPath string) {
433 defaultConfig := Config{
434 Server: ServerConfig{
435 Host: "localhost",
436 Port: 8080,
437 },
438 AccessToken: "",
439 Timezone: "UTC",
440 Areas: []Area{{
441 Name: "Example Area",
442 ID: "area-id-placeholder",
443 Goals: []Goal{{
444 Name: "Example Goal",
445 ID: "goal-id-placeholder",
446 }},
447 }},
448 }
449 file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
450 if err != nil {
451 log.Fatalf("Failed to create default config at %s: %v", configPath, err)
452 }
453 defer closeFile(file)
454 if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
455 log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
456 }
457 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)
458 os.Exit(1)
459}