From f8fe125b5d0f246fed9d5d76c464faed6e75ef57 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 18 Apr 2025 20:15:23 -0600 Subject: [PATCH] refactor(main): enhance task creation and configuration - 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 --- go.mod | 11 +++ go.sum | 26 +++++++ main.go | 222 ++++++++++++++++++++++++++++++++++++++++++++++---------- 3 files changed, 219 insertions(+), 40 deletions(-) diff --git a/go.mod b/go.mod index 50c7f6fc1212b5259d947b6856920cecdd89bc49..52062c12833fefd293204df64a288dd3631f3242 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index c44b4ffb09ec0153c6fd1b03b37498c635ab7814..25fcf053c27e4818a87e3fba296b5f957c8d9b22 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index 2c485e368b8f42fe3bbbea9188cb20ec992cff32..b26667732decf3a04b57f25ce454b3c81b4208e4 100644 --- a/main.go +++ b/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) +}