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