@@ -17,7 +17,7 @@ import (
"git.sr.ht/~amolith/lunatask-mcp-server/tools"
)
-// Goal represents a Lunatask goal with its name and ID
+// Goal represents a Lunatask goal with its name and ID.
type Goal struct {
Name string `toml:"name"`
ID string `toml:"id"`
@@ -29,7 +29,7 @@ func (g Goal) GetName() string { return g.Name }
// GetID returns the goal's ID.
func (g Goal) GetID() string { return g.ID }
-// Area represents a Lunatask area with its name, ID, and its goals
+// Area represents a Lunatask area with its name, ID, and its goals.
type Area struct {
Name string `toml:"name"`
ID string `toml:"id"`
@@ -48,10 +48,11 @@ func (a Area) GetGoals() []tools.GoalProvider {
for i, g := range a.Goals {
providers[i] = g // Goal implements GoalProvider
}
+
return providers
}
-// Habit represents a Lunatask habit with its name and ID
+// Habit represents a Lunatask habit with its name and ID.
type Habit struct {
Name string `toml:"name"`
ID string `toml:"id"`
@@ -63,7 +64,7 @@ func (h Habit) GetName() string { return h.Name }
// GetID returns the habit's ID.
func (h Habit) GetID() string { return h.ID }
-// ServerConfig holds the application's configuration loaded from TOML
+// ServerConfig holds the application's configuration loaded from TOML.
type ServerConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
@@ -81,12 +82,14 @@ var version = ""
func main() {
configPath := "./config.toml"
+
for i, arg := range os.Args {
switch arg {
case "-v", "--version":
if version == "" {
version = "unknown, build with `just build` or copy/paste the build command from ./justfile"
}
+
fmt.Println("lunatask-mcp-server:", version)
os.Exit(0)
case "-c", "--config":
@@ -113,6 +116,7 @@ func main() {
if area.Name == "" || area.ID == "" {
log.Fatalf("All areas (areas[%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)
@@ -131,12 +135,13 @@ func main() {
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)
}
}
-// closeFile properly closes a file, handling any errors
+// closeFile properly closes a file, handling any errors.
func closeFile(f *os.File) {
err := f.Close()
if err != nil {
@@ -351,14 +356,17 @@ func createDefaultConfigFile(configPath string) {
ID: "habit-id-placeholder",
}},
}
+
file, err := os.OpenFile(configPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
log.Fatalf("Failed to create default config at %s: %v", configPath, err)
}
defer closeFile(file)
+
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, correct area/goal IDs, and your timezone (IANA/Olson format, e.g. 'America/New_York'), then restart the server.\n", configPath)
os.Exit(1)
}
@@ -27,12 +27,15 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
}
var areaFoundProvider AreaProvider
+
for _, ap := range h.config.Areas {
if ap.GetID() == areaID {
areaFoundProvider = ap
+
break
}
}
+
if areaFoundProvider == nil {
return reportMCPError("Area not found for given area_id")
}
@@ -40,15 +43,19 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
var goalID *string
if goalIDStr, exists := arguments["goal_id"].(string); exists && goalIDStr != "" {
found := false
+
for _, goal := range areaFoundProvider.GetGoals() {
if goal.GetID() == goalIDStr {
found = true
+
break
}
}
+
if !found {
return reportMCPError("Goal not found in specified area for given goal_id")
}
+
goalID = &goalIDStr
}
@@ -56,6 +63,7 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
if !ok || name == "" {
return reportMCPError("Missing or invalid required argument: name")
}
+
if len(name) > 100 {
return reportMCPError("'name' must be 100 characters or fewer")
}
@@ -75,6 +83,7 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("Invalid type for 'priority' argument: expected string.")
}
+
priorityMap := map[string]int{
"lowest": -2,
"low": -1,
@@ -82,10 +91,12 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
"high": 1,
"highest": 2,
}
+
translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
if !isValid {
return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
}
+
task.Priority = &translatedPriority
}
@@ -94,6 +105,7 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
}
+
eisenhowerMap := map[string]int{
"uncategorised": 0,
"both urgent and important": 1,
@@ -101,10 +113,12 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
"important, but not urgent": 3,
"neither urgent nor important": 4,
}
+
translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
if !isValid {
return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
}
+
task.Eisenhower = &translatedEisenhower
}
@@ -113,11 +127,13 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("'motivation' must be a string")
}
+
if motivation != "" {
validMotivations := map[string]bool{"must": true, "should": true, "want": true}
if !validMotivations[motivation] {
return reportMCPError("'motivation' must be one of 'must', 'should', or 'want'")
}
+
task.Motivation = &motivation
}
}
@@ -127,11 +143,13 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("'status' must be a string")
}
+
if status != "" {
validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
if !validStatus[status] {
return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'")
}
+
task.Status = &status
}
}
@@ -141,10 +159,12 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("Invalid type for 'estimate' argument: expected number.")
}
+
estimate := int(estimateVal)
if estimate < 0 || estimate > 720 {
return reportMCPError("'estimate' must be between 0 and 720 minutes")
}
+
task.Estimate = &estimate
}
@@ -153,11 +173,13 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("Invalid type for scheduled_on argument: expected string.")
}
+
if scheduledOnStr != "" {
date, err := lunatask.ParseDate(scheduledOnStr)
if err != nil {
return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
}
+
task.ScheduledOn = &date
}
}
@@ -171,6 +193,7 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
}
client := lunatask.NewClient(h.config.AccessToken)
+
response, err := client.CreateTask(ctx, &task)
if err != nil {
return reportMCPError(fmt.Sprintf("%v", err))
@@ -191,7 +214,7 @@ func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolReq
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
- Text: fmt.Sprintf("Task created successfully with ID: %s", response.ID),
+ Text: "Task created successfully with ID: " + response.ID,
},
},
}, nil
@@ -213,6 +236,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
updatePayload := lunatask.UpdateTaskRequest{}
var specifiedAreaProvider AreaProvider
+
areaIDProvided := false
if areaIDArg, exists := arguments["area_id"]; exists {
@@ -220,19 +244,23 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok && areaIDArg != nil {
return reportMCPError("Invalid type for area_id argument: expected string.")
}
+
if ok && areaIDStr != "" {
updatePayload.AreaID = &areaIDStr
areaIDProvided = true
found := false
+
for _, ap := range h.config.Areas {
if ap.GetID() == areaIDStr {
specifiedAreaProvider = ap
found = true
+
break
}
}
+
if !found {
- return reportMCPError(fmt.Sprintf("Area not found for given area_id: %s", areaIDStr))
+ return reportMCPError("Area not found for given area_id: " + areaIDStr)
}
}
}
@@ -242,16 +270,21 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok && goalIDArg != nil {
return reportMCPError("Invalid type for goal_id argument: expected string.")
}
+
if ok && goalIDStr != "" {
updatePayload.GoalID = &goalIDStr
+
if specifiedAreaProvider != nil {
foundGoal := false
+
for _, goal := range specifiedAreaProvider.GetGoals() {
if goal.GetID() == goalIDStr {
foundGoal = true
+
break
}
}
+
if !foundGoal {
return reportMCPError(fmt.Sprintf("Goal not found in specified area '%s' for given goal_id: %s", specifiedAreaProvider.GetName(), goalIDStr))
}
@@ -266,6 +299,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if len(nameStr) > 100 {
return reportMCPError("'name' must be 100 characters or fewer")
}
+
updatePayload.Name = &nameStr
} else {
return reportMCPError("Invalid type for name argument: expected string.")
@@ -276,6 +310,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok && noteArg != nil {
return reportMCPError("Invalid type for note argument: expected string.")
}
+
if ok {
updatePayload.Note = ¬eStr
}
@@ -286,10 +321,12 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("Invalid type for estimate argument: expected number.")
}
+
estimate := int(estimateVal)
if estimate < 0 || estimate > 720 {
return reportMCPError("'estimate' must be between 0 and 720 minutes")
}
+
updatePayload.Estimate = &estimate
}
@@ -298,6 +335,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("Invalid type for 'priority' argument: expected string.")
}
+
priorityMap := map[string]int{
"lowest": -2,
"low": -1,
@@ -305,10 +343,12 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
"high": 1,
"highest": 2,
}
+
translatedPriority, isValid := priorityMap[strings.ToLower(priorityStr)]
if !isValid {
return reportMCPError(fmt.Sprintf("Invalid 'priority' value: '%s'. Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.", priorityStr))
}
+
updatePayload.Priority = &translatedPriority
}
@@ -317,6 +357,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok {
return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
}
+
eisenhowerMap := map[string]int{
"uncategorised": 0,
"both urgent and important": 1,
@@ -324,10 +365,12 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
"important, but not urgent": 3,
"neither urgent nor important": 4,
}
+
translatedEisenhower, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
if !isValid {
return reportMCPError(fmt.Sprintf("Invalid 'eisenhower' value: '%s'. Must be one of 'uncategorised', 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important'.", eisenhowerStr))
}
+
updatePayload.Eisenhower = &translatedEisenhower
}
@@ -336,6 +379,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok && motivationArg != nil {
return reportMCPError("Invalid type for motivation argument: expected string.")
}
+
if ok {
if motivationStr != "" {
validMotivations := map[string]bool{"must": true, "should": true, "want": true}
@@ -343,6 +387,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
}
}
+
updatePayload.Motivation = &motivationStr
}
}
@@ -352,6 +397,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok && statusArg != nil {
return reportMCPError("Invalid type for status argument: expected string.")
}
+
if ok {
if statusStr != "" {
validStatus := map[string]bool{"later": true, "next": true, "started": true, "waiting": true, "completed": true}
@@ -359,6 +405,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
}
}
+
updatePayload.Status = &statusStr
}
}
@@ -368,16 +415,19 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
if !ok && scheduledOnArg != nil {
return reportMCPError("Invalid type for scheduled_on argument: expected string.")
}
+
if ok && scheduledOnStr != "" {
date, err := lunatask.ParseDate(scheduledOnStr)
if err != nil {
return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.", scheduledOnStr))
}
+
updatePayload.ScheduledOn = &date
}
}
client := lunatask.NewClient(h.config.AccessToken)
+
response, err := client.UpdateTask(ctx, taskID, &updatePayload)
if err != nil {
return reportMCPError(fmt.Sprintf("Failed to update task: %v", err))
@@ -387,7 +437,7 @@ func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolReq
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
- Text: fmt.Sprintf("Task updated successfully. ID: %s", response.ID),
+ Text: "Task updated successfully. ID: " + response.ID,
},
},
}, nil
@@ -401,6 +451,7 @@ func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolReq
}
client := lunatask.NewClient(h.config.AccessToken)
+
_, err := client.DeleteTask(ctx, taskID)
if err != nil {
return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
@@ -6,6 +6,7 @@ package tools
import (
"context"
+ "errors"
"fmt"
"strings"
"time"
@@ -59,15 +60,17 @@ func reportMCPError(msg string) (*mcp.CallToolResult, error) {
}, nil
}
-// LoadLocation loads a timezone location string, returning a *time.Location or error
+// LoadLocation loads a timezone location string, returning a *time.Location or error.
func LoadLocation(timezone string) (*time.Location, error) {
if timezone == "" {
- return nil, fmt.Errorf("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
+ return nil, errors.New("timezone is not configured; please set the 'timezone' value in your config file (e.g. 'UTC' or 'America/New_York')")
}
+
loc, err := time.LoadLocation(timezone)
if err != nil {
- return nil, fmt.Errorf("could not load timezone '%s': %v", timezone, err)
+ return nil, fmt.Errorf("could not load timezone '%s': %w", timezone, err)
}
+
return loc, nil
}
@@ -77,14 +80,17 @@ func (h *Handlers) HandleGetTimestamp(ctx context.Context, request mcp.CallToolR
if !ok || natLangDate == "" {
return reportMCPError("Missing or invalid required argument: natural_language_date")
}
+
loc, err := LoadLocation(h.config.Timezone)
if err != nil {
return reportMCPError(err.Error())
}
+
parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
if err != nil {
return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
}
+
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{
@@ -100,10 +106,12 @@ func (h *Handlers) HandleListAreasAndGoals(ctx context.Context, request mcp.Call
var b strings.Builder
for _, area := range h.config.Areas {
fmt.Fprintf(&b, "- %s: %s\n", area.GetName(), area.GetID())
+
for _, goal := range area.GetGoals() {
fmt.Fprintf(&b, " - %s: %s\n", goal.GetName(), goal.GetID())
}
}
+
return &mcp.CallToolResult{
Content: []mcp.Content{
mcp.TextContent{