refactor(main): enhance task creation and configuration

Amolith created

- added support for goals in task creation
- introduced server configuration in config
- improved validation and error handling
- added default config generation on startup if config is missing
- updated task creation tool to include more parameters
- refactored task creation logic for better structure and readability

Change summary

go.mod  |  11 ++
go.sum  |  26 ++++++
main.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++++----------
3 files changed, 219 insertions(+), 40 deletions(-)

Detailed changes

go.mod 🔗

@@ -4,10 +4,21 @@ go 1.24.2
 
 require (
 	github.com/BurntSushi/toml v1.5.0
+	github.com/go-playground/validator/v10 v10.26.0
+	github.com/ijt/go-anytime v1.9.2
 	github.com/mark3labs/mcp-go v0.21.1
 )
 
 require (
+	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
+	github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+	golang.org/x/crypto v0.33.0 // indirect
+	golang.org/x/net v0.34.0 // indirect
+	golang.org/x/sys v0.30.0 // indirect
+	golang.org/x/text v0.22.0 // indirect
 )

go.sum 🔗

@@ -2,15 +2,41 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
+github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/ijt/go-anytime v1.9.2 h1:DmYgVwUiFPNR+n6c1T5P070tlGATRZG4aYNJs6XDUfU=
+github.com/ijt/go-anytime v1.9.2/go.mod h1:egBT6FhVjNlXNHUN2wTPi6ILCNKXeeXFy04pWJjw/LI=
+github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d h1:LFOmpWrSbtolg0YqYC9hQjj5WSLtRGb6aZ3JAugLfgg=
+github.com/ijt/goparsify v0.0.0-20221203142333-3a5276334b8d/go.mod h1:112TOyA+aruNSUBlyBWlKBdLVYTdhjiO2CKD0j/URSU=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/mark3labs/mcp-go v0.21.1 h1:7Ek6KPIIbMhEYHRiRIg6K6UAgNZCJaHKQp926MNr6V0=
 github.com/mark3labs/mcp-go v0.21.1/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0PhZE7qpvbZl5ljd8r6U0bI=
+github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
+golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

main.go 🔗

@@ -4,11 +4,17 @@ import (
 	"bytes"
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"log"
 	"net/http"
 	"os"
+	"strings"
+	"time"
+
+	"github.com/go-playground/validator/v10"
+	"github.com/ijt/go-anytime"
 
 	"github.com/BurntSushi/toml"
 	"github.com/mark3labs/mcp-go/mcp"
@@ -21,25 +27,23 @@ type Area struct {
 	ID   string `toml:"id"`
 }
 
-// Config holds the application's configuration loaded from TOML
-type Config struct {
-	AccessToken string `toml:"access_token"`
-	Areas       []Area `toml:"areas"`
+// Goal represents a Lunatask goal with its name and ID
+type Goal struct {
+	Name string `toml:"name"`
+	ID   string `toml:"id"`
 }
 
-
-// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
-type LunataskCreateTaskRequest struct {
-	Name   string `json:"name"`
-	Source string `json:"source"`
-	AreaID string `json:"area_id"`
+// Config holds the application's configuration loaded from TOML
+type ServerConfig struct {
+	Host string `toml:"host"`
+	Port int    `toml:"port"`
 }
 
-// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
-type LunataskCreateTaskResponse struct {
-	Task struct {
-		ID string `json:"id"`
-	} `json:"task"`
+type Config struct {
+	AccessToken string       `toml:"access_token"`
+	Areas       []Area       `toml:"areas"`
+	Goals       []Goal       `toml:"goals"`
+	Server      ServerConfig `toml:"server"`
 }
 
 func main() {
@@ -53,21 +57,40 @@ func main() {
 		}
 	}
 
+	// Check if config exists; if not, generate default config and exit
+	if _, err := os.Stat(configPath); os.IsNotExist(err) {
+		createDefaultConfigFile(configPath)
+	}
+
 	// Load and decode TOML config
 	var config Config
 	if _, err := toml.DecodeFile(configPath, &config); err != nil {
 		log.Fatalf("Failed to load config file %s: %v", configPath, err)
 	}
 
-	if config.AccessToken == "" || config.AreaID == "" {
-		log.Fatalf("Config file must provide access_token and area_id")
+	if config.AccessToken == "" || len(config.Areas) == 0 {
+		log.Fatalf("Config file must provide access_token and at least one area.")
+	}
+	// All areas must have both name and id
+	for i, area := range config.Areas {
+		if area.Name == "" || area.ID == "" {
+			log.Fatalf("All areas (areas[%d]) must have both a name and id", i)
+		}
+	}
+	// If goals exist, all must have name and id
+	for i, goal := range config.Goals {
+		if goal.Name == "" || goal.ID == "" {
+			log.Fatalf("All goals (goals[%d]) must have both a name and id", i)
+		}
 	}
 
 	mcpServer := NewMCPServer(&config)
 
-	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL("http://localhost:8080"))
-	log.Printf("SSE server listening on :8080")
-	if err := sseServer.Start(":8080"); err != nil {
+	baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
+	sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
+	listenAddr := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port)
+	log.Printf("SSE server listening on %s (baseURL: %s)", listenAddr, baseURL)
+	if err := sseServer.Start(listenAddr); err != nil {
 		log.Fatalf("Server error: %v", err)
 	}
 }
@@ -99,17 +122,57 @@ func NewMCPServer(config *Config) *server.MCPServer {
 
 	mcpServer := server.NewMCPServer(
 		"Lunatask MCP Server",
-		"1.0.0",
+		"0.1.0",
 		server.WithHooks(hooks),
 	)
 
-	// Pass config to the handler through closure
+	mcpServer.AddTool(mcp.NewTool("get_date_for_task",
+		mcp.WithDescription("Retrieves the formatted date for a task"),
+		mcp.WithString("natural_language_date",
+			mcp.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', etc."),
+			mcp.Required(),
+		),
+	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+		natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
+		if !ok || natLangDate == "" {
+			return reportMCPError("Missing or invalid required argument: natural_language_date")
+		}
+		parsedTime, err := anytime.Parse(natLangDate, time.Now())
+		if err != nil {
+			return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
+		}
+		return &mcp.CallToolResult{
+			Content: []mcp.Content{
+				mcp.TextContent{
+					Type: "text",
+					Text: parsedTime.Format(time.RFC3339),
+				},
+			},
+		}, nil
+	})
+
 	mcpServer.AddTool(mcp.NewTool("create_task",
 		mcp.WithDescription("Creates a new task"),
+		mcp.WithString("area_id",
+			mcp.Description("Area ID in which to create the task"),
+			mcp.Required(),
+		),
+		mcp.WithString("goal_id",
+			mcp.Description("Goal the task should be associated with"),
+		),
 		mcp.WithString("name",
-			mcp.Description("Name of the task"),
+			mcp.Description("Plain text task name"),
 			mcp.Required(),
 		),
+		mcp.WithString("note",
+			mcp.Description("Note attached to the task, optionally Markdown-formatted"),
+		),
+		mcp.WithNumber("estimate",
+			mcp.Description("Estimated time to completion in minutes"),
+		),
+		mcp.WithString("scheduled_on",
+			mcp.Description("Natural language date the task is scheduled on"),
+		),
 	), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
 		return handleCreateTask(ctx, request, config)
 	})
@@ -117,29 +180,77 @@ func NewMCPServer(config *Config) *server.MCPServer {
 	return mcpServer
 }
 
+// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
+type LunataskCreateTaskRequest struct {
+	AreaID      string `json:"area_id"`
+	GoalID      string `json:"goal_id,omitempty"`
+	Name        string `json:"name" validate:"max=100"`
+	Note        string `json:"note,omitempty"`
+	Status      string `json:"status,omitempty" validate:"oneof=later next started waiting completed"`
+	Motivation  string `json:"motivation,omitempty" validate:"oneof=must should want unknown"`
+	Estimate    int    `json:"estimate,omitempty" validate:"min=0,max=720"`
+	Priority    int    `json:"priority,omitempty" validate:"min=-2,max=2"`
+	ScheduledOn string `json:"scheduled_on,omitempty"`
+	CompletedAt string `json:"completed_at,omitempty"`
+	Source      string `json:"source,omitempty"`
+}
+
+// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
+type LunataskCreateTaskResponse struct {
+	Task struct {
+		ID string `json:"id"`
+	} `json:"task"`
+}
+
+func reportMCPError(msg string) (*mcp.CallToolResult, error) {
+	return &mcp.CallToolResult{
+		IsError: true,
+		Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
+	}, nil
+}
+
+// handleCreateTask handles the creation of a task in Lunatask
 func handleCreateTask(
 	ctx context.Context,
 	request mcp.CallToolRequest,
 	config *Config,
 ) (*mcp.CallToolResult, error) {
-	// Extract the name parameter from the request
 	arguments := request.Params.Arguments
-	name, ok := arguments["name"].(string)
-	if !ok {
-		return nil, fmt.Errorf("invalid value for argument 'name'")
-	}
 
-	// Create the payload for the Lunatask API using our struct
 	payload := LunataskCreateTaskRequest{
-		Name:   name,
 		Source: "lmcps",
-		AreaID: config.AreaID,
+	}
+	argBytes, err := json.Marshal(arguments)
+	if err != nil {
+		return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err))
+	}
+	if err := json.Unmarshal(argBytes, &payload); err != nil {
+		return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err))
+	}
+
+	// Validate the struct before sending
+	validate := validator.New()
+	if err := validate.Struct(payload); err != nil {
+		var invalidValidationError *validator.InvalidValidationError
+		if errors.As(err, &invalidValidationError) {
+			return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err))
+		}
+		var validationErrs validator.ValidationErrors
+		if errors.As(err, &validationErrs) {
+			var msgBuilder strings.Builder
+			msgBuilder.WriteString("task validation failed:")
+			for _, e := range validationErrs {
+				fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value())
+			}
+			return reportMCPError(msgBuilder.String())
+		}
+		return reportMCPError(fmt.Sprintf("Validation error: %v", err))
 	}
 
 	// Convert the payload to JSON
 	payloadBytes, err := json.Marshal(payload)
 	if err != nil {
-		return nil, fmt.Errorf("failed to marshal payload: %w", err)
+		return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
 	}
 
 	// Create the HTTP request
@@ -150,18 +261,18 @@ func handleCreateTask(
 		bytes.NewBuffer(payloadBytes),
 	)
 	if err != nil {
-		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
+		return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err))
 	}
 
 	// Set the required headers
 	req.Header.Set("Content-Type", "application/json")
-	req.Header.Set("Authorization", "bearer " + config.AccessToken)
+	req.Header.Set("Authorization", "bearer "+config.AccessToken)
 
 	// Send the request
 	client := &http.Client{}
 	resp, err := client.Do(req)
 	if err != nil {
-		return nil, fmt.Errorf("failed to send HTTP request: %w", err)
+		return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
 	}
 	defer resp.Body.Close()
 
@@ -171,7 +282,7 @@ func handleCreateTask(
 			Content: []mcp.Content{
 				mcp.TextContent{
 					Type: "text",
-					Text: "Duplicate task found, no new task created.",
+					Text: "Task already exists (not an error).",
 				},
 			},
 		}, nil
@@ -180,7 +291,10 @@ func handleCreateTask(
 	// Check for error responses
 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
 		respBody, _ := io.ReadAll(resp.Body)
-		return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
+		return &mcp.CallToolResult{
+			IsError: true,
+			Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}},
+		}, nil
 	}
 
 	// Parse the response
@@ -188,12 +302,12 @@ func handleCreateTask(
 
 	respBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read response body: %w", err)
+		return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
 	}
 
 	err = json.Unmarshal(respBody, &response)
 	if err != nil {
-		return nil, fmt.Errorf("failed to parse response: %w", err)
+		return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
 	}
 
 	// Return success result
@@ -201,8 +315,36 @@ func handleCreateTask(
 		Content: []mcp.Content{
 			mcp.TextContent{
 				Type: "text",
-				Text: fmt.Sprintf("Task created successfully! Task ID: %s", response.Task.ID),
+				Text: fmt.Sprint("Task created successfully.", response.Task.ID),
 			},
 		},
 	}, nil
 }
+
+func createDefaultConfigFile(configPath string) {
+	defaultConfig := Config{
+		Server: ServerConfig{
+			Host: "localhost",
+			Port: 8080,
+		},
+		AccessToken: "",
+		Areas: []Area{{
+			Name: "Example Area",
+			ID:   "area-id-placeholder",
+		}},
+		Goals: []Goal{{
+			Name: "Example Goal",
+			ID:   "goal-id-placeholder",
+		}},
+	}
+	file, err := os.Create(configPath)
+	if err != nil {
+		log.Fatalf("Failed to create default config at %s: %v", configPath, err)
+	}
+	defer file.Close()
+	if err := toml.NewEncoder(file).Encode(defaultConfig); err != nil {
+		log.Fatalf("Failed to encode default config to %s: %v", configPath, err)
+	}
+	fmt.Printf("A default config has been created at %s.\nPlease edit it to provide your Lunatask access token and correct area/goal IDs, then restart the server.\n", configPath)
+	os.Exit(1)
+}