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:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user