feat(mcp): mint-token CLI + get_card / delete_comment tools + executeToolAs(actor)

Net-new capacidades recuperadas del WIP stash que el merge notif no traia:

- mint-token CLI subcommand: 'kanban mint-token --user <id> --name <pc>' genera token bearer
  para configurar Claude Code u otros clientes MCP HTTP sin tocar la UI.
- executeToolAs(db, name, input, actor): variante actor-aware de executeTool. El dispatcher
  HTTP /mcp pasa el user_id resuelto del bearer token; tools per-user (add_comment,
  delete_comment) lo usan como autor sin que el llamante pueda forjarlo.
- get_card tool: lookup por id o seq_num. Devuelve Card completa.
- delete_comment tool: borra card_message; solo el autor original (validado en DB).

executeTool() sigue siendo el wrapper legacy sin actor para chat WS.
This commit is contained in:
egutierrez
2026-05-28 09:36:48 +02:00
parent 084defe014
commit 65771ebb12
5 changed files with 162 additions and 15 deletions
+81 -12
View File
@@ -19,8 +19,15 @@ func errResult(err error) ToolResult { return ToolResult{OK: false, Error: err.
func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} }
// executeTool dispatches a tool by name with raw JSON input and returns a ToolResult.
// Tools that mutate the board return ok=true on success; read-only tools include their data in result.
// Used by the legacy chat path (no authenticated user available).
func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return executeToolAs(db, name, input, "")
}
// executeToolAs is the actor-aware dispatch used by the HTTP MCP path.
// actor is the authenticated user id (resolved from the bearer token) for tools
// that need it (add_comment / delete_comment infer the author from it).
func executeToolAs(db *DB, name string, input json.RawMessage, actor string) ToolResult {
switch name {
case "list_board":
return toolListBoard(db)
@@ -50,10 +57,14 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
return toolListUsers(db)
case "assign_card":
return toolAssignCard(db, input)
case "get_card":
return toolGetCard(db, input)
case "add_comment":
return toolAddComment(db, input)
return toolAddCommentAs(db, input, actor)
case "list_comments":
return toolListComments(db, input)
case "delete_comment":
return toolDeleteComment(db, input, actor)
default:
return errMsg("unknown tool: " + name)
}
@@ -64,7 +75,7 @@ func toolMutates(name string) bool {
switch name {
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
"create_card", "update_card", "delete_card", "move_card", "assign_card",
"add_comment":
"add_comment", "delete_comment":
return true
}
return false
@@ -352,7 +363,8 @@ func validateToolName(name string) error {
"update_card": true, "delete_card": true, "move_card": true,
"card_history": true, "find_cards": true,
"list_users": true, "assign_card": true,
"add_comment": true, "list_comments": true,
"add_comment": true, "list_comments": true, "delete_comment": true,
"get_card": true,
}
if !known[name] {
return fmt.Errorf("unknown tool: %s", name)
@@ -360,10 +372,15 @@ func validateToolName(name string) error {
return nil
}
// toolAddComment appends a comment (card_message) to a card. Accepts either
// {card_id, body, author_id} or {card_id, body, author_username}. Resolves
// the username to an id when needed.
func toolAddComment(db *DB, input json.RawMessage) ToolResult {
// toolAddCommentAs appends a comment (card_message) to a card.
//
// Author resolution order:
// 1. explicit "author_id" in input (legacy chat path)
// 2. explicit "author_username" in input -> resolve to id
// 3. fallback to `actor` (authenticated user from MCP HTTP token)
//
// At least one must yield a non-empty id.
func toolAddCommentAs(db *DB, input json.RawMessage, actor string) ToolResult {
var in struct {
CardID string `json:"card_id"`
Body string `json:"body"`
@@ -380,16 +397,19 @@ func toolAddComment(db *DB, input json.RawMessage) ToolResult {
return errMsg("body required")
}
authorID := strings.TrimSpace(in.AuthorID)
if authorID == "" {
if in.AuthorUsername == "" {
return errMsg("author_id or author_username required")
}
if authorID == "" && in.AuthorUsername != "" {
u, _, err := db.GetUserByUsername(in.AuthorUsername)
if err != nil {
return errResult(fmt.Errorf("author_username: %w", err))
}
authorID = u.ID
}
if authorID == "" {
authorID = actor
}
if authorID == "" {
return errMsg("author_id, author_username, or authenticated MCP token required")
}
m, err := db.CreateCardMessage(in.CardID, authorID, in.Body)
if err != nil {
return errResult(err)
@@ -397,6 +417,55 @@ func toolAddComment(db *DB, input json.RawMessage) ToolResult {
return okResult(m)
}
// toolGetCard returns a single active (non-archived) card by id or seq_num.
// Pass exactly ONE of {id, seq_num}.
func toolGetCard(db *DB, input json.RawMessage) ToolResult {
var in struct {
ID string `json:"id"`
SeqNum int `json:"seq_num"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.ID == "" && in.SeqNum == 0 {
return errMsg("provide id or seq_num")
}
cards, err := db.ListCardsWithTime()
if err != nil {
return errResult(err)
}
for _, c := range cards {
if in.ID != "" && c.ID == in.ID {
return okResult(c)
}
if in.SeqNum != 0 && c.SeqNum == in.SeqNum {
return okResult(c)
}
}
return errMsg("card not found")
}
// toolDeleteComment deletes a comment. Only the original author can delete it
// (enforced via actor == message.author_id).
func toolDeleteComment(db *DB, input json.RawMessage, actor string) ToolResult {
if actor == "" {
return errMsg("authenticated user required (call via MCP HTTP with a valid token)")
}
var in struct {
ID string `json:"id"`
}
if err := json.Unmarshal(input, &in); err != nil {
return errResult(err)
}
if in.ID == "" {
return errMsg("id required")
}
if err := db.DeleteCardMessage(in.ID, actor); err != nil {
return errResult(err)
}
return okResult(map[string]bool{"ok": true})
}
// toolListComments returns every comment (card_message) attached to a card
// sorted by created_at ascending.
func toolListComments(db *DB, input json.RawMessage) ToolResult {