Detailed changes
@@ -115,13 +115,44 @@ linters:
- wrapcheck
- wsl_v5
- zerologlint
+ - prealloc
disable:
- depguard
- - noinlineerr
settings:
+ exhaustruct:
+ exclude:
+ # External types where zero values are intentional by library design
+ - github.com/mark3labs/mcp-go/server.Hooks
+ - github.com/mark3labs/mcp-go/mcp.CallToolResult
+ - github.com/mark3labs/mcp-go/mcp.TextContent
+ - git.secluded.site/go-lunatask.CreateTaskRequest
+ - git.secluded.site/go-lunatask.UpdateTaskRequest
tagliatelle:
case:
rules:
json: snake
+ varnamelen:
+ ignore-names:
+ - c # client
+ - f # file handle
+ - id # entity ID
+ - ok # boolean check
+ - db # database
+ - tx # transaction
+ - fn # function
+
+ exclusions:
+ rules:
+ - path: cmd/
+ linters:
+ - gochecknoglobals # Cobra commands are package-level vars
+ - gochecknoinits # Cobra uses init() for flag registration
+ - wrapcheck # CLI returns errors for display, wrapping adds noise
+ - mnd # Magic numbers in cobra.ExactArgs are clear in context
+ - dupl # Builder types differ but share method signatures
+ - path: cmd/
+ text: unused-parameter # Cobra callback signatures can't be changed
+ - path: internal/config/
+ text: "0o700" # Config directory permissions are intentional
@@ -6,16 +6,26 @@ SPDX-License-Identifier: CC0-1.0
# AGENTS.md
-MCP server exposing Lunatask (task/habit tracker) to LLMs via SSE. Primary use case: Home Assistant voice assistant says "remind me to call mom tomorrow" β HA's LLM calls this server's `create_task` tool β task appears in Lunatask.
+MCP server exposing Lunatask to LLMs. Primary use case is letting the user say "remind me to call mom tomorrow" to their preferred MCP-supporting LLM, that LLM calls create_task, and the user sees the task in their desktop/mobile apps.
-All of Lunatask's API docs are local (uncommitted), so feel free to read them _liberally_ while working on the client. The index, telling you which files to read for which topics, is in lunatask/docs/index.md
+If you know what doc-agent is, use it to check go module APIs often. You can check context7 for details on various libraries directly if the MCP tools are available:
+
+- [link](target) (`/context7/id`) - description
+- [lunatask.app/api](https://lunatask.app/api) (`/websites/lunatask_app_api`) - Official Lunatask REST API documentation
+- [git.secluded.site/go-lunatask](https://git.secluded.site/go-lunatask) (`/websites/pkg_go_dev_git_secluded_site_go-lunatask`) - Go client for the Lunatask API
+- [github.com/charmbracelet/fang](https://github.com/charmbracelet/fang) (`/charmbracelet/fang`) - CLI starter kit with styled help, error handling, and version management for Cobra
+- [github.com/BurntSushi/toml](https://github.com/BurntSushi/toml) (`/burntsushi/toml`) - TOML parser and encoder for Go
+- [github.com/spf13/cobra](https://github.com/spf13/cobra) (`/websites/pkg_go_dev_github_com_spf13_cobra`) - CLI framework for Go
+- [github.com/markusmobius/go-dateparser](https://github.com/markusmobius/go-dateparser) (`/markusmobius/go-dateparser`) - Natural language date parsing supporting 200+ locales
+- [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) (`/modelcontextprotocol/go-sdk`) - Official Go SDK for the Model Context Protocol, providing APIs for constructing and using MCP clients and servers
## Commands
+Run in order of most to least often
+
```sh
-just # Run all checks (fmt, lint, staticcheck, test, vuln, reuse)
-just build # Build with version info
-just run # Build and run
+task fmt lint:fix test # frequently
+task # before committing
```
## Architecture
@@ -25,21 +35,20 @@ cmd/lunatask-mcp-server.go β Config, tool registration, SSE server
tools/ β MCP tool handlers
```
-**Data flow**: SSE request β MCP server β tool handler β lunatask client β Lunatask API
+**Data flow**: SSE/STDIO request β MCP server β tool handler β lunatask client β Lunatask API
-**Domain model**: Areas contain Goals. Habits are separate. Tasks belong to an Area and optionally a Goal.
+**Domain model**: Areas contain Goals. Habits are separate. Tasks always belong to an Area and might belong to a Goal within that area.
## Adding New Functionality
-**New MCP tool**:
+**New MCP tool**:
+
1. Add handler method to `tools/` (on `Handlers` struct)
2. Register tool with `mcpServer.AddTool()` in `cmd/lunatask-mcp-server.go`
-**New API endpoint**: Add method to `lunatask/Client` with request/response types
-
## Key Patterns
-- **Tool descriptions are prompts**: The verbose `mcp.WithDescription()` strings guide the *calling* LLM's behaviorβthey explain workflows, valid values, and when to use each tool
+- **Tool descriptions are prompts**: The verbose `mcp.WithDescription()` strings guide the _calling_ LLM's behaviorβthey explain workflows, valid values, and when to use each tool
- **Error returns**: Handlers return `(result, nil)` with `IsError: true`βapplication errors go in the result, not the Go error
- **Enum translation**: Human strings (`"high"`) β API integers (`1`) in handlers before calling client
- **Provider interfaces**: `tools/` defines interfaces (`AreaProvider`, etc.) that `cmd/` types implement, keeping packages decoupled
@@ -48,6 +57,5 @@ tools/ β MCP tool handlers
- Area/goal/habit IDs are static in config, not fetchedβLunatask API has no list endpoint
- `CreateTask` returns `(nil, nil)` on HTTP 204 (task already exists)βnot an error
-- `update_task` MCP tool requires `name` even for partial updates
- No tests exist yet
-- All files need SPDX headers (`just reuse` checks this)
+- All files need appropriate SPDX headers based on content (`task reuse` checks this)
@@ -17,7 +17,11 @@ import (
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
- "git.sr.ht/~amolith/lunatask-mcp-server/tools"
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/areas"
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/habits"
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/tasks"
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/timestamp"
)
// Goal represents a Lunatask goal with its name and ID.
@@ -45,11 +49,11 @@ func (a Area) GetName() string { return a.Name }
// GetID returns the area's ID.
func (a Area) GetID() string { return a.ID }
-// GetGoals returns the area's goals as a slice of tools.GoalProvider.
-func (a Area) GetGoals() []tools.GoalProvider {
- providers := make([]tools.GoalProvider, len(a.Goals))
+// GetGoals returns the area's goals as a slice of shared.GoalProvider.
+func (a Area) GetGoals() []shared.GoalProvider {
+ providers := make([]shared.GoalProvider, len(a.Goals))
for i, g := range a.Goals {
- providers[i] = g // Goal implements GoalProvider
+ providers[i] = g
}
return providers
@@ -73,6 +77,7 @@ type ServerConfig struct {
Port int `toml:"port"`
}
+// Config holds the application's configuration loaded from TOML.
type Config struct {
AccessToken string `toml:"access_token"`
Areas []Area `toml:"area"`
@@ -84,13 +89,30 @@ type Config struct {
var version = ""
func main() {
+ configPath := parseArgs()
+ config := loadConfig(configPath)
+ validateConfig(&config)
+
+ mcpServer := NewMCPServer(&config)
+
+ hostPort := net.JoinHostPort(config.Server.Host, strconv.Itoa(config.Server.Port))
+ baseURL := "http://" + hostPort
+ sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
+ log.Printf("SSE server listening on %s (baseURL: %s)", hostPort, baseURL)
+
+ if err := sseServer.Start(hostPort); err != nil {
+ log.Fatalf("Server error: %v", err)
+ }
+}
+
+func parseArgs() string {
configPath := "./config.toml"
for argIdx, 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"
+ version = "unknown, build with `task build` or see Taskfile.yaml"
}
slog.Info("version", "name", "lunatask-mcp-server", "version", version)
@@ -102,6 +124,10 @@ func main() {
}
}
+ return configPath
+}
+
+func loadConfig(configPath string) Config {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
createDefaultConfigFile(configPath)
}
@@ -111,6 +137,10 @@ func main() {
log.Fatalf("Failed to load config file %s: %v", configPath, err)
}
+ return config
+}
+
+func validateConfig(config *Config) {
if config.AccessToken == "" || len(config.Areas) == 0 {
log.Fatalf("Config file must provide access_token and at least one area.")
}
@@ -122,221 +152,290 @@ func main() {
for goalIdx, goal := range area.Goals {
if goal.Name == "" || goal.ID == "" {
- log.Fatalf("All goals (areas[%d].goals[%d]) must have both a name and id", areaIdx, goalIdx)
+ log.Fatalf(
+ "All goals (areas[%d].goals[%d]) must have both a name and id",
+ areaIdx,
+ goalIdx,
+ )
}
}
}
- // Validate timezone config on startup
- if _, err := tools.LoadLocation(config.Timezone); err != nil {
+ if _, err := shared.LoadLocation(config.Timezone); err != nil {
log.Fatalf("Timezone validation failed: %v", err)
}
-
- mcpServer := NewMCPServer(&config)
-
- hostPort := net.JoinHostPort(config.Server.Host, strconv.Itoa(config.Server.Port))
- baseURL := "http://" + hostPort
- sseServer := server.NewSSEServer(mcpServer, server.WithBaseURL(baseURL))
- listenAddr := hostPort
- 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.
func closeFile(f *os.File) {
- err := f.Close()
- if err != nil {
+ if err := f.Close(); err != nil {
log.Printf("Error closing file: %v", err)
}
}
+// NewMCPServer creates and configures the MCP server with all tools.
func NewMCPServer(appConfig *Config) *server.MCPServer {
+ mcpServer := server.NewMCPServer(
+ "Lunatask MCP Server",
+ "0.1.0",
+ server.WithHooks(createHooks()),
+ server.WithToolCapabilities(true),
+ )
+
+ areaProviders := toAreaProviders(appConfig.Areas)
+ habitProviders := toHabitProviders(appConfig.Habit)
+
+ registerTimestampTool(mcpServer, appConfig.Timezone)
+ registerAreasTool(mcpServer, areaProviders)
+ registerTaskTools(mcpServer, appConfig.AccessToken, appConfig.Timezone, areaProviders)
+ registerHabitTools(mcpServer, appConfig.AccessToken, habitProviders)
+
+ return mcpServer
+}
+
+func createHooks() *server.Hooks {
hooks := &server.Hooks{}
- hooks.AddBeforeAny(func(ctx context.Context, id any, method mcp.MCPMethod, message any) {
+ hooks.AddBeforeAny(func(_ context.Context, id any, method mcp.MCPMethod, message any) {
slog.Debug("beforeAny", "method", method, "id", id, "message", message)
})
- hooks.AddOnSuccess(func(ctx context.Context, id any, method mcp.MCPMethod, message any, result any) {
- slog.Debug("onSuccess", "method", method, "id", id, "message", message, "result", result)
- })
- hooks.AddOnError(func(ctx context.Context, id any, method mcp.MCPMethod, message any, err error) {
- slog.Error("onError", "method", method, "id", id, "message", message, "error", err)
- })
- hooks.AddBeforeInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest) {
+ hooks.AddOnSuccess(
+ func(_ context.Context, id any, method mcp.MCPMethod, message any, result any) {
+ slog.Debug("onSuccess",
+ "method", method, "id", id, "message", message, "result", result)
+ },
+ )
+ hooks.AddOnError(
+ func(_ context.Context, id any, method mcp.MCPMethod, message any, err error) {
+ slog.Error("onError", "method", method, "id", id, "message", message, "error", err)
+ },
+ )
+ hooks.AddBeforeInitialize(func(_ context.Context, id any, message *mcp.InitializeRequest) {
slog.Debug("beforeInitialize", "id", id, "message", message)
})
- hooks.AddAfterInitialize(func(ctx context.Context, id any, message *mcp.InitializeRequest, result *mcp.InitializeResult) {
- slog.Debug("afterInitialize", "id", id, "message", message, "result", result)
- })
- hooks.AddAfterCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest, result *mcp.CallToolResult) {
- slog.Debug("afterCallTool", "id", id, "message", message, "result", result)
- })
- hooks.AddBeforeCallTool(func(ctx context.Context, id any, message *mcp.CallToolRequest) {
+ hooks.AddAfterInitialize(
+ func(_ context.Context, id any, msg *mcp.InitializeRequest, res *mcp.InitializeResult) {
+ slog.Debug("afterInitialize", "id", id, "message", msg, "result", res)
+ },
+ )
+ hooks.AddAfterCallTool(
+ func(_ context.Context, id any, msg *mcp.CallToolRequest, res *mcp.CallToolResult) {
+ slog.Debug("afterCallTool", "id", id, "message", msg, "result", res)
+ },
+ )
+ hooks.AddBeforeCallTool(func(_ context.Context, id any, message *mcp.CallToolRequest) {
slog.Debug("beforeCallTool", "id", id, "message", message)
})
- mcpServer := server.NewMCPServer(
- "Lunatask MCP Server",
- "0.1.0",
- server.WithHooks(hooks),
- server.WithToolCapabilities(true),
- )
+ return hooks
+}
- // Prepare AreaProviders
- areaProviders := make([]tools.AreaProvider, len(appConfig.Areas))
- for i, area := range appConfig.Areas {
- areaProviders[i] = area // Area implements AreaProvider
+func toAreaProviders(areas []Area) []shared.AreaProvider {
+ providers := make([]shared.AreaProvider, len(areas))
+ for i, area := range areas {
+ providers[i] = area
}
- // Prepare HabitProviders
- habitProviders := make([]tools.HabitProvider, len(appConfig.Habit))
- for i, habit := range appConfig.Habit {
- habitProviders[i] = habit // Habit implements HabitProvider
- }
+ return providers
+}
- handlerCfg := tools.HandlerConfig{
- AccessToken: appConfig.AccessToken,
- Timezone: appConfig.Timezone,
- Areas: areaProviders,
- Habits: habitProviders,
+func toHabitProviders(habits []Habit) []shared.HabitProvider {
+ providers := make([]shared.HabitProvider, len(habits))
+ for i, habit := range habits {
+ providers[i] = habit
}
- toolHandlers := tools.NewHandlers(handlerCfg)
- mcpServer.AddTool(mcp.NewTool("get_timestamp",
- mcp.WithDescription("Parses natural language dates into formatted timestamps for task scheduling. Use this tool when creating or updating tasks that include dates or times (after using list_areas_and_goals first). Supports relative dates (e.g., 'tomorrow', '1 week', 'next friday'), absolute dates (e.g., 'january 15', '2024-03-10'), and times (e.g., 'at 2pm', 'sunday at 19:00'). Also accepts 'now' for current timestamp."),
- mcp.WithString("natural_language_date",
- mcp.Description("Natural language date expression. Examples: 'tomorrow', '1 week', 'sunday at 19:00', 'january 15 at 2pm', 'next friday', 'now'. The tool will parse this into a properly formatted timestamp for use with task scheduling."),
- mcp.Required(),
- ),
- ), toolHandlers.HandleGetTimestamp)
+ return providers
+}
+
+func registerTimestampTool(mcpServer *server.MCPServer, timezone string) {
+ handler := timestamp.NewHandler(timezone)
mcpServer.AddTool(
- mcp.NewTool(
- "list_areas_and_goals",
- mcp.WithDescription("Lists all available areas and their associated goals with their IDs. Use this tool FIRST before creating or updating tasks to identify valid area_id and goal_id values. Areas represent broad categories of work, and goals are specific objectives within those areas. Each task must belong to an area and can optionally be associated with a goal within that area."),
+ mcp.NewTool("get_timestamp",
+ mcp.WithDescription(timestamp.ToolDescription),
+ mcp.WithString("natural_language_date",
+ mcp.Description(timestamp.ParamNaturalLanguageDate),
+ mcp.Required(),
+ ),
),
- toolHandlers.HandleListAreasAndGoals,
+ handler.Handle,
)
+}
- mcpServer.AddTool(mcp.NewTool("create_task",
- mcp.WithDescription("Creates a new task in Lunatask. WORKFLOW: First use list_areas_and_goals to identify valid area_id and goal_id values, then use get_timestamp if scheduling the task. Only include optional parameters if the user indicates or hints at them. Try to interpret speech-to-text input that may not be entirely accurate."),
- mcp.WithString("area_id",
- mcp.Description("Area ID in which to create the task. Must be a valid area_id from list_areas_and_goals tool."),
- mcp.Required(),
- ),
- mcp.WithString("goal_id",
- mcp.Description("Optional goal ID to associate the task with. Must be a valid goal_id from list_areas_and_goals that belongs to the specified area. Only include if the task relates to a specific goal."),
- ),
- mcp.WithString("name",
- mcp.Description("Plain text task name using sentence case."),
- mcp.Required(),
- ),
- mcp.WithString("note",
- mcp.Description("Additional details or notes for the task, using Markdown formatting. Include any extra context, requirements, or information provided by the user that doesn't fit in the task name."),
- ),
- mcp.WithNumber("estimate",
- mcp.Description("Estimated completion time in minutes (0-720, max 12 hours). Only include if user mentions a time estimate like '30 minutes' (pass 30) or '2 hours' (pass 120). Omit if no estimate is provided."),
- mcp.Min(0),
- mcp.Max(720),
- ),
- mcp.WithString("priority",
- mcp.Description("Task priority level. Valid values: 'lowest', 'low', 'neutral', 'high', 'highest'. Only include if user explicitly mentions priority or urgency. Omit for normal tasks."),
- mcp.Enum("lowest", "low", "neutral", "high", "highest"),
- ),
- mcp.WithString("motivation",
- mcp.Description("Level of importance for the task. Valid values: 'must' (critical/required), 'should' (important), 'want' (nice-to-have). Only include if the user's language suggests strong obligation ('I need to', 'I have to') vs preference ('I'd like to', 'I want to')."),
- mcp.Enum("must", "should", "want"),
- ),
- mcp.WithString("eisenhower",
- mcp.Description("Eisenhower Matrix quadrant for task prioritization. Valid values: 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important', 'uncategorised'. Only include for areas which the user has indicated follow the Eisenhower workflow."),
- mcp.Enum("both urgent and important", "urgent, but not important", "important, but not urgent", "neither urgent nor important", "uncategorised"),
- ),
- mcp.WithString("status",
- mcp.Description("Initial task status. Valid values: 'later' (someday/backlog), 'next' (upcoming/soon), 'started' (in progress), 'waiting' (blocked), 'completed' (finished). Infer from context: 'working on' = 'started', 'soon'/'upcoming' = 'next', 'blocked'/'waiting for' = 'waiting'. Omit for normal new tasks (defaults to appropriate status)."),
- mcp.Enum("later", "next", "started", "waiting", "completed"),
- ),
- mcp.WithString("scheduled_on",
- mcp.Description("Scheduled date/time for the task. Must use the formatted timestamp returned by get_timestamp tool. Only include if user specifies when the task should be done."),
- ),
- ), toolHandlers.HandleCreateTask)
+func registerAreasTool(mcpServer *server.MCPServer, areaProviders []shared.AreaProvider) {
+ handler := areas.NewHandler(areaProviders)
- mcpServer.AddTool(mcp.NewTool("update_task",
- mcp.WithDescription("Updates an existing task. Only provided fields will be updated. WORKFLOW: Use list_areas_and_goals first if changing area/goal, then get_timestamp if changing schedule. Only include parameters that are being changed. Empty strings will clear existing values for text fields."),
- mcp.WithString("task_id",
- mcp.Description("ID of the task to update."),
- mcp.Required(),
- ),
- mcp.WithString("area_id",
- mcp.Description("New Area ID for the task. Must be a valid area_id from list_areas_and_goals tool. Only include if moving the task to a different area. If omitted, the task will remain in its current area."),
- ),
- mcp.WithString("goal_id",
- mcp.Description("New Goal ID for the task. Must be a valid goal_id from list_areas_and_goals that belongs to the task's area (current or new). Only include if changing the goal association."),
- ),
- mcp.WithString("name",
- mcp.Description("New plain text task name using sentence case. Sending an empty string WILL clear the name."),
- mcp.Required(),
- ),
- mcp.WithString("note",
- mcp.Description("New note for the task, using Markdown formatting. Sending an empty string WILL clear the existing note. Only include if changing the task notes."),
- ),
- mcp.WithNumber("estimate",
- mcp.Description("New estimated completion time in minutes (0-720, max 12 hours). Only include if user mentions changing the time estimate. Note: update_task has a lower maximum than create_task."),
- mcp.Min(0),
- mcp.Max(720),
- ),
- mcp.WithString("priority",
- mcp.Description("New task priority level. Valid values: 'lowest', 'low', 'neutral', 'high', 'highest'. Only include if user wants to change the priority."),
- mcp.Enum("lowest", "low", "neutral", "high", "highest"),
- ),
- mcp.WithString("motivation",
- mcp.Description("New level of importance for the task. Valid values: 'must' (critical/required), 'should' (important), 'want' (nice-to-have), or empty string to clear. Only include if changing the motivation level."),
- mcp.Enum("must", "should", "want", ""),
- ),
- mcp.WithString("eisenhower",
- mcp.Description("New Eisenhower Matrix quadrant for task prioritization. Valid values: 'both urgent and important', 'urgent, but not important', 'important, but not urgent', 'neither urgent nor important', 'uncategorised' (clears the field). Only include for areas which the user has indicated follow the Eisenhower workflow."),
- mcp.Enum("both urgent and important", "urgent, but not important", "important, but not urgent", "neither urgent nor important", "uncategorised"),
- ),
- mcp.WithString("status",
- mcp.Description("New task status. Valid values: 'later' (someday/backlog), 'next' (upcoming/soon), 'started' (in progress), 'waiting' (blocked), 'completed' (finished), or empty string to clear. Only include if changing the task status."),
- mcp.Enum("later", "next", "started", "waiting", "completed", ""),
- ),
- mcp.WithString("scheduled_on",
- mcp.Description("New scheduled date/time for the task. Must use the formatted timestamp returned by get_timestamp tool. Sending an empty string might clear the scheduled date. Only include if changing the schedule."),
+ mcpServer.AddTool(
+ mcp.NewTool("list_areas_and_goals",
+ mcp.WithDescription(areas.ToolDescription),
),
- ), toolHandlers.HandleUpdateTask)
+ handler.Handle,
+ )
+}
+
+func registerTaskTools(
+ mcpServer *server.MCPServer,
+ accessToken string,
+ timezone string,
+ areaProviders []shared.AreaProvider,
+) {
+ handler := tasks.NewHandler(accessToken, timezone, areaProviders)
+ registerCreateTaskTool(mcpServer, handler)
+ registerUpdateTaskTool(mcpServer, handler)
+ registerDeleteTaskTool(mcpServer, handler)
+}
- mcpServer.AddTool(mcp.NewTool("delete_task",
- mcp.WithDescription("Permanently deletes an existing task from Lunatask. This action cannot be undone."),
- mcp.WithString("task_id",
- mcp.Description("ID of the task to delete. This must be a valid task ID from an existing task in Lunatask."),
- mcp.Required(),
+func registerCreateTaskTool(mcpServer *server.MCPServer, handler *tasks.Handler) {
+ mcpServer.AddTool(
+ mcp.NewTool("create_task",
+ mcp.WithDescription(tasks.CreateToolDescription),
+ mcp.WithString("area_id",
+ mcp.Description(tasks.ParamAreaID),
+ mcp.Required(),
+ ),
+ mcp.WithString("goal_id",
+ mcp.Description(tasks.ParamGoalID),
+ ),
+ mcp.WithString("name",
+ mcp.Description(tasks.ParamName),
+ mcp.Required(),
+ ),
+ mcp.WithString("note",
+ mcp.Description(tasks.ParamNote),
+ ),
+ mcp.WithNumber("estimate",
+ mcp.Description(tasks.ParamEstimate),
+ mcp.Min(0),
+ mcp.Max(tasks.MaxEstimate),
+ ),
+ mcp.WithString("priority",
+ mcp.Description(tasks.ParamPriority),
+ mcp.Enum("lowest", "low", "neutral", "high", "highest"),
+ ),
+ mcp.WithString("motivation",
+ mcp.Description(tasks.ParamMotivation),
+ mcp.Enum("must", "should", "want"),
+ ),
+ mcp.WithString("eisenhower",
+ mcp.Description(tasks.ParamEisenhower),
+ mcp.Enum(
+ "both urgent and important",
+ "urgent, but not important",
+ "important, but not urgent",
+ "neither urgent nor important",
+ "uncategorised",
+ ),
+ ),
+ mcp.WithString("status",
+ mcp.Description(tasks.ParamStatus),
+ mcp.Enum("later", "next", "started", "waiting", "completed"),
+ ),
+ mcp.WithString("scheduled_on",
+ mcp.Description(tasks.ParamScheduledOn),
+ ),
),
- ), toolHandlers.HandleDeleteTask)
+ handler.HandleCreate,
+ )
+}
+func registerUpdateTaskTool(mcpServer *server.MCPServer, handler *tasks.Handler) {
mcpServer.AddTool(
- mcp.NewTool(
- "list_habits_and_activities",
- mcp.WithDescription("Lists all configured habits and their IDs for habit tracking. Use this tool FIRST before track_habit_activity to identify valid habit_id values. Shows habit names, descriptions, and unique identifiers needed for tracking activities."),
+ mcp.NewTool("update_task",
+ mcp.WithDescription(tasks.UpdateToolDescription),
+ mcp.WithString("task_id",
+ mcp.Description(tasks.ParamTaskID),
+ mcp.Required(),
+ ),
+ mcp.WithString("area_id",
+ mcp.Description(tasks.ParamUpdateAreaID),
+ ),
+ mcp.WithString("goal_id",
+ mcp.Description(tasks.ParamUpdateGoalID),
+ ),
+ mcp.WithString("name",
+ mcp.Description(tasks.ParamUpdateName),
+ mcp.Required(),
+ ),
+ mcp.WithString("note",
+ mcp.Description(tasks.ParamUpdateNote),
+ ),
+ mcp.WithNumber("estimate",
+ mcp.Description(tasks.ParamUpdateEstimate),
+ mcp.Min(0),
+ mcp.Max(tasks.MaxEstimate),
+ ),
+ mcp.WithString("priority",
+ mcp.Description(tasks.ParamUpdatePriority),
+ mcp.Enum("lowest", "low", "neutral", "high", "highest"),
+ ),
+ mcp.WithString("motivation",
+ mcp.Description(tasks.ParamUpdateMotivation),
+ mcp.Enum("must", "should", "want", ""),
+ ),
+ mcp.WithString("eisenhower",
+ mcp.Description(tasks.ParamUpdateEisenhower),
+ mcp.Enum(
+ "both urgent and important",
+ "urgent, but not important",
+ "important, but not urgent",
+ "neither urgent nor important",
+ "uncategorised",
+ ),
+ ),
+ mcp.WithString("status",
+ mcp.Description(tasks.ParamUpdateStatus),
+ mcp.Enum("later", "next", "started", "waiting", "completed", ""),
+ ),
+ mcp.WithString("scheduled_on",
+ mcp.Description(tasks.ParamUpdateScheduledOn),
+ ),
),
- toolHandlers.HandleListHabitsAndActivities,
+ handler.HandleUpdate,
)
+}
- mcpServer.AddTool(mcp.NewTool("track_habit_activity",
- mcp.WithDescription("Records completion of a habit activity in Lunatask. WORKFLOW: First use list_habits_and_activities to get valid habit_id, then use get_timestamp to format the performed_on date."),
- mcp.WithString("habit_id",
- mcp.Description("ID of the habit to track activity for. Must be a valid habit_id from list_habits_and_activities tool."),
- mcp.Required(),
+func registerDeleteTaskTool(mcpServer *server.MCPServer, handler *tasks.Handler) {
+ mcpServer.AddTool(
+ mcp.NewTool("delete_task",
+ mcp.WithDescription(tasks.DeleteToolDescription),
+ mcp.WithString("task_id",
+ mcp.Description(tasks.ParamDeleteTaskID),
+ mcp.Required(),
+ ),
),
- mcp.WithString("performed_on",
- mcp.Description("Timestamp when the habit was performed. Must use the formatted timestamp returned by get_timestamp tool. Examples: if user says 'I did this yesterday', use get_timestamp with 'yesterday'."),
- mcp.Required(),
+ handler.HandleDelete,
+ )
+}
+
+func registerHabitTools(
+ mcpServer *server.MCPServer,
+ accessToken string,
+ habitProviders []shared.HabitProvider,
+) {
+ handler := habits.NewHandler(accessToken, habitProviders)
+
+ mcpServer.AddTool(
+ mcp.NewTool("list_habits_and_activities",
+ mcp.WithDescription(habits.ListToolDescription),
),
- ), toolHandlers.HandleTrackHabitActivity)
+ handler.HandleList,
+ )
- return mcpServer
+ mcpServer.AddTool(
+ mcp.NewTool("track_habit_activity",
+ mcp.WithDescription(habits.TrackToolDescription),
+ mcp.WithString("habit_id",
+ mcp.Description(habits.ParamHabitID),
+ mcp.Required(),
+ ),
+ mcp.WithString("performed_on",
+ mcp.Description(habits.ParamPerformedOn),
+ mcp.Required(),
+ ),
+ ),
+ handler.HandleTrack,
+ )
}
func createDefaultConfigFile(configPath string) {
@@ -361,6 +460,7 @@ func createDefaultConfigFile(configPath string) {
}},
}
+ //nolint:gosec // user-provided config path is expected
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)
@@ -375,7 +475,7 @@ func createDefaultConfigFile(configPath string) {
slog.Info("default config created",
"path", configPath,
- "next_steps", "edit the config to provide your Lunatask access token, area/goal IDs, and timezone (IANA format, e.g. 'America/New_York'), then restart",
+ "next_steps", "edit config with access token, area/goal IDs, and timezone, then restart",
)
os.Exit(1)
}
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package areas provides the list_areas_and_goals MCP tool.
+package areas
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/mark3labs/mcp-go/mcp"
+
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+)
+
+// Handler handles area-related MCP tool calls.
+type Handler struct {
+ areas []shared.AreaProvider
+}
+
+// NewHandler creates a new areas Handler.
+func NewHandler(areas []shared.AreaProvider) *Handler {
+ return &Handler{areas: areas}
+}
+
+// Handle handles the list_areas_and_goals tool call.
+func (h *Handler) Handle(
+ _ context.Context,
+ _ mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+ var builder strings.Builder
+
+ for _, area := range h.areas {
+ fmt.Fprintf(&builder, "- %s: %s\n", area.GetName(), area.GetID())
+
+ for _, goal := range area.GetGoals() {
+ fmt.Fprintf(&builder, " - %s: %s\n", goal.GetName(), goal.GetID())
+ }
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: builder.String(),
+ },
+ },
+ }, nil
+}
@@ -0,0 +1,13 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package areas
+
+// ToolDescription describes the list_areas_and_goals tool for LLMs.
+const ToolDescription = `Lists all available areas and their associated goals with their IDs.
+Use this tool FIRST before creating or updating tasks to identify valid
+area_id and goal_id values. Areas represent broad categories of work,
+and goals are specific objectives within those areas. Each task must
+belong to an area and can optionally be associated with a goal within
+that area.`
@@ -1,69 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-// Package tools provides MCP tool handlers for Lunatask operations.
-package tools
-
-import (
- "context"
- "fmt"
- "strings"
-
- "git.secluded.site/go-lunatask"
- "github.com/mark3labs/mcp-go/mcp"
-)
-
-// HandleListHabitsAndActivities handles the list_habits_and_activities tool call.
-func (h *Handlers) HandleListHabitsAndActivities(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- var builder strings.Builder
- for _, habit := range h.config.Habits {
- fmt.Fprintf(&builder, "- %s: %s\n", habit.GetName(), habit.GetID())
- }
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{
- mcp.TextContent{
- Type: "text",
- Text: builder.String(),
- },
- },
- }, nil
-}
-
-// HandleTrackHabitActivity handles the track_habit_activity tool call.
-func (h *Handlers) HandleTrackHabitActivity(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- habitID, ok := request.Params.Arguments["habit_id"].(string)
- if !ok || habitID == "" {
- return reportMCPError("Missing or invalid required argument: habit_id")
- }
-
- performedOnStr, ok := request.Params.Arguments["performed_on"].(string)
- if !ok || performedOnStr == "" {
- return reportMCPError("Missing or invalid required argument: performed_on")
- }
-
- performedOn, err := lunatask.ParseDate(performedOnStr)
- if err != nil {
- return reportMCPError(fmt.Sprintf("Invalid format for performed_on: '%s'. Must be YYYY-MM-DD.", performedOnStr))
- }
-
- client := lunatask.NewClient(h.config.AccessToken)
- habitRequest := &lunatask.TrackHabitActivityRequest{
- PerformedOn: performedOn,
- }
-
- resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
- if err != nil {
- return reportMCPError(fmt.Sprintf("Failed to track habit activity: %v", err))
- }
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{
- mcp.TextContent{
- Type: "text",
- Text: fmt.Sprintf("Habit activity tracked successfully. Status: %s, Message: %s", resp.Status, resp.Message),
- },
- },
- }, nil
-}
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package habits provides MCP tools for habit tracking in Lunatask.
+package habits
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "git.secluded.site/go-lunatask"
+ "github.com/mark3labs/mcp-go/mcp"
+
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+)
+
+// Handler handles habit-related MCP tool calls.
+type Handler struct {
+ accessToken string
+ habits []shared.HabitProvider
+}
+
+// NewHandler creates a new habits Handler.
+func NewHandler(accessToken string, habits []shared.HabitProvider) *Handler {
+ return &Handler{
+ accessToken: accessToken,
+ habits: habits,
+ }
+}
+
+// HandleList handles the list_habits_and_activities tool call.
+func (h *Handler) HandleList(
+ _ context.Context,
+ _ mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+ var builder strings.Builder
+
+ for _, habit := range h.habits {
+ fmt.Fprintf(&builder, "- %s: %s\n", habit.GetName(), habit.GetID())
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: builder.String(),
+ },
+ },
+ }, nil
+}
+
+// HandleTrack handles the track_habit_activity tool call.
+//
+//nolint:wrapcheck // ReportError returns nil for error
+func (h *Handler) HandleTrack(
+ ctx context.Context,
+ request mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+ habitID, ok := request.Params.Arguments["habit_id"].(string)
+ if !ok || habitID == "" {
+ return shared.ReportError("Missing or invalid required argument: habit_id")
+ }
+
+ performedOnStr, ok := request.Params.Arguments["performed_on"].(string)
+ if !ok || performedOnStr == "" {
+ return shared.ReportError("Missing or invalid required argument: performed_on")
+ }
+
+ performedOn, err := lunatask.ParseDate(performedOnStr)
+ if err != nil {
+ return shared.ReportError(fmt.Sprintf(
+ "Invalid format for performed_on: '%s'. Must be YYYY-MM-DD.",
+ performedOnStr,
+ ))
+ }
+
+ client := lunatask.NewClient(h.accessToken)
+ habitRequest := &lunatask.TrackHabitActivityRequest{
+ PerformedOn: performedOn,
+ }
+
+ resp, err := client.TrackHabitActivity(ctx, habitID, habitRequest)
+ if err != nil {
+ return shared.ReportError(fmt.Sprintf("Failed to track habit activity: %v", err))
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: fmt.Sprintf(
+ "Habit activity tracked successfully. Status: %s, Message: %s",
+ resp.Status,
+ resp.Message,
+ ),
+ },
+ },
+ }, nil
+}
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package habits
+
+// ListToolDescription describes the list_habits_and_activities tool for LLMs.
+const ListToolDescription = `Lists all configured habits and their IDs for habit tracking.
+Use this tool FIRST before track_habit_activity to identify valid habit_id values.
+Shows habit names, descriptions, and unique identifiers needed for tracking activities.`
+
+// TrackToolDescription describes the track_habit_activity tool for LLMs.
+const TrackToolDescription = `Records completion of a habit activity in Lunatask.
+WORKFLOW: First use list_habits_and_activities to get valid habit_id,
+then use get_timestamp to format the performed_on date.`
+
+// ParamHabitID describes the habit_id parameter.
+const ParamHabitID = `ID of the habit to track activity for.
+Must be a valid habit_id from list_habits_and_activities tool.`
+
+// ParamPerformedOn describes the performed_on parameter.
+const ParamPerformedOn = `Timestamp when the habit was performed.
+Must use the formatted timestamp returned by get_timestamp tool.
+Examples: if user says 'I did this yesterday', use get_timestamp with 'yesterday'.`
@@ -0,0 +1,73 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package shared provides common interfaces, types, and helpers for MCP tools.
+package shared
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/mark3labs/mcp-go/mcp"
+)
+
+// ErrTimezoneNotConfigured is returned when the timezone config value is empty.
+var ErrTimezoneNotConfigured = errors.New(
+ "timezone is not configured; please set the 'timezone' value in your " +
+ "config file (e.g. 'UTC' or 'America/New_York')",
+)
+
+// AreaProvider defines the interface for accessing area data.
+type AreaProvider interface {
+ GetName() string
+ GetID() string
+ GetGoals() []GoalProvider
+}
+
+// GoalProvider defines the interface for accessing goal data.
+//
+//nolint:iface // semantically distinct from HabitProvider
+type GoalProvider interface {
+ GetName() string
+ GetID() string
+}
+
+// HabitProvider defines the interface for accessing habit data.
+//
+//nolint:iface // semantically distinct from GoalProvider
+type HabitProvider interface {
+ GetName() string
+ GetID() string
+}
+
+// Config holds the necessary configuration for tool handlers.
+type Config struct {
+ AccessToken string
+ Timezone string
+ Areas []AreaProvider
+ Habits []HabitProvider
+}
+
+// ReportError creates an MCP error result.
+func ReportError(msg string) (*mcp.CallToolResult, error) {
+ return &mcp.CallToolResult{
+ IsError: true,
+ Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
+ }, nil
+}
+
+// LoadLocation loads a timezone location string, returning a *time.Location or error.
+func LoadLocation(timezone string) (*time.Location, error) {
+ if timezone == "" {
+ return nil, ErrTimezoneNotConfigured
+ }
+
+ loc, err := time.LoadLocation(timezone)
+ if err != nil {
+ return nil, fmt.Errorf("could not load timezone '%s': %w", timezone, err)
+ }
+
+ return loc, nil
+}
@@ -1,468 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package tools
-
-import (
- "context"
- "fmt"
- "strings"
-
- "git.secluded.site/go-lunatask"
- "github.com/mark3labs/mcp-go/mcp"
-)
-
-// HandleCreateTask handles the create_task tool call.
-func (h *Handlers) HandleCreateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- arguments := request.Params.Arguments
-
- if _, err := LoadLocation(h.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")
- }
-
- 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")
- }
-
- 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
- }
-
- name, ok := arguments["name"].(string)
- if !ok || name == "" {
- return reportMCPError("Missing or invalid required argument: name")
- }
-
- if len(name) > 100 {
- return reportMCPError("'name' must be 100 characters or fewer")
- }
-
- task := lunatask.CreateTaskRequest{
- Name: name,
- AreaID: &areaID,
- GoalID: goalID,
- }
-
- if noteVal, exists := arguments["note"].(string); exists {
- task.Note = ¬eVal
- }
-
- if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
- priorityStr, ok := priorityArg.(string)
- if !ok {
- return reportMCPError("Invalid type for 'priority' argument: expected string.")
- }
-
- priorityMap := map[string]int{
- "lowest": -2,
- "low": -1,
- "neutral": 0,
- "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
- }
-
- if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
- eisenhowerStr, ok := eisenhowerArg.(string)
- if !ok {
- return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
- }
-
- eisenhowerMap := map[string]int{
- "uncategorised": 0,
- "both urgent and important": 1,
- "urgent, but not important": 2,
- "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
- }
-
- if motivationVal, exists := arguments["motivation"]; exists && motivationVal != nil {
- motivation, ok := motivationVal.(string)
- 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
- }
- }
-
- if statusVal, exists := arguments["status"]; exists && statusVal != nil {
- status, ok := statusVal.(string)
- 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
- }
- }
-
- if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
- estimateVal, ok := estimateArg.(float64)
- 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
- }
-
- if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
- scheduledOnStr, ok := scheduledOnArg.(string)
- 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
- }
- }
-
- if sourceArg, exists := arguments["source"].(string); exists && sourceArg != "" {
- task.Source = &sourceArg
- }
-
- if sourceIDArg, exists := arguments["source_id"].(string); exists && sourceIDArg != "" {
- task.SourceID = &sourceIDArg
- }
-
- client := lunatask.NewClient(h.config.AccessToken)
-
- response, err := client.CreateTask(ctx, &task)
- if err != nil {
- return reportMCPError(fmt.Sprintf("%v", err))
- }
-
- if response == nil {
- return &mcp.CallToolResult{
- Content: []mcp.Content{
- mcp.TextContent{
- Type: "text",
- Text: "Task already exists (not an error).",
- },
- },
- }, nil
- }
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{
- mcp.TextContent{
- Type: "text",
- Text: "Task created successfully with ID: " + response.ID,
- },
- },
- }, nil
-}
-
-// HandleUpdateTask handles the update_task tool call.
-func (h *Handlers) HandleUpdateTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- arguments := request.Params.Arguments
-
- taskID, ok := arguments["task_id"].(string)
- if !ok || taskID == "" {
- return reportMCPError("Missing or invalid required argument: task_id")
- }
-
- if _, err := LoadLocation(h.config.Timezone); err != nil {
- return reportMCPError(err.Error())
- }
-
- updatePayload := lunatask.UpdateTaskRequest{}
-
- var specifiedAreaProvider AreaProvider
-
- areaIDProvided := false
-
- if areaIDArg, exists := arguments["area_id"]; exists {
- areaIDStr, ok := areaIDArg.(string)
- 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("Area not found for given area_id: " + areaIDStr)
- }
- }
- }
-
- if goalIDArg, exists := arguments["goal_id"]; exists {
- goalIDStr, ok := goalIDArg.(string)
- 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))
- }
- } else if areaIDProvided {
- return reportMCPError("Internal error: area_id provided but area details not loaded for goal validation.")
- }
- }
- }
-
- nameArg := arguments["name"]
- if nameStr, ok := nameArg.(string); ok {
- 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.")
- }
-
- if noteArg, exists := arguments["note"]; exists {
- noteStr, ok := noteArg.(string)
- if !ok && noteArg != nil {
- return reportMCPError("Invalid type for note argument: expected string.")
- }
-
- if ok {
- updatePayload.Note = ¬eStr
- }
- }
-
- if estimateArg, exists := arguments["estimate"]; exists && estimateArg != nil {
- estimateVal, ok := estimateArg.(float64)
- 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
- }
-
- if priorityArg, exists := arguments["priority"]; exists && priorityArg != nil {
- priorityStr, ok := priorityArg.(string)
- if !ok {
- return reportMCPError("Invalid type for 'priority' argument: expected string.")
- }
-
- priorityMap := map[string]int{
- "lowest": -2,
- "low": -1,
- "neutral": 0,
- "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
- }
-
- if eisenhowerArg, exists := arguments["eisenhower"]; exists && eisenhowerArg != nil {
- eisenhowerStr, ok := eisenhowerArg.(string)
- if !ok {
- return reportMCPError("Invalid type for 'eisenhower' argument: expected string.")
- }
-
- eisenhowerMap := map[string]int{
- "uncategorised": 0,
- "both urgent and important": 1,
- "urgent, but not important": 2,
- "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
- }
-
- if motivationArg, exists := arguments["motivation"]; exists {
- motivationStr, ok := motivationArg.(string)
- 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}
- if !validMotivations[motivationStr] {
- return reportMCPError("'motivation' must be one of 'must', 'should', or 'want', or empty to clear.")
- }
- }
-
- updatePayload.Motivation = &motivationStr
- }
- }
-
- if statusArg, exists := arguments["status"]; exists {
- statusStr, ok := statusArg.(string)
- 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}
- if !validStatus[statusStr] {
- return reportMCPError("'status' must be one of 'later', 'next', 'started', 'waiting', 'completed', or empty.")
- }
- }
-
- updatePayload.Status = &statusStr
- }
- }
-
- if scheduledOnArg, exists := arguments["scheduled_on"]; exists {
- scheduledOnStr, ok := scheduledOnArg.(string)
- 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))
- }
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{
- mcp.TextContent{
- Type: "text",
- Text: "Task updated successfully. ID: " + response.ID,
- },
- },
- }, nil
-}
-
-// HandleDeleteTask handles the delete_task tool call.
-func (h *Handlers) HandleDeleteTask(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- taskID, ok := request.Params.Arguments["task_id"].(string)
- if !ok || taskID == "" {
- return reportMCPError("Missing or invalid required argument: task_id")
- }
-
- client := lunatask.NewClient(h.config.AccessToken)
-
- _, err := client.DeleteTask(ctx, taskID)
- if err != nil {
- return reportMCPError(fmt.Sprintf("Failed to delete task: %v", err))
- }
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{
- mcp.TextContent{
- Type: "text",
- Text: "Task deleted successfully.",
- },
- },
- }, nil
-}
@@ -0,0 +1,434 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package tasks
+
+import (
+ "fmt"
+
+ "git.secluded.site/go-lunatask"
+ "github.com/mark3labs/mcp-go/mcp"
+
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+)
+
+// setCreatePriority sets priority on a create task request.
+func (h *Handler) setCreatePriority(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ priorityArg, exists := arguments["priority"]
+ if !exists || priorityArg == nil {
+ return nil
+ }
+
+ priorityStr, ok := priorityArg.(string)
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for 'priority' argument: expected string.",
+ )
+
+ return result
+ }
+
+ translated, errResult := ParsePriority(priorityStr)
+ if errResult != nil {
+ return errResult
+ }
+
+ task.Priority = &translated
+
+ return nil
+}
+
+// setCreateEisenhower sets eisenhower on a create task request.
+func (h *Handler) setCreateEisenhower(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ eisenhowerArg, exists := arguments["eisenhower"]
+ if !exists || eisenhowerArg == nil {
+ return nil
+ }
+
+ eisenhowerStr, ok := eisenhowerArg.(string)
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for 'eisenhower' argument: expected string.",
+ )
+
+ return result
+ }
+
+ translated, errResult := ParseEisenhower(eisenhowerStr)
+ if errResult != nil {
+ return errResult
+ }
+
+ task.Eisenhower = &translated
+
+ return nil
+}
+
+// setCreateMotivation sets motivation on a create task request.
+func (h *Handler) setCreateMotivation(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ motivationVal, exists := arguments["motivation"]
+ if !exists || motivationVal == nil {
+ return nil
+ }
+
+ motivation, ok := motivationVal.(string)
+ if !ok {
+ result, _ := shared.ReportError("'motivation' must be a string")
+
+ return result
+ }
+
+ if motivation == "" {
+ return nil
+ }
+
+ if errResult := ValidateMotivation(motivation); errResult != nil {
+ return errResult
+ }
+
+ task.Motivation = &motivation
+
+ return nil
+}
+
+// setCreateStatus sets status on a create task request.
+func (h *Handler) setCreateStatus(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ statusVal, exists := arguments["status"]
+ if !exists || statusVal == nil {
+ return nil
+ }
+
+ status, ok := statusVal.(string)
+ if !ok {
+ result, _ := shared.ReportError("'status' must be a string")
+
+ return result
+ }
+
+ if status == "" {
+ return nil
+ }
+
+ if errResult := ValidateStatus(status); errResult != nil {
+ return errResult
+ }
+
+ task.Status = &status
+
+ return nil
+}
+
+// setCreateEstimate sets estimate on a create task request.
+func (h *Handler) setCreateEstimate(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ estimateArg, exists := arguments["estimate"]
+ if !exists || estimateArg == nil {
+ return nil
+ }
+
+ estimateVal, ok := estimateArg.(float64)
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for 'estimate' argument: expected number.",
+ )
+
+ return result
+ }
+
+ estimate := int(estimateVal)
+
+ if errResult := ValidateEstimate(estimate); errResult != nil {
+ return errResult
+ }
+
+ task.Estimate = &estimate
+
+ return nil
+}
+
+// setCreateScheduledOn sets scheduled_on on a create task request.
+func (h *Handler) setCreateScheduledOn(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ scheduledOnArg, exists := arguments["scheduled_on"]
+ if !exists {
+ return nil
+ }
+
+ scheduledOnStr, ok := scheduledOnArg.(string)
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for scheduled_on argument: expected string.",
+ )
+
+ return result
+ }
+
+ if scheduledOnStr == "" {
+ return nil
+ }
+
+ date, err := lunatask.ParseDate(scheduledOnStr)
+ if err != nil {
+ result, _ := shared.ReportError(fmt.Sprintf(
+ "Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.",
+ scheduledOnStr,
+ ))
+
+ return result
+ }
+
+ task.ScheduledOn = &date
+
+ return nil
+}
+
+// setCreateSource sets source fields on a create task request.
+func (h *Handler) setCreateSource(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) {
+ if sourceArg, exists := arguments["source"].(string); exists && sourceArg != "" {
+ task.Source = &sourceArg
+ }
+
+ if sourceIDArg, exists := arguments["source_id"].(string); exists && sourceIDArg != "" {
+ task.SourceID = &sourceIDArg
+ }
+}
+
+// setUpdateNote sets note on an update task request.
+func (h *Handler) setUpdateNote(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ noteArg, exists := arguments["note"]
+ if !exists {
+ return nil
+ }
+
+ noteStr, ok := noteArg.(string)
+ if !ok && noteArg != nil {
+ result, _ := shared.ReportError(
+ "Invalid type for note argument: expected string.",
+ )
+
+ return result
+ }
+
+ if ok {
+ payload.Note = ¬eStr
+ }
+
+ return nil
+}
+
+// setUpdateEstimate sets estimate on an update task request.
+func (h *Handler) setUpdateEstimate(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ estimateArg, exists := arguments["estimate"]
+ if !exists || estimateArg == nil {
+ return nil
+ }
+
+ estimateVal, ok := estimateArg.(float64)
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for estimate argument: expected number.",
+ )
+
+ return result
+ }
+
+ estimate := int(estimateVal)
+
+ if errResult := ValidateEstimate(estimate); errResult != nil {
+ return errResult
+ }
+
+ payload.Estimate = &estimate
+
+ return nil
+}
+
+// setUpdatePriority sets priority on an update task request.
+func (h *Handler) setUpdatePriority(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ priorityArg, exists := arguments["priority"]
+ if !exists || priorityArg == nil {
+ return nil
+ }
+
+ priorityStr, ok := priorityArg.(string)
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for 'priority' argument: expected string.",
+ )
+
+ return result
+ }
+
+ translated, errResult := ParsePriority(priorityStr)
+ if errResult != nil {
+ return errResult
+ }
+
+ payload.Priority = &translated
+
+ return nil
+}
+
+// setUpdateEisenhower sets eisenhower on an update task request.
+func (h *Handler) setUpdateEisenhower(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ eisenhowerArg, exists := arguments["eisenhower"]
+ if !exists || eisenhowerArg == nil {
+ return nil
+ }
+
+ eisenhowerStr, ok := eisenhowerArg.(string)
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for 'eisenhower' argument: expected string.",
+ )
+
+ return result
+ }
+
+ translated, errResult := ParseEisenhower(eisenhowerStr)
+ if errResult != nil {
+ return errResult
+ }
+
+ payload.Eisenhower = &translated
+
+ return nil
+}
+
+// setUpdateMotivation sets motivation on an update task request.
+func (h *Handler) setUpdateMotivation(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ motivationArg, exists := arguments["motivation"]
+ if !exists {
+ return nil
+ }
+
+ motivationStr, ok := motivationArg.(string)
+ if !ok && motivationArg != nil {
+ result, _ := shared.ReportError(
+ "Invalid type for motivation argument: expected string.",
+ )
+
+ return result
+ }
+
+ if !ok {
+ return nil
+ }
+
+ if motivationStr != "" {
+ if errResult := ValidateMotivation(motivationStr); errResult != nil {
+ return errResult
+ }
+ }
+
+ payload.Motivation = &motivationStr
+
+ return nil
+}
+
+// setUpdateStatus sets status on an update task request.
+func (h *Handler) setUpdateStatus(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ statusArg, exists := arguments["status"]
+ if !exists {
+ return nil
+ }
+
+ statusStr, ok := statusArg.(string)
+ if !ok && statusArg != nil {
+ result, _ := shared.ReportError(
+ "Invalid type for status argument: expected string.",
+ )
+
+ return result
+ }
+
+ if !ok {
+ return nil
+ }
+
+ if statusStr != "" {
+ if errResult := ValidateStatus(statusStr); errResult != nil {
+ return errResult
+ }
+ }
+
+ payload.Status = &statusStr
+
+ return nil
+}
+
+// setUpdateScheduledOn sets scheduled_on on an update task request.
+func (h *Handler) setUpdateScheduledOn(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ scheduledOnArg, exists := arguments["scheduled_on"]
+ if !exists {
+ return nil
+ }
+
+ scheduledOnStr, ok := scheduledOnArg.(string)
+ if !ok && scheduledOnArg != nil {
+ result, _ := shared.ReportError(
+ "Invalid type for scheduled_on argument: expected string.",
+ )
+
+ return result
+ }
+
+ if !ok || scheduledOnStr == "" {
+ return nil
+ }
+
+ date, err := lunatask.ParseDate(scheduledOnStr)
+ if err != nil {
+ result, _ := shared.ReportError(fmt.Sprintf(
+ "Invalid format for scheduled_on: '%s'. Must be YYYY-MM-DD.",
+ scheduledOnStr,
+ ))
+
+ return result
+ }
+
+ payload.ScheduledOn = &date
+
+ return nil
+}
@@ -0,0 +1,390 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package tasks provides MCP tools for task management in Lunatask.
+package tasks
+
+import (
+ "context"
+ "fmt"
+
+ "git.secluded.site/go-lunatask"
+ "github.com/mark3labs/mcp-go/mcp"
+
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+)
+
+// Handler handles task-related MCP tool calls.
+type Handler struct {
+ accessToken string
+ timezone string
+ areas []shared.AreaProvider
+}
+
+// NewHandler creates a new tasks Handler.
+func NewHandler(
+ accessToken string,
+ timezone string,
+ areas []shared.AreaProvider,
+) *Handler {
+ return &Handler{
+ accessToken: accessToken,
+ timezone: timezone,
+ areas: areas,
+ }
+}
+
+// HandleCreate handles the create_task tool call.
+//
+//nolint:cyclop,funlen,wrapcheck // validation complexity; ReportError returns nil
+func (h *Handler) HandleCreate(
+ ctx context.Context,
+ request mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+ arguments := request.Params.Arguments
+
+ if _, err := shared.LoadLocation(h.timezone); err != nil {
+ return shared.ReportError(err.Error())
+ }
+
+ areaID, ok := arguments["area_id"].(string)
+ if !ok || areaID == "" {
+ return shared.ReportError("Missing or invalid required argument: area_id")
+ }
+
+ area := FindArea(h.areas, areaID)
+ if area == nil {
+ return shared.ReportError("Area not found for given area_id")
+ }
+
+ goalID, errResult := h.validateGoalID(arguments, area)
+ if errResult != nil {
+ return errResult, nil
+ }
+
+ name, ok := arguments["name"].(string)
+ if !ok || name == "" {
+ return shared.ReportError("Missing or invalid required argument: name")
+ }
+
+ if errResult := ValidateName(name); errResult != nil {
+ return errResult, nil
+ }
+
+ task := lunatask.CreateTaskRequest{
+ Name: name,
+ AreaID: &areaID,
+ GoalID: goalID,
+ }
+
+ if err := h.populateCreateFields(&task, arguments); err != nil {
+ return err, nil
+ }
+
+ client := lunatask.NewClient(h.accessToken)
+
+ response, err := client.CreateTask(ctx, &task)
+ if err != nil {
+ return shared.ReportError(fmt.Sprintf("%v", err))
+ }
+
+ if response == nil {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Task already exists (not an error).",
+ },
+ },
+ }, nil
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Task created successfully with ID: " + response.ID,
+ },
+ },
+ }, nil
+}
+
+// HandleUpdate handles the update_task tool call.
+//
+//nolint:wrapcheck // ReportError returns nil
+func (h *Handler) HandleUpdate(
+ ctx context.Context,
+ request mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+ arguments := request.Params.Arguments
+
+ taskID, ok := arguments["task_id"].(string)
+ if !ok || taskID == "" {
+ return shared.ReportError("Missing or invalid required argument: task_id")
+ }
+
+ if _, err := shared.LoadLocation(h.timezone); err != nil {
+ return shared.ReportError(err.Error())
+ }
+
+ updatePayload := lunatask.UpdateTaskRequest{}
+
+ area, errResult := h.validateUpdateArea(arguments, &updatePayload)
+ if errResult != nil {
+ return errResult, nil
+ }
+
+ if errResult := h.validateUpdateGoal(arguments, area, &updatePayload); errResult != nil {
+ return errResult, nil
+ }
+
+ if errResult := h.validateUpdateName(arguments, &updatePayload); errResult != nil {
+ return errResult, nil
+ }
+
+ if err := h.populateUpdateFields(&updatePayload, arguments); err != nil {
+ return err, nil
+ }
+
+ client := lunatask.NewClient(h.accessToken)
+
+ response, err := client.UpdateTask(ctx, taskID, &updatePayload)
+ if err != nil {
+ return shared.ReportError(fmt.Sprintf("Failed to update task: %v", err))
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Task updated successfully. ID: " + response.ID,
+ },
+ },
+ }, nil
+}
+
+// HandleDelete handles the delete_task tool call.
+//
+//nolint:wrapcheck // ReportError returns nil
+func (h *Handler) HandleDelete(
+ ctx context.Context,
+ request mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+ taskID, ok := request.Params.Arguments["task_id"].(string)
+ if !ok || taskID == "" {
+ return shared.ReportError("Missing or invalid required argument: task_id")
+ }
+
+ client := lunatask.NewClient(h.accessToken)
+
+ _, err := client.DeleteTask(ctx, taskID)
+ if err != nil {
+ return shared.ReportError(fmt.Sprintf("Failed to delete task: %v", err))
+ }
+
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ mcp.TextContent{
+ Type: "text",
+ Text: "Task deleted successfully.",
+ },
+ },
+ }, nil
+}
+
+// validateGoalID validates and returns the goal_id if provided.
+func (h *Handler) validateGoalID(
+ arguments map[string]any,
+ area shared.AreaProvider,
+) (*string, *mcp.CallToolResult) {
+ goalIDStr, exists := arguments["goal_id"].(string)
+ if !exists || goalIDStr == "" {
+ return nil, nil
+ }
+
+ if !FindGoalInArea(area, goalIDStr) {
+ result, _ := shared.ReportError(
+ "Goal not found in specified area for given goal_id",
+ )
+
+ return nil, result
+ }
+
+ return &goalIDStr, nil
+}
+
+// validateUpdateArea validates area_id for update and returns the area provider.
+//
+//nolint:ireturn // returns interface by design
+func (h *Handler) validateUpdateArea(
+ arguments map[string]any,
+ payload *lunatask.UpdateTaskRequest,
+) (shared.AreaProvider, *mcp.CallToolResult) {
+ areaIDArg, exists := arguments["area_id"]
+ if !exists {
+ return nil, nil
+ }
+
+ areaIDStr, ok := areaIDArg.(string)
+ if !ok && areaIDArg != nil {
+ result, _ := shared.ReportError(
+ "Invalid type for area_id argument: expected string.",
+ )
+
+ return nil, result
+ }
+
+ if !ok || areaIDStr == "" {
+ return nil, nil
+ }
+
+ payload.AreaID = &areaIDStr
+ area := FindArea(h.areas, areaIDStr)
+
+ if area == nil {
+ result, _ := shared.ReportError("Area not found for given area_id: " + areaIDStr)
+
+ return nil, result
+ }
+
+ return area, nil
+}
+
+// validateUpdateGoal validates goal_id for update.
+func (h *Handler) validateUpdateGoal(
+ arguments map[string]any,
+ area shared.AreaProvider,
+ payload *lunatask.UpdateTaskRequest,
+) *mcp.CallToolResult {
+ goalIDArg, exists := arguments["goal_id"]
+ if !exists {
+ return nil
+ }
+
+ goalIDStr, ok := goalIDArg.(string)
+ if !ok && goalIDArg != nil {
+ result, _ := shared.ReportError(
+ "Invalid type for goal_id argument: expected string.",
+ )
+
+ return result
+ }
+
+ if !ok || goalIDStr == "" {
+ return nil
+ }
+
+ payload.GoalID = &goalIDStr
+
+ if area != nil && !FindGoalInArea(area, goalIDStr) {
+ result, _ := shared.ReportError(fmt.Sprintf(
+ "Goal not found in specified area '%s' for given goal_id: %s",
+ area.GetName(),
+ goalIDStr,
+ ))
+
+ return result
+ }
+
+ return nil
+}
+
+// validateUpdateName validates and sets the name for update.
+func (h *Handler) validateUpdateName(
+ arguments map[string]any,
+ payload *lunatask.UpdateTaskRequest,
+) *mcp.CallToolResult {
+ nameArg := arguments["name"]
+ nameStr, ok := nameArg.(string)
+
+ if !ok {
+ result, _ := shared.ReportError(
+ "Invalid type for name argument: expected string.",
+ )
+
+ return result
+ }
+
+ if errResult := ValidateName(nameStr); errResult != nil {
+ return errResult
+ }
+
+ payload.Name = &nameStr
+
+ return nil
+}
+
+// populateCreateFields populates optional fields for task creation.
+func (h *Handler) populateCreateFields(
+ task *lunatask.CreateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ if noteVal, exists := arguments["note"].(string); exists {
+ task.Note = ¬eVal
+ }
+
+ if errResult := h.setCreatePriority(task, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setCreateEisenhower(task, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setCreateMotivation(task, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setCreateStatus(task, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setCreateEstimate(task, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setCreateScheduledOn(task, arguments); errResult != nil {
+ return errResult
+ }
+
+ h.setCreateSource(task, arguments)
+
+ return nil
+}
+
+// populateUpdateFields populates optional fields for task update.
+func (h *Handler) populateUpdateFields(
+ payload *lunatask.UpdateTaskRequest,
+ arguments map[string]any,
+) *mcp.CallToolResult {
+ if errResult := h.setUpdateNote(payload, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setUpdateEstimate(payload, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setUpdatePriority(payload, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setUpdateEisenhower(payload, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setUpdateMotivation(payload, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setUpdateStatus(payload, arguments); errResult != nil {
+ return errResult
+ }
+
+ if errResult := h.setUpdateScheduledOn(payload, arguments); errResult != nil {
+ return errResult
+ }
+
+ return nil
+}
@@ -0,0 +1,136 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package tasks
+
+// CreateToolDescription describes the create_task tool for LLMs.
+const CreateToolDescription = `Creates a new task in Lunatask.
+WORKFLOW: First use list_areas_and_goals to identify valid area_id and goal_id values,
+then use get_timestamp if scheduling the task. Only include optional parameters if
+the user indicates or hints at them. Try to interpret speech-to-text input that
+may not be entirely accurate.`
+
+// UpdateToolDescription describes the update_task tool for LLMs.
+const UpdateToolDescription = `Updates an existing task. Only provided fields will be updated.
+WORKFLOW: Use list_areas_and_goals first if changing area/goal,
+then get_timestamp if changing schedule. Only include parameters that are being changed.
+Empty strings will clear existing values for text fields.`
+
+// DeleteToolDescription describes the delete_task tool for LLMs.
+const DeleteToolDescription = `Permanently deletes an existing task from Lunatask.
+This action cannot be undone.`
+
+// ParamTaskID describes the task_id parameter.
+const ParamTaskID = `ID of the task to update.`
+
+// ParamDeleteTaskID describes the task_id parameter for delete.
+const ParamDeleteTaskID = `ID of the task to delete.
+This must be a valid task ID from an existing task in Lunatask.`
+
+// ParamAreaID describes the area_id parameter for create.
+const ParamAreaID = `Area ID in which to create the task.
+Must be a valid area_id from list_areas_and_goals tool.`
+
+// ParamUpdateAreaID describes the area_id parameter for update.
+const ParamUpdateAreaID = `New Area ID for the task.
+Must be a valid area_id from list_areas_and_goals tool.
+Only include if moving the task to a different area.
+If omitted, the task will remain in its current area.`
+
+// ParamGoalID describes the goal_id parameter for create.
+const ParamGoalID = `Optional goal ID to associate the task with.
+Must be a valid goal_id from list_areas_and_goals that belongs to the specified area.
+Only include if the task relates to a specific goal.`
+
+// ParamUpdateGoalID describes the goal_id parameter for update.
+const ParamUpdateGoalID = `New Goal ID for the task.
+Must be a valid goal_id from list_areas_and_goals that belongs to the task's area
+(current or new). Only include if changing the goal association.`
+
+// ParamName describes the name parameter.
+const ParamName = `Plain text task name using sentence case.`
+
+// ParamUpdateName describes the name parameter for update.
+const ParamUpdateName = `New plain text task name using sentence case.
+Sending an empty string WILL clear the name.`
+
+// ParamNote describes the note parameter for create.
+const ParamNote = `Additional details or notes for the task, using Markdown formatting.
+Include any extra context, requirements, or information provided by the user
+that doesn't fit in the task name.`
+
+// ParamUpdateNote describes the note parameter for update.
+const ParamUpdateNote = `New note for the task, using Markdown formatting.
+Sending an empty string WILL clear the existing note.
+Only include if changing the task notes.`
+
+// ParamEstimate describes the estimate parameter for create.
+const ParamEstimate = `Estimated completion time in minutes (0-720, max 12 hours).
+Only include if user mentions a time estimate like '30 minutes' (pass 30)
+or '2 hours' (pass 120). Omit if no estimate is provided.`
+
+// ParamUpdateEstimate describes the estimate parameter for update.
+const ParamUpdateEstimate = `New estimated completion time in minutes (0-720, max 12 hours).
+Only include if user mentions changing the time estimate.
+Note: update_task has a lower maximum than create_task.`
+
+// ParamPriority describes the priority parameter for create.
+const ParamPriority = `Task priority level.
+Valid values: 'lowest', 'low', 'neutral', 'high', 'highest'.
+Only include if user explicitly mentions priority or urgency.
+Omit for normal tasks.`
+
+// ParamUpdatePriority describes the priority parameter for update.
+const ParamUpdatePriority = `New task priority level.
+Valid values: 'lowest', 'low', 'neutral', 'high', 'highest'.
+Only include if user wants to change the priority.`
+
+// ParamMotivation describes the motivation parameter for create.
+const ParamMotivation = `Level of importance for the task.
+Valid values: 'must' (critical/required), 'should' (important), 'want' (nice-to-have).
+Only include if the user's language suggests strong obligation
+('I need to', 'I have to') vs preference ('I'd like to', 'I want to').`
+
+// ParamUpdateMotivation describes the motivation parameter for update.
+const ParamUpdateMotivation = `New level of importance for the task.
+Valid values: 'must' (critical/required), 'should' (important), 'want' (nice-to-have),
+or empty string to clear. Only include if changing the motivation level.`
+
+// ParamEisenhower describes the eisenhower parameter for create.
+const ParamEisenhower = `Eisenhower Matrix quadrant for task prioritization.
+Valid values: 'both urgent and important', 'urgent, but not important',
+'important, but not urgent', 'neither urgent nor important', 'uncategorised'.
+Only include for areas which the user has indicated follow the Eisenhower workflow.`
+
+// ParamUpdateEisenhower describes the eisenhower parameter for update.
+const ParamUpdateEisenhower = `New Eisenhower Matrix quadrant for task prioritization.
+Valid values: 'both urgent and important', 'urgent, but not important',
+'important, but not urgent', 'neither urgent nor important',
+'uncategorised' (clears the field).
+Only include for areas which the user has indicated follow the Eisenhower workflow.`
+
+// ParamStatus describes the status parameter for create.
+const ParamStatus = `Initial task status.
+Valid values: 'later' (someday/backlog), 'next' (upcoming/soon),
+'started' (in progress), 'waiting' (blocked), 'completed' (finished).
+Infer from context: 'working on' = 'started', 'soon'/'upcoming' = 'next',
+'blocked'/'waiting for' = 'waiting'.
+Omit for normal new tasks (defaults to appropriate status).`
+
+// ParamUpdateStatus describes the status parameter for update.
+const ParamUpdateStatus = `New task status.
+Valid values: 'later' (someday/backlog), 'next' (upcoming/soon),
+'started' (in progress), 'waiting' (blocked), 'completed' (finished),
+or empty string to clear. Only include if changing the task status.`
+
+// ParamScheduledOn describes the scheduled_on parameter for create.
+const ParamScheduledOn = `Scheduled date/time for the task.
+Must use the formatted timestamp returned by get_timestamp tool.
+Only include if user specifies when the task should be done.`
+
+// ParamUpdateScheduledOn describes the scheduled_on parameter for update.
+const ParamUpdateScheduledOn = `New scheduled date/time for the task.
+Must use the formatted timestamp returned by get_timestamp tool.
+Sending an empty string might clear the scheduled date.
+Only include if changing the schedule.`
@@ -0,0 +1,187 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package tasks
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/mark3labs/mcp-go/mcp"
+
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+)
+
+// Priority API values.
+const (
+ PriorityLowest = -2
+ PriorityLow = -1
+ PriorityNeutral = 0
+ PriorityHigh = 1
+ PriorityHighest = 2
+)
+
+// Eisenhower API values.
+const (
+ EisenhowerUncategorised = 0
+ EisenhowerUrgentAndImportant = 1
+ EisenhowerUrgentNotImportant = 2
+ EisenhowerImportantNotUrgent = 3
+ EisenhowerNeitherUrgentNorImpt = 4
+)
+
+// MaxNameLength is the maximum allowed task name length.
+const MaxNameLength = 100
+
+// MaxEstimate is the maximum estimate in minutes (12 hours).
+const MaxEstimate = 720
+
+// priorityMap maps human-readable priority strings to API values.
+var priorityMap = map[string]int{ //nolint:gochecknoglobals // lookup table
+ "lowest": PriorityLowest,
+ "low": PriorityLow,
+ "neutral": PriorityNeutral,
+ "high": PriorityHigh,
+ "highest": PriorityHighest,
+}
+
+// eisenhowerMap maps human-readable eisenhower strings to API values.
+var eisenhowerMap = map[string]int{ //nolint:gochecknoglobals // lookup table
+ "uncategorised": EisenhowerUncategorised,
+ "both urgent and important": EisenhowerUrgentAndImportant,
+ "urgent, but not important": EisenhowerUrgentNotImportant,
+ "important, but not urgent": EisenhowerImportantNotUrgent,
+ "neither urgent nor important": EisenhowerNeitherUrgentNorImpt,
+}
+
+// validMotivations are the allowed motivation values.
+var validMotivations = map[string]bool{ //nolint:gochecknoglobals // lookup table
+ "must": true,
+ "should": true,
+ "want": true,
+}
+
+// validStatuses are the allowed status values.
+var validStatuses = map[string]bool{ //nolint:gochecknoglobals // lookup table
+ "later": true,
+ "next": true,
+ "started": true,
+ "waiting": true,
+ "completed": true,
+}
+
+// ParsePriority parses a priority string and returns the API value.
+// Returns an error result if the priority is invalid.
+func ParsePriority(priorityStr string) (int, *mcp.CallToolResult) {
+ translated, isValid := priorityMap[strings.ToLower(priorityStr)]
+ if !isValid {
+ result, _ := shared.ReportError(fmt.Sprintf(
+ "Invalid 'priority' value: '%s'. "+
+ "Must be one of 'lowest', 'low', 'neutral', 'high', 'highest'.",
+ priorityStr,
+ ))
+
+ return 0, result
+ }
+
+ return translated, nil
+}
+
+// ParseEisenhower parses an eisenhower string and returns the API value.
+// Returns an error result if the eisenhower value is invalid.
+func ParseEisenhower(eisenhowerStr string) (int, *mcp.CallToolResult) {
+ translated, isValid := eisenhowerMap[strings.ToLower(eisenhowerStr)]
+ if !isValid {
+ result, _ := shared.ReportError(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,
+ ))
+
+ return 0, result
+ }
+
+ return translated, nil
+}
+
+// ValidateMotivation checks if a motivation value is valid.
+// Returns an error result if invalid.
+func ValidateMotivation(motivation string) *mcp.CallToolResult {
+ if motivation != "" && !validMotivations[motivation] {
+ result, _ := shared.ReportError(
+ "'motivation' must be one of 'must', 'should', or 'want'",
+ )
+
+ return result
+ }
+
+ return nil
+}
+
+// ValidateStatus checks if a status value is valid.
+// Returns an error result if invalid.
+func ValidateStatus(status string) *mcp.CallToolResult {
+ if status != "" && !validStatuses[status] {
+ result, _ := shared.ReportError(
+ "'status' must be one of 'later', 'next', 'started', 'waiting', or 'completed'",
+ )
+
+ return result
+ }
+
+ return nil
+}
+
+// ValidateEstimate checks if an estimate value is within valid range.
+// Returns an error result if invalid.
+func ValidateEstimate(estimate int) *mcp.CallToolResult {
+ if estimate < 0 || estimate > MaxEstimate {
+ result, _ := shared.ReportError(
+ "'estimate' must be between 0 and 720 minutes",
+ )
+
+ return result
+ }
+
+ return nil
+}
+
+// ValidateName checks if a task name is valid.
+// Returns an error result if invalid.
+func ValidateName(name string) *mcp.CallToolResult {
+ if len(name) > MaxNameLength {
+ result, _ := shared.ReportError("'name' must be 100 characters or fewer")
+
+ return result
+ }
+
+ return nil
+}
+
+// FindArea finds an area by ID from the list of providers.
+// Returns nil if not found.
+//
+//nolint:ireturn // returns interface by design
+func FindArea(areas []shared.AreaProvider, areaID string) shared.AreaProvider {
+ for _, ap := range areas {
+ if ap.GetID() == areaID {
+ return ap
+ }
+ }
+
+ return nil
+}
+
+// FindGoalInArea checks if a goal exists within an area.
+// Returns true if found.
+func FindGoalInArea(area shared.AreaProvider, goalID string) bool {
+ for _, goal := range area.GetGoals() {
+ if goal.GetID() == goalID {
+ return true
+ }
+ }
+
+ return false
+}
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package timestamp provides the get_timestamp MCP tool for parsing natural
+// language dates into formatted timestamps.
+package timestamp
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/ijt/go-anytime"
+ "github.com/mark3labs/mcp-go/mcp"
+
+ "git.sr.ht/~amolith/lunatask-mcp-server/tools/shared"
+)
+
+// Handler handles timestamp-related MCP tool calls.
+type Handler struct {
+ timezone string
+}
+
+// NewHandler creates a new timestamp Handler.
+func NewHandler(timezone string) *Handler {
+ return &Handler{timezone: timezone}
+}
+
+// Handle handles the get_timestamp tool call.
+//
+//nolint:wrapcheck // ReportError returns nil for error
+func (h *Handler) Handle(
+ _ context.Context,
+ request mcp.CallToolRequest,
+) (*mcp.CallToolResult, error) {
+ natLangDate, ok := request.Params.Arguments["natural_language_date"].(string)
+ if !ok || natLangDate == "" {
+ return shared.ReportError(
+ "Missing or invalid required argument: natural_language_date",
+ )
+ }
+
+ loc, err := shared.LoadLocation(h.timezone)
+ if err != nil {
+ return shared.ReportError(err.Error())
+ }
+
+ parsedTime, err := anytime.Parse(natLangDate, time.Now().In(loc))
+ if err != nil {
+ return shared.ReportError(
+ 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
+}
@@ -0,0 +1,19 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+package timestamp
+
+// ToolDescription describes the get_timestamp tool for LLMs.
+const ToolDescription = `Parses natural language dates into formatted timestamps for task scheduling.
+Use this tool when creating or updating tasks that include dates or times
+(after using list_areas_and_goals first). Supports relative dates
+(e.g., 'tomorrow', '1 week', 'next friday'), absolute dates
+(e.g., 'january 15', '2024-03-10'), and times
+(e.g., 'at 2pm', 'sunday at 19:00'). Also accepts 'now' for current timestamp.`
+
+// ParamNaturalLanguageDate describes the natural_language_date parameter.
+const ParamNaturalLanguageDate = `Natural language date expression. Examples: 'tomorrow', '1 week',
+'sunday at 19:00', 'january 15 at 2pm', 'next friday', 'now'.
+The tool will parse this into a properly formatted timestamp for use
+with task scheduling.`
@@ -1,129 +0,0 @@
-// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
-//
-// SPDX-License-Identifier: AGPL-3.0-or-later
-
-package tools
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/ijt/go-anytime"
- "github.com/mark3labs/mcp-go/mcp"
-)
-
-// ErrTimezoneNotConfigured is returned when the timezone config value is empty.
-var ErrTimezoneNotConfigured = errors.New(
- "timezone is not configured; please set the 'timezone' value in your config file " +
- "(e.g. 'UTC' or 'America/New_York')",
-)
-
-// AreaProvider defines the interface for accessing area data.
-type AreaProvider interface {
- GetName() string
- GetID() string
- GetGoals() []GoalProvider
-}
-
-// GoalProvider defines the interface for accessing goal data.
-type GoalProvider interface {
- GetName() string
- GetID() string
-}
-
-// HabitProvider defines the interface for accessing habit data.
-type HabitProvider interface {
- GetName() string
- GetID() string
-}
-
-// HandlerConfig holds the necessary configuration for tool handlers.
-type HandlerConfig struct {
- AccessToken string
- Timezone string
- Areas []AreaProvider
- Habits []HabitProvider
-}
-
-// Handlers provides methods for handling MCP tool calls.
-type Handlers struct {
- config HandlerConfig
-}
-
-// NewHandlers creates a new Handlers instance.
-func NewHandlers(config HandlerConfig) *Handlers {
- return &Handlers{config: config}
-}
-
-// reportMCPError creates an MCP error result.
-func reportMCPError(msg string) (*mcp.CallToolResult, error) {
- return &mcp.CallToolResult{
- IsError: true,
- Content: []mcp.Content{mcp.TextContent{Type: "text", Text: msg}},
- }, nil
-}
-
-// LoadLocation loads a timezone location string, returning a *time.Location or error.
-func LoadLocation(timezone string) (*time.Location, error) {
- if timezone == "" {
- return nil, ErrTimezoneNotConfigured
- }
-
- loc, err := time.LoadLocation(timezone)
- if err != nil {
- return nil, fmt.Errorf("could not load timezone '%s': %w", timezone, err)
- }
-
- return loc, nil
-}
-
-// HandleGetTimestamp handles the get_timestamp tool call.
-func (h *Handlers) HandleGetTimestamp(_ 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")
- }
-
- 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{
- Type: "text",
- Text: parsedTime.Format(time.RFC3339),
- },
- },
- }, nil
-}
-
-// HandleListAreasAndGoals handles the list_areas_and_goals tool call.
-func (h *Handlers) HandleListAreasAndGoals(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- var builder strings.Builder
- for _, area := range h.config.Areas {
- fmt.Fprintf(&builder, "- %s: %s\n", area.GetName(), area.GetID())
-
- for _, goal := range area.GetGoals() {
- fmt.Fprintf(&builder, " - %s: %s\n", goal.GetName(), goal.GetID())
- }
- }
-
- return &mcp.CallToolResult{
- Content: []mcp.Content{
- mcp.TextContent{
- Type: "text",
- Text: builder.String(),
- },
- },
- }, nil
-}