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