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}