feat: add goals to areas and update task creation

Amolith created

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

Change summary

main.go | 158 ++++++++++++++++++++++++++++------------------------------
1 file changed, 76 insertions(+), 82 deletions(-)

Detailed changes

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)