@@ -4,11 +4,17 @@ 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"
@@ -21,25 +27,23 @@ type Area struct {
ID string `toml:"id"`
}
-// Config holds the application's configuration loaded from TOML
-type Config struct {
- AccessToken string `toml:"access_token"`
- Areas []Area `toml:"areas"`
+// Goal represents a Lunatask goal with its name and ID
+type Goal struct {
+ Name string `toml:"name"`
+ ID string `toml:"id"`
}
-
-// LunataskCreateTaskRequest represents the request payload for creating a task in Lunatask
-type LunataskCreateTaskRequest struct {
- Name string `json:"name"`
- Source string `json:"source"`
- AreaID string `json:"area_id"`
+// Config holds the application's configuration loaded from TOML
+type ServerConfig struct {
+ Host string `toml:"host"`
+ Port int `toml:"port"`
}
-// LunataskCreateTaskResponse represents the response from Lunatask API when creating a task
-type LunataskCreateTaskResponse struct {
- Task struct {
- ID string `json:"id"`
- } `json:"task"`
+type Config struct {
+ AccessToken string `toml:"access_token"`
+ Areas []Area `toml:"areas"`
+ Goals []Goal `toml:"goals"`
+ Server ServerConfig `toml:"server"`
}
func main() {
@@ -53,21 +57,40 @@ 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)
}
- if config.AccessToken == "" || config.AreaID == "" {
- log.Fatalf("Config file must provide access_token and area_id")
+ 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)
+ }
}
mcpServer := NewMCPServer(&config)
- sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL("http://localhost:8080"))
- log.Printf("SSE server listening on :8080")
- if err := sseServer.Start(":8080"); err != nil {
+ baseURL := fmt.Sprintf("http://%s:%d", config.Server.Host, config.Server.Port)
+ 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)
}
}
@@ -99,17 +122,57 @@ func NewMCPServer(config *Config) *server.MCPServer {
mcpServer := server.NewMCPServer(
"Lunatask MCP Server",
- "1.0.0",
+ "0.1.0",
server.WithHooks(hooks),
)
- // Pass config to the handler through closure
+ mcpServer.AddTool(mcp.NewTool("get_date_for_task",
+ mcp.WithDescription("Retrieves the formatted date 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.Required(),
+ ),
+ ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
+ if !ok || natLangDate == "" {
+ return reportMCPError("Missing or invalid required argument: natural_language_date")
+ }
+ parsedTime, err := anytime.Parse(natLangDate, time.Now())
+ if err != nil {
+ return reportMCPError(fmt.Sprintf("Could not parse natural language date: %v", err))
+ }
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: parsedTime.Format(time.RFC3339),
+ },
+ },
+ }, 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.Required(),
+ ),
+ mcp.WithString("goal_id",
+ mcp.Description("Goal the task should be associated with"),
+ ),
mcp.WithString("name",
- mcp.Description("Name of the task"),
+ mcp.Description("Plain text task name"),
mcp.Required(),
),
+ mcp.WithString("note",
+ mcp.Description("Note attached to the task, optionally Markdown-formatted"),
+ ),
+ mcp.WithNumber("estimate",
+ mcp.Description("Estimated time to completion in minutes"),
+ ),
+ mcp.WithString("scheduled_on",
+ mcp.Description("Natural language date the task is scheduled on"),
+ ),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return handleCreateTask(ctx, request, config)
})
@@ -117,29 +180,77 @@ 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"`
+ Name string `json:"name" validate:"max=100"`
+ Note string `json:"note,omitempty"`
+ Status string `json:"status,omitempty" validate:"oneof=later next started waiting completed"`
+ Motivation string `json:"motivation,omitempty" validate:"oneof=must should want unknown"`
+ Estimate int `json:"estimate,omitempty" validate:"min=0,max=720"`
+ Priority int `json:"priority,omitempty" validate:"min=-2,max=2"`
+ ScheduledOn string `json:"scheduled_on,omitempty"`
+ CompletedAt string `json:"completed_at,omitempty"`
+ Source string `json:"source,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,
+ Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
+ }, nil
+}
+
+// handleCreateTask handles the creation of a task in Lunatask
func handleCreateTask(
ctx context.Context,
request mcp.CallToolRequest,
config *Config,
) (*mcp.CallToolResult, error) {
- // Extract the name parameter from the request
arguments := request.Params.Arguments
- name, ok := arguments["name"].(string)
- if !ok {
- return nil, fmt.Errorf("invalid value for argument 'name'")
- }
- // Create the payload for the Lunatask API using our struct
payload := LunataskCreateTaskRequest{
- Name: name,
Source: "lmcps",
- AreaID: config.AreaID,
+ }
+ 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 {
+ 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
+ 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))
}
// Convert the payload to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
- return nil, fmt.Errorf("failed to marshal payload: %w", err)
+ return reportMCPError(fmt.Sprintf("Failed to marshal payload: %v", err))
}
// Create the HTTP request
@@ -150,18 +261,18 @@ func handleCreateTask(
bytes.NewBuffer(payloadBytes),
)
if err != nil {
- return nil, fmt.Errorf("failed to create HTTP request: %w", err)
+ 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)
+ req.Header.Set("Authorization", "bearer "+config.AccessToken)
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
- return nil, fmt.Errorf("failed to send HTTP request: %w", err)
+ return reportMCPError(fmt.Sprintf("Failed to send HTTP request: %v", err))
}
defer resp.Body.Close()
@@ -171,7 +282,7 @@ func handleCreateTask(
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
- Text: "Duplicate task found, no new task created.",
+ Text: "Task already exists (not an error).",
},
},
}, nil
@@ -180,7 +291,10 @@ func handleCreateTask(
// Check for error responses
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))
+ 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
}
// Parse the response
@@ -188,12 +302,12 @@ func handleCreateTask(
respBody, err := io.ReadAll(resp.Body)
if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
+ return reportMCPError(fmt.Sprintf("Failed to read response body: %v", err))
}
err = json.Unmarshal(respBody, &response)
if err != nil {
- return nil, fmt.Errorf("failed to parse response: %w", err)
+ return reportMCPError(fmt.Sprintf("Failed to parse response: %v", err))
}
// Return success result
@@ -201,8 +315,36 @@ func handleCreateTask(
Content: []mcp.Content{
mcp.TextContent{
Type: "text",
- Text: fmt.Sprintf("Task created successfully! Task ID: %s", response.Task.ID),
+ Text: fmt.Sprint("Task created successfully.", response.Task.ID),
},
},
}, nil
}
+
+func createDefaultConfigFile(configPath string) {
+ defaultConfig := Config{
+ Server: ServerConfig{
+ Host: "localhost",
+ Port: 8080,
+ },
+ AccessToken: "",
+ Areas: []Area{{
+ Name: "Example Area",
+ ID: "area-id-placeholder",
+ }},
+ Goals: []Goal{{
+ Name: "Example Goal",
+ ID: "goal-id-placeholder",
+ }},
+ }
+ file, err := os.Create(configPath)
+ if err != nil {
+ log.Fatalf("Failed to create default config at %s: %v", configPath, err)
+ }
+ defer file.Close()
+ 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)
+ os.Exit(1)
+}