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