@@ -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
+}
@@ -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{