main.go

  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}