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
+9
View File
@@ -36,6 +36,15 @@ func main() {
return
}
// Subcommand `kanban mint-token` issues an HTTP MCP bearer token for a user.
if len(os.Args) > 1 && os.Args[1] == "mint-token" {
if err := runMintToken(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "kanban mint-token: %v\n", err)
os.Exit(1)
}
return
}
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path")
+26
View File
@@ -279,6 +279,32 @@ func mcpToolDefs() []infra.MCPToolDef {
"required": []string{"card_id"},
}),
},
{
Name: "delete_comment",
Description: "Borra un comentario propio. Solo el autor original puede borrar (validado en server). " +
"Requiere autenticacion via MCP HTTP — el actor se infiere del bearer token. " +
"Output: {ok:true}.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "ID del card_message a borrar (no de la card)."},
},
"required": []string{"id"},
}),
},
{
Name: "get_card",
Description: "Devuelve una tarjeta activa (no archivada) por id o por seq_num. Read-only. " +
"Pasa exactamente UNO de los dos: id (hash interno) o seq_num (entero visible, ej. la '115' de 'card 00115'). " +
"Output: Card completa con time_in_column_ms, total_locked_ms, tags, stickers, deadline.",
InputSchema: rawSchema(map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "ID hash de la tarjeta (16 hex)."},
"seq_num": map[string]any{"type": "integer", "description": "Numero secuencial visible al usuario."},
},
}),
},
}
}
+4 -3
View File
@@ -13,8 +13,8 @@ import (
// mcpHTTPHandler builds the http.Handler that serves the MCP Streamable HTTP
// transport for remote Claude clients. Bearer-auth backed by the mcp_tokens
// table; tool dispatch reuses executeTool() — the same set of operations the
// chat assistant uses internally.
// table; tool dispatch reuses executeToolAs() so per-user tools (add_comment,
// delete_comment) can infer the actor from the authenticated token.
func mcpHTTPHandler(db *DB) http.Handler {
auth := func(r *http.Request) (context.Context, error) {
header := r.Header.Get("Authorization")
@@ -37,7 +37,8 @@ func mcpHTTPHandler(db *DB) http.Handler {
if len(body) == 0 {
body = json.RawMessage(`{}`)
}
res := executeTool(db, name, body)
actor, _ := infra.UserIDFromContext(ctx, userCtxKey)
res := executeToolAs(db, name, body, actor)
if !res.OK {
return res.Error, true, nil
}
+42
View File
@@ -6,6 +6,7 @@ import (
"database/sql"
"encoding/hex"
"errors"
"flag"
"fmt"
)
@@ -130,3 +131,44 @@ func generateMCPTokenPlaintext() (string, error) {
}
return mcpTokenPrefix + hex.EncodeToString(b), nil
}
// runMintToken implements `kanban mint-token --user <id> --name <pc>`.
// Generates a fresh token, persists its sha256 in mcp_tokens, and prints the
// plaintext ONCE to stdout. The caller must save it — the server keeps only
// the hash.
func runMintToken(args []string) error {
fs := flag.NewFlagSet("kanban mint-token", flag.ContinueOnError)
dbPath := fs.String("db", "operations.db", "SQLite database path")
userID := fs.String("user", "", "owner user_id (must exist in users table)")
name := fs.String("name", "", "label for this token (e.g. PC name)")
if err := fs.Parse(args); err != nil {
return err
}
if *userID == "" || *name == "" {
return fmt.Errorf("--user and --name required")
}
db, err := openDB(*dbPath)
if err != nil {
return fmt.Errorf("open db: %w", err)
}
defer db.Close()
var exists int
if err := db.conn.QueryRow(`SELECT COUNT(*) FROM users WHERE id=?`, *userID).Scan(&exists); err != nil {
return fmt.Errorf("user lookup: %w", err)
}
if exists == 0 {
return fmt.Errorf("user %q not found", *userID)
}
plaintext, tok, err := db.MintMCPToken(*userID, *name)
if err != nil {
return fmt.Errorf("mint: %w", err)
}
fmt.Printf("token id: %s\n", tok.ID)
fmt.Printf("name: %s\n", tok.Name)
fmt.Printf("created_at: %s\n", tok.CreatedAt)
fmt.Printf("\ntoken (save now, will not be shown again):\n%s\n", plaintext)
return nil
}
+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 {