From 3926843b029d3d3885d33559527125c093e1ece2 Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 18 Apr 2025 22:15:38 -0600 Subject: [PATCH] feat: add goals to areas and update task creation add goals to areas and update task creation logic to include goal validation - remove unused list_areas and list_goals tools - update create_task tool to validate goal_id against area - update get_date_for_task tool to get_task_timestamp - add list_areas_and_goals tool to list areas and goals --- main.go | 158 +++++++++++++++++++++++++++----------------------------- 1 file changed, 76 insertions(+), 82 deletions(-) diff --git a/main.go b/main.go index d51d4932e99bf26545689d61355f0008894eb274..64f3f728a288da96a53f6f3dd88d954506305d2b 100644 --- a/main.go +++ b/main.go @@ -21,18 +21,19 @@ import ( "github.com/mark3labs/mcp-go/server" ) -// Area represents a Lunatask area with its name and ID -type Area struct { - Name string `toml:"name"` - ID string `toml:"id"` -} - // Goal represents a Lunatask goal with its name and ID type Goal struct { Name string `toml:"name"` ID string `toml:"id"` } +// Area represents a Lunatask area with its name, ID, and its goals +type Area struct { + Name string `toml:"name"` + ID string `toml:"id"` + Goals []Goal `toml:"goals"` +} + // Config holds the application's configuration loaded from TOML type ServerConfig struct { Host string `toml:"host"` @@ -42,12 +43,10 @@ type ServerConfig struct { type Config struct { AccessToken string `toml:"access_token"` Areas []Area `toml:"areas"` - Goals []Goal `toml:"goals"` Server ServerConfig `toml:"server"` } func main() { - // Determine config path from command-line arguments configPath := "./config.toml" for i, arg := range os.Args { if arg == "-c" || arg == "--config" { @@ -57,12 +56,10 @@ 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) @@ -71,16 +68,15 @@ func main() { 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) + for j, goal := range area.Goals { + if goal.Name == "" || goal.ID == "" { + log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", i, j) + } } } @@ -126,10 +122,10 @@ func NewMCPServer(config *Config) *server.MCPServer { server.WithHooks(hooks), ) - mcpServer.AddTool(mcp.NewTool("get_date_for_task", - mcp.WithDescription("Retrieves the formatted date for a task"), + mcpServer.AddTool(mcp.NewTool("get_task_timestamp", + mcp.WithDescription("Retrieves the formatted timestamp 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.Description("Natural language date as described by the user, e.g. '1 week', 'tomorrow', 'sunday at 19:00', etc."), mcp.Required(), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -151,17 +147,41 @@ func NewMCPServer(config *Config) *server.MCPServer { }, nil }) + mcpServer.AddTool( + mcp.NewTool( + "list_areas_and_goals", + mcp.WithDescription("List areas and goals and their IDs."), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var b strings.Builder + for _, area := range config.Areas { + fmt.Fprintf(&b, "- %s: %s\n", area.Name, area.ID) + for _, goal := range area.Goals { + fmt.Fprintf(&b, " - %s: %s\n", goal.Name, goal.ID) + } + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: b.String(), + }, + }, + }, 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.Description("ID of the area in which to create the task"), mcp.Required(), ), mcp.WithString("goal_id", - mcp.Description("Goal the task should be associated with"), + mcp.Description("ID of the goal, which must belong to the specified area, that the task should be associated with."), ), mcp.WithString("name", - mcp.Description("Plain text task name"), + mcp.Description("Plain text task name using sentence case."), mcp.Required(), ), mcp.WithString("note", @@ -171,56 +191,12 @@ func NewMCPServer(config *Config) *server.MCPServer { mcp.Description("Estimated time to completion in minutes"), ), mcp.WithString("scheduled_on", - mcp.Description("Natural language date the task is scheduled on"), + mcp.Description("Formatted timestamp"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { return handleCreateTask(ctx, request, config) }) - mcpServer.AddTool( - mcp.NewTool( - "list_areas", - mcp.WithDescription("List areas and their IDs."), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var b strings.Builder - b.WriteString("| Area Name | Area ID |\n|-----------|--------|\n") - for _, area := range config.Areas { - fmt.Fprintf(&b, "| %s | %s |\n", area.Name, area.ID) - } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: b.String(), - }, - }, - }, nil - }, - ) - - mcpServer.AddTool( - mcp.NewTool( - "list_goals", - mcp.WithDescription("List goals and their IDs."), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var b strings.Builder - b.WriteString("| Goal Name | Goal ID |\n|----------|--------|\n") - for _, goal := range config.Goals { - fmt.Fprintf(&b, "| %s | %s |\n", goal.Name, goal.ID) - } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: b.String(), - }, - }, - }, nil - }, - ) - return mcpServer } @@ -261,9 +237,36 @@ func handleCreateTask( ) (*mcp.CallToolResult, error) { arguments := request.Params.Arguments - payload := LunataskCreateTaskRequest{ - AreaID: config.Areas[0].ID, + areaID, ok := arguments["area_id"].(string) + if !ok || areaID == "" { + return reportMCPError("Missing or invalid required argument: area_id") + } + + var area *Area + for i := range config.Areas { + if config.Areas[i].ID == areaID { + area = &config.Areas[i] + break + } + } + if area == nil { + return reportMCPError("Area not found for given area_id") } + + if goalID, exists := arguments["goal_id"].(string); exists && goalID != "" { + found := false + for _, goal := range area.Goals { + if goal.ID == goalID { + found = true + break + } + } + if !found { + return reportMCPError("Goal not found in specified area for given goal_id") + } + } + + var payload LunataskCreateTaskRequest argBytes, err := json.Marshal(arguments) if err != nil { return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err)) @@ -272,7 +275,6 @@ func handleCreateTask( 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 @@ -291,13 +293,11 @@ func handleCreateTask( return reportMCPError(fmt.Sprintf("Validation error: %v", err)) } - // Convert the payload to JSON payloadBytes, err := json.Marshal(payload) if err != nil { return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err)) } - // Create the HTTP request req, err := http.NewRequestWithContext( ctx, "POST", @@ -308,11 +308,9 @@ func handleCreateTask( 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) - // Send the request client := &http.Client{} resp, err := client.Do(req) if err != nil { @@ -320,7 +318,6 @@ func handleCreateTask( } defer resp.Body.Close() - // Handle duplicate task (204 No Content) if resp.StatusCode == http.StatusNoContent { return &mcp.CallToolResult{ Content: []mcp.Content{ @@ -332,7 +329,6 @@ func handleCreateTask( }, nil } - // Check for error responses if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return &mcp.CallToolResult{ @@ -341,7 +337,6 @@ func handleCreateTask( }, nil } - // Parse the response var response LunataskCreateTaskResponse respBody, err := io.ReadAll(resp.Body) @@ -354,7 +349,6 @@ func handleCreateTask( return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err)) } - // Return success result return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ @@ -375,10 +369,10 @@ func createDefaultConfigFile(configPath string) { Areas: []Area{{ Name: "Example Area", ID: "area-id-placeholder", - }}, - Goals: []Goal{{ - Name: "Example Goal", - ID: "goal-id-placeholder", + Goals: []Goal{{ + Name: "Example Goal", + ID: "goal-id-placeholder", + }}, }}, } file, err := os.Create(configPath)