feat(mcp): accept keys and deeplinks for area/goal

Amolith created

MCP task tools now accept config keys and lunatask:// deep links in
addition to UUIDs for area_id and goal_id parameters.

Resolution order: UUID → deep link → config key lookup.

Adds validate.AreaRef() and validate.GoalRef() functions.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

cmd/mcp/server.go                 |  5 ++-
internal/mcp/tools/task/create.go | 35 ++++++++++++++++-------
internal/mcp/tools/task/update.go | 31 ++++++++++++++------
internal/validate/validate.go     | 48 +++++++++++++++++++++++++++++++++
4 files changed, 96 insertions(+), 23 deletions(-)

Detailed changes

cmd/mcp/server.go 🔗

@@ -142,7 +142,7 @@ func registerTools(
 		}, tsHandler.Handle)
 	}
 
-	registerTaskTools(mcpServer, tools, accessToken, areaProviders)
+	registerTaskTools(mcpServer, cfg, tools, accessToken, areaProviders)
 	registerNoteTools(mcpServer, tools, accessToken, notebookProviders)
 	registerPersonTools(mcpServer, tools, accessToken)
 
@@ -165,11 +165,12 @@ func registerTools(
 
 func registerTaskTools(
 	mcpServer *mcp.Server,
+	cfg *config.Config,
 	tools *config.ToolsConfig,
 	accessToken string,
 	areaProviders []shared.AreaProvider,
 ) {
-	taskHandler := task.NewHandler(accessToken, areaProviders)
+	taskHandler := task.NewHandler(accessToken, cfg, areaProviders)
 
 	if tools.CreateTask {
 		mcp.AddTool(mcpServer, &mcp.Tool{

internal/mcp/tools/task/create.go 🔗

@@ -9,8 +9,10 @@ import (
 	"context"
 
 	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/config"
 	"git.secluded.site/lune/internal/dateutil"
 	"git.secluded.site/lune/internal/mcp/shared"
+	"git.secluded.site/lune/internal/validate"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
@@ -24,8 +26,8 @@ Required:
 - name: Task title
 
 Optional:
-- area_id: Area UUID (get from lunatask://areas resource)
-- goal_id: Goal UUID (requires area_id; get from lunatask://areas resource)
+- area_id: Area UUID, lunatask:// deep link, or config key
+- goal_id: Goal UUID, lunatask:// deep link, or config key (requires area_id)
 - status: later, next, started, waiting (default: later)
 - note: Markdown note/description for the task
 - priority: lowest, low, normal, high, highest
@@ -75,13 +77,15 @@ type parsedCreateInput struct {
 // Handler handles task-related MCP tool requests.
 type Handler struct {
 	client *lunatask.Client
+	cfg    *config.Config
 	areas  []shared.AreaProvider
 }
 
 // NewHandler creates a new task handler.
-func NewHandler(accessToken string, areas []shared.AreaProvider) *Handler {
+func NewHandler(accessToken string, cfg *config.Config, areas []shared.AreaProvider) *Handler {
 	return &Handler{
 		client: lunatask.NewClient(accessToken, lunatask.UserAgent("lune-mcp/1.0")),
+		cfg:    cfg,
 		areas:  areas,
 	}
 }
@@ -92,7 +96,7 @@ func (h *Handler) HandleCreate(
 	_ *mcp.CallToolRequest,
 	input CreateInput,
 ) (*mcp.CallToolResult, CreateOutput, error) {
-	parsed, errResult := parseCreateInput(input)
+	parsed, errResult := parseCreateInput(h.cfg, input)
 	if errResult != nil {
 		return errResult, CreateOutput{}, nil
 	}
@@ -115,11 +119,9 @@ func (h *Handler) HandleCreate(
 }
 
 //nolint:cyclop,funlen
-func parseCreateInput(input CreateInput) (*parsedCreateInput, *mcp.CallToolResult) {
+func parseCreateInput(cfg *config.Config, input CreateInput) (*parsedCreateInput, *mcp.CallToolResult) {
 	parsed := &parsedCreateInput{
 		Name:      input.Name,
-		AreaID:    input.AreaID,
-		GoalID:    input.GoalID,
 		Note:      input.Note,
 		Estimate:  input.Estimate,
 		Important: input.Important,
@@ -127,15 +129,26 @@ func parseCreateInput(input CreateInput) (*parsedCreateInput, *mcp.CallToolResul
 	}
 
 	if input.AreaID != nil {
-		if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
-			return nil, shared.ErrorResult("invalid area_id: expected UUID")
+		areaID, err := validate.AreaRef(cfg, *input.AreaID)
+		if err != nil {
+			return nil, shared.ErrorResult(err.Error())
 		}
+
+		parsed.AreaID = &areaID
 	}
 
 	if input.GoalID != nil {
-		if err := lunatask.ValidateUUID(*input.GoalID); err != nil {
-			return nil, shared.ErrorResult("invalid goal_id: expected UUID")
+		areaID := ""
+		if parsed.AreaID != nil {
+			areaID = *parsed.AreaID
+		}
+
+		goalID, err := validate.GoalRef(cfg, areaID, *input.GoalID)
+		if err != nil {
+			return nil, shared.ErrorResult(err.Error())
 		}
+
+		parsed.GoalID = &goalID
 	}
 
 	if input.Estimate != nil {

internal/mcp/tools/task/update.go 🔗

@@ -8,8 +8,10 @@ import (
 	"context"
 
 	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/config"
 	"git.secluded.site/lune/internal/dateutil"
 	"git.secluded.site/lune/internal/mcp/shared"
+	"git.secluded.site/lune/internal/validate"
 	"github.com/modelcontextprotocol/go-sdk/mcp"
 )
 
@@ -24,8 +26,8 @@ Required:
 
 Optional (only specified fields are updated):
 - name: New task title
-- area_id: Move to area UUID
-- goal_id: Move to goal UUID (requires area_id)
+- area_id: Move to area (UUID, lunatask:// deep link, or config key)
+- goal_id: Move to goal (UUID, lunatask:// deep link, or config key; requires area_id)
 - status: later, next, started, waiting, completed
 - note: New markdown note (replaces existing)
 - priority: lowest, low, normal, high, highest
@@ -80,7 +82,7 @@ func (h *Handler) HandleUpdate(
 	_ *mcp.CallToolRequest,
 	input UpdateInput,
 ) (*mcp.CallToolResult, UpdateOutput, error) {
-	parsed, errResult := parseUpdateInput(input)
+	parsed, errResult := parseUpdateInput(h.cfg, input)
 	if errResult != nil {
 		return errResult, UpdateOutput{}, nil
 	}
@@ -103,7 +105,7 @@ func (h *Handler) HandleUpdate(
 }
 
 //nolint:cyclop,funlen
-func parseUpdateInput(input UpdateInput) (*parsedUpdateInput, *mcp.CallToolResult) {
+func parseUpdateInput(cfg *config.Config, input UpdateInput) (*parsedUpdateInput, *mcp.CallToolResult) {
 	_, id, err := lunatask.ParseReference(input.ID)
 	if err != nil {
 		return nil, shared.ErrorResult("invalid ID: expected UUID or lunatask:// deep link")
@@ -112,8 +114,6 @@ func parseUpdateInput(input UpdateInput) (*parsedUpdateInput, *mcp.CallToolResul
 	parsed := &parsedUpdateInput{
 		ID:        id,
 		Name:      input.Name,
-		AreaID:    input.AreaID,
-		GoalID:    input.GoalID,
 		Note:      input.Note,
 		Estimate:  input.Estimate,
 		Important: input.Important,
@@ -121,15 +121,26 @@ func parseUpdateInput(input UpdateInput) (*parsedUpdateInput, *mcp.CallToolResul
 	}
 
 	if input.AreaID != nil {
-		if err := lunatask.ValidateUUID(*input.AreaID); err != nil {
-			return nil, shared.ErrorResult("invalid area_id: expected UUID")
+		areaID, err := validate.AreaRef(cfg, *input.AreaID)
+		if err != nil {
+			return nil, shared.ErrorResult(err.Error())
 		}
+
+		parsed.AreaID = &areaID
 	}
 
 	if input.GoalID != nil {
-		if err := lunatask.ValidateUUID(*input.GoalID); err != nil {
-			return nil, shared.ErrorResult("invalid goal_id: expected UUID")
+		areaID := ""
+		if parsed.AreaID != nil {
+			areaID = *parsed.AreaID
+		}
+
+		goalID, err := validate.GoalRef(cfg, areaID, *input.GoalID)
+		if err != nil {
+			return nil, shared.ErrorResult(err.Error())
 		}
+
+		parsed.GoalID = &goalID
 	}
 
 	if input.Estimate != nil {

internal/validate/validate.go 🔗

@@ -10,6 +10,7 @@ import (
 	"fmt"
 
 	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/config"
 )
 
 // ErrInvalidReference indicates the input is not a valid UUID or deep link.
@@ -69,3 +70,50 @@ func RelationshipStrength(input string) (lunatask.RelationshipStrength, error) {
 
 	return rel, nil
 }
+
+// ErrInvalidArea indicates the area reference is not a valid UUID, deep link, or config key.
+var ErrInvalidArea = errors.New("invalid area: expected UUID, lunatask:// deep link, or config key")
+
+// AreaRef resolves an area reference to a UUID.
+// Accepts formats:
+//   - UUID: "527a2b42-99fd-490d-8b21-c55451368f4c"
+//   - Deep link: "lunatask://areas/527a2b42-..."
+//   - Config key: "projects"
+func AreaRef(cfg *config.Config, input string) (string, error) {
+	// Try UUID or deep link first
+	if _, id, err := lunatask.ParseReference(input); err == nil {
+		return id, nil
+	}
+
+	// Try config key lookup
+	if area := cfg.AreaByKey(input); area != nil {
+		return area.ID, nil
+	}
+
+	return "", fmt.Errorf("%w: %s", ErrInvalidArea, input)
+}
+
+// ErrInvalidGoal indicates the goal reference is not a valid UUID, deep link, or config key.
+var ErrInvalidGoal = errors.New("invalid goal: expected UUID, lunatask:// deep link, or config key")
+
+// GoalRef resolves a goal reference to a UUID.
+// Requires a valid area ID to look up goals by key (goals are scoped to areas).
+// Accepts formats:
+//   - UUID: "53ca909e-887d-4ed2-9943-d1212adf8ad8"
+//   - Deep link: "lunatask://goals/53ca909e-..."
+//   - Config key: "lunatask" (requires valid areaID for lookup)
+func GoalRef(cfg *config.Config, areaID, input string) (string, error) {
+	// Try UUID or deep link first
+	if _, id, err := lunatask.ParseReference(input); err == nil {
+		return id, nil
+	}
+
+	// Try config key lookup (requires area context)
+	if area := cfg.AreaByID(areaID); area != nil {
+		if goal := area.GoalByKey(input); goal != nil {
+			return goal.ID, nil
+		}
+	}
+
+	return "", fmt.Errorf("%w: %s", ErrInvalidGoal, input)
+}