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