diff --git a/main.go b/main.go index b219f94aab0abfc2df83a85aafc368ef2b05ca5b..252c2d759fdb9951b16550703d24005e0d00579f 100644 --- a/main.go +++ b/main.go @@ -48,6 +48,7 @@ type Config struct { AccessToken string `toml:"access_token"` Areas []Area `toml:"areas"` Server ServerConfig `toml:"server"` + Timezone string `toml:"timezone"` } func main() { @@ -84,6 +85,11 @@ func main() { } } + // Validate timezone config on startup + if _, err := loadLocation(config.Timezone); err != nil { + log.Fatalf("Timezone validation failed: %v", err) + } + mcpServer := NewMCPServer(&config) baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port) @@ -95,6 +101,18 @@ func main() { } } +// 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')") + } + loc, err := time.LoadLocation(timezone) + if err != nil { + return nil, fmt.Errorf("Could not load timezone '%s': %v", timezone, err) + } + return loc, nil +} + func NewMCPServer(config *Config) *server.MCPServer { hooks := &server.Hooks{} @@ -137,7 +155,11 @@ func NewMCPServer(config *Config) *server.MCPServer { if !ok || natLangDate == "" { return reportMCPError("Missing or invalid required argument: natural_language_date") } - parsedTime, err := anytime.Parse(natLangDate, time.Now()) + loc, err := loadLocation(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)) } @@ -241,6 +263,11 @@ func handleCreateTask( ) (*mcp.CallToolResult, error) { arguments := request.Params.Arguments + // Validate timezone before proceeding any further + if _, err := loadLocation(config.Timezone); err != nil { + return reportMCPError(err.Error()) + } + areaID, ok := arguments["area_id"].(string) if !ok || areaID == "" { return reportMCPError("Missing or invalid required argument: area_id") @@ -270,6 +297,19 @@ func handleCreateTask( } } + // Validate scheduled_on format if provided + if scheduledOnArg, exists := arguments["scheduled_on"]; exists { + if scheduledOnStr, ok := scheduledOnArg.(string); ok && scheduledOnStr != "" { + if _, err := time.Parse(time.RFC3339, scheduledOnStr); err != nil { + return reportMCPError(fmt.Sprintf("Invalid format for scheduled_on: '%s'. Must be RFC3339 timestamp (e.g., YYYY-MM-DDTHH:MM:SSZ). Use get_task_timestamp tool first.", scheduledOnStr)) + } + } else if !ok { + // It exists but isn't a string, which shouldn't happen based on MCP schema but check anyway + return reportMCPError("Invalid type for scheduled_on argument: expected string.") + } + // If it's an empty string, it's handled by the API or omitempty later, no need to validate format. + } + var payload LunataskCreateTaskRequest argBytes, err := json.Marshal(arguments) if err != nil { @@ -371,6 +411,7 @@ func createDefaultConfigFile(configPath string) { Port: 8080, }, AccessToken: "", + Timezone: "UTC", Areas: []Area{{ Name: "Example Area", ID: "area-id-placeholder", @@ -388,6 +429,6 @@ func createDefaultConfigFile(configPath string) { 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) + 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) }