@@ -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)