diff --git a/lunatask/tasks.go b/lunatask/tasks.go index 19a7f2af7683e79440e77af2861d19e270f54e59..379007e2019ecf05838815c02601ac53f849fe0b 100644 --- a/lunatask/tasks.go +++ b/lunatask/tasks.go @@ -3,3 +3,157 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package lunatask + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/go-playground/validator/v10" +) + +// Client handles communication with the Lunatask API +type Client struct { + AccessToken string + BaseURL string + HTTPClient *http.Client +} + +// NewClient creates a new Lunatask API client +func NewClient(accessToken string) *Client { + return &Client{ + AccessToken: accessToken, + BaseURL: "https://api.lunatask.app/v1", + HTTPClient: &http.Client{}, + } +} + +// CreateTaskRequest represents the request to create a task in Lunatask +type CreateTaskRequest struct { + AreaID string `json:"area_id"` + GoalID string `json:"goal_id,omitempty" validate:"omitempty"` + Name string `json:"name" validate:"max=100"` + Note string `json:"note,omitempty" validate:"omitempty"` + Status string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"` + Motivation string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"` + Estimate int `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"` + Priority int `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"` + ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"` + CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"` + Source string `json:"source,omitempty" validate:"omitempty"` +} + +// CreateTaskResponse represents the response from Lunatask API when creating a task +type CreateTaskResponse struct { + Task struct { + ID string `json:"id"` + } `json:"task"` +} + +// ValidationError represents errors returned by the validator +type ValidationError struct { + Field string + Tag string + Message string +} + +// Error implements the error interface for ValidationError +func (e ValidationError) Error() string { + return e.Message +} + +// ValidateTask validates the create task request +func ValidateTask(task *CreateTaskRequest) error { + validate := validator.New() + if err := validate.Struct(task); err != nil { + var invalidValidationError *validator.InvalidValidationError + if errors.As(err, &invalidValidationError) { + return fmt.Errorf("invalid validation error: %w", err) + } + + var validationErrs validator.ValidationErrors + if errors.As(err, &validationErrs) { + var msgBuilder strings.Builder + msgBuilder.WriteString("task validation failed:") + for _, e := range validationErrs { + fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value()) + } + return errors.New(msgBuilder.String()) + } + return fmt.Errorf("validation error: %w", err) + } + return nil +} + +// CreateTask creates a new task in Lunatask +func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*CreateTaskResponse, error) { + // Validate the task + if err := ValidateTask(task); err != nil { + return nil, err + } + + // Marshal the task to JSON + payloadBytes, err := json.Marshal(task) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + // Create the request + req, err := http.NewRequestWithContext( + ctx, + "POST", + fmt.Sprintf("%s/tasks", c.BaseURL), + bytes.NewBuffer(payloadBytes), + ) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + // Set headers + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "bearer "+c.AccessToken) + + // Send the request + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send HTTP request: %w", err) + } + defer func() { + if resp.Body != nil { + if err := resp.Body.Close(); err != nil { + // We're in a defer, so we can only log the error + fmt.Printf("Error closing response body: %v\n", err) + } + } + }() + + // Handle already exists (no content) case + if resp.StatusCode == http.StatusNoContent { + return nil, nil // Task already exists (not an error) + } + + // Handle error status codes + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + // Read and parse the response + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var response CreateTaskResponse + err = json.Unmarshal(respBody, &response) + if err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &response, nil +} diff --git a/main.go b/main.go index 432a307b85662df031aa42cf6dee0901025c0f6b..47f9c9ef128add5894d07e4600e3d0f824bba6ea 100644 --- a/main.go +++ b/main.go @@ -5,24 +5,21 @@ package main import ( - "bytes" "context" "encoding/json" - "errors" "fmt" - "io" "log" - "net/http" "os" "strings" "time" - "github.com/go-playground/validator/v10" "github.com/ijt/go-anytime" "github.com/BurntSushi/toml" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + + "git.sr.ht/~amolith/lunatask-mcp-server/lunatask" ) // Goal represents a Lunatask goal with its name and ID @@ -122,14 +119,6 @@ func loadLocation(timezone string) (*time.Location, error) { return loc, nil } -// closeResponseBody properly closes an HTTP response body, handling any errors -func closeResponseBody(resp *http.Response) { - err := resp.Body.Close() - if err != nil { - log.Printf("Error closing response body: %v", err) - } -} - // closeFile properly closes a file, handling any errors func closeFile(f *os.File) { err := f.Close() @@ -267,28 +256,6 @@ func NewMCPServer(config *Config) *server.MCPServer { return mcpServer } -// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask -type LunataskCreateTaskRequest struct { - AreaID string `json:"area_id"` - GoalID string `json:"goal_id,omitempty" validate:"omitempty"` - Name string `json:"name" validate:"max=100"` - Note string `json:"note,omitempty" validate:"omitempty"` - Status string `json:"status,omitempty" validate:"omitempty,oneof=later next started waiting completed"` - Motivation string `json:"motivation,omitempty" validate:"omitempty,oneof=must should want unknown"` - Estimate int `json:"estimate,omitempty" validate:"omitempty,min=0,max=720"` - Priority int `json:"priority,omitempty" validate:"omitempty,min=-2,max=2"` - ScheduledOn string `json:"scheduled_on,omitempty" validate:"omitempty"` - CompletedAt string `json:"completed_at,omitempty" validate:"omitempty"` - Source string `json:"source,omitempty" validate:"omitempty"` -} - -// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task -type LunataskCreateTaskResponse struct { - Task struct { - ID string `json:"id"` - } `json:"task"` -} - func reportMCPError(msg string) (*mcp.CallToolResult, error) { return &mcp.CallToolResult{ IsError: true, @@ -387,59 +354,27 @@ func handleCreateTask( // If it's an empty string, it's handled by the API or omitempty later, no need to validate format. } - var payload LunataskCreateTaskRequest + // Create Lunatask client + client := lunatask.NewClient(config.AccessToken) + + // Prepare the task request + var task lunatask.CreateTaskRequest argBytes, err := json.Marshal(arguments) if err != nil { return reportMCPError(fmt.Sprintf("Failed to process arguments: %v", err)) } - if err := json.Unmarshal(argBytes, &payload); err != nil { + if err := json.Unmarshal(argBytes, &task); err != nil { return reportMCPError(fmt.Sprintf("Failed to parse arguments: %v", err)) } - validate := validator.New() - if err := validate.Struct(payload); err != nil { - var invalidValidationError *validator.InvalidValidationError - if errors.As(err, &invalidValidationError) { - return reportMCPError(fmt.Sprintf("Invalid validation error: %v", err)) - } - var validationErrs validator.ValidationErrors - if errors.As(err, &validationErrs) { - var msgBuilder strings.Builder - msgBuilder.WriteString("task validation failed:") - for _, e := range validationErrs { - fmt.Fprintf(&msgBuilder, " field '%s' failed '%s' validation (was: %v);", e.Field(), e.Tag(), e.Value()) - } - return reportMCPError(msgBuilder.String()) - } - return reportMCPError(fmt.Sprintf("Validation error: %v", err)) - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err)) - } - - req, err := http.NewRequestWithContext( - ctx, - "POST", - "https://api.lunatask.app/v1/tasks", - bytes.NewBuffer(payloadBytes), - ) + // Call the client to create the task + response, err := client.CreateTask(ctx, &task) if err != nil { - return reportMCPError(fmt.Sprintf("Failed to create HTTP request: %v", err)) + return reportMCPError(fmt.Sprintf("%v", err)) } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "bearer "+config.AccessToken) - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err)) - } - defer closeResponseBody(resp) - - if resp.StatusCode == http.StatusNoContent { + // Handle the case where task already exists + if response == nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ @@ -450,27 +385,6 @@ func handleCreateTask( }, nil } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(resp.Body) - log.Printf("Lunatask API error (status %d): %s", resp.StatusCode, string(respBody)) - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{mcp.TextContent{Type: "text", Text: fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(respBody))}}, - }, nil - } - - var response LunataskCreateTaskResponse - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err)) - } - - err = json.Unmarshal(respBody, &response) - if err != nil { - return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err)) - } - return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{