From bb0713bea67f48692c76e7eeb80859ed1591ecfa Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 24 Dec 2025 10:50:25 -0700 Subject: [PATCH] feat(mcp): accept keys and deeplinks for area/goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 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(-) diff --git a/cmd/mcp/server.go b/cmd/mcp/server.go index 3f6d96aaa8fe8e3a558d4b5cabc954e7fbbbf2be..ae3a33276e2dff5273347a20af4eae49ccdd0318 100644 --- a/cmd/mcp/server.go +++ b/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{ diff --git a/internal/mcp/tools/task/create.go b/internal/mcp/tools/task/create.go index 620ac986aea146fcb50c02e0247d3d68f4f3debb..ddf47c6f943cc2c2ce689647439b322e924bf328 100644 --- a/internal/mcp/tools/task/create.go +++ b/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 { diff --git a/internal/mcp/tools/task/update.go b/internal/mcp/tools/task/update.go index 873e7920846eca8df9e04bfa4ae29d2cf045c4a8..3e918648089ca2b200b495ed774c143487c65c01 100644 --- a/internal/mcp/tools/task/update.go +++ b/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 { diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 3e0f3030007db74178cdf482efd8de755bf1e75c..f06ebac1fd6e3fcc691f7077a2df47988e4e84ed 100644 --- a/internal/validate/validate.go +++ b/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) +}