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