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
|
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)
|
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
||||||
port := flags.Int("port", 8095, "HTTP port")
|
port := flags.Int("port", 8095, "HTTP port")
|
||||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||||
|
|||||||
@@ -279,6 +279,32 @@ func mcpToolDefs() []infra.MCPToolDef {
|
|||||||
"required": []string{"card_id"},
|
"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
|
// mcpHTTPHandler builds the http.Handler that serves the MCP Streamable HTTP
|
||||||
// transport for remote Claude clients. Bearer-auth backed by the mcp_tokens
|
// transport for remote Claude clients. Bearer-auth backed by the mcp_tokens
|
||||||
// table; tool dispatch reuses executeTool() — the same set of operations the
|
// table; tool dispatch reuses executeToolAs() so per-user tools (add_comment,
|
||||||
// chat assistant uses internally.
|
// delete_comment) can infer the actor from the authenticated token.
|
||||||
func mcpHTTPHandler(db *DB) http.Handler {
|
func mcpHTTPHandler(db *DB) http.Handler {
|
||||||
auth := func(r *http.Request) (context.Context, error) {
|
auth := func(r *http.Request) (context.Context, error) {
|
||||||
header := r.Header.Get("Authorization")
|
header := r.Header.Get("Authorization")
|
||||||
@@ -37,7 +37,8 @@ func mcpHTTPHandler(db *DB) http.Handler {
|
|||||||
if len(body) == 0 {
|
if len(body) == 0 {
|
||||||
body = json.RawMessage(`{}`)
|
body = json.RawMessage(`{}`)
|
||||||
}
|
}
|
||||||
res := executeTool(db, name, body)
|
actor, _ := infra.UserIDFromContext(ctx, userCtxKey)
|
||||||
|
res := executeToolAs(db, name, body, actor)
|
||||||
if !res.OK {
|
if !res.OK {
|
||||||
return res.Error, true, nil
|
return res.Error, true, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,3 +131,44 @@ func generateMCPTokenPlaintext() (string, error) {
|
|||||||
}
|
}
|
||||||
return mcpTokenPrefix + hex.EncodeToString(b), nil
|
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} }
|
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.
|
// 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 {
|
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 {
|
switch name {
|
||||||
case "list_board":
|
case "list_board":
|
||||||
return toolListBoard(db)
|
return toolListBoard(db)
|
||||||
@@ -50,10 +57,14 @@ func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
|
|||||||
return toolListUsers(db)
|
return toolListUsers(db)
|
||||||
case "assign_card":
|
case "assign_card":
|
||||||
return toolAssignCard(db, input)
|
return toolAssignCard(db, input)
|
||||||
|
case "get_card":
|
||||||
|
return toolGetCard(db, input)
|
||||||
case "add_comment":
|
case "add_comment":
|
||||||
return toolAddComment(db, input)
|
return toolAddCommentAs(db, input, actor)
|
||||||
case "list_comments":
|
case "list_comments":
|
||||||
return toolListComments(db, input)
|
return toolListComments(db, input)
|
||||||
|
case "delete_comment":
|
||||||
|
return toolDeleteComment(db, input, actor)
|
||||||
default:
|
default:
|
||||||
return errMsg("unknown tool: " + name)
|
return errMsg("unknown tool: " + name)
|
||||||
}
|
}
|
||||||
@@ -64,7 +75,7 @@ func toolMutates(name string) bool {
|
|||||||
switch name {
|
switch name {
|
||||||
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
|
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
|
||||||
"create_card", "update_card", "delete_card", "move_card", "assign_card",
|
"create_card", "update_card", "delete_card", "move_card", "assign_card",
|
||||||
"add_comment":
|
"add_comment", "delete_comment":
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -352,7 +363,8 @@ func validateToolName(name string) error {
|
|||||||
"update_card": true, "delete_card": true, "move_card": true,
|
"update_card": true, "delete_card": true, "move_card": true,
|
||||||
"card_history": true, "find_cards": true,
|
"card_history": true, "find_cards": true,
|
||||||
"list_users": true, "assign_card": 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] {
|
if !known[name] {
|
||||||
return fmt.Errorf("unknown tool: %s", name)
|
return fmt.Errorf("unknown tool: %s", name)
|
||||||
@@ -360,10 +372,15 @@ func validateToolName(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolAddComment appends a comment (card_message) to a card. Accepts either
|
// toolAddCommentAs appends a comment (card_message) to a card.
|
||||||
// {card_id, body, author_id} or {card_id, body, author_username}. Resolves
|
//
|
||||||
// the username to an id when needed.
|
// Author resolution order:
|
||||||
func toolAddComment(db *DB, input json.RawMessage) ToolResult {
|
// 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 {
|
var in struct {
|
||||||
CardID string `json:"card_id"`
|
CardID string `json:"card_id"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
@@ -380,16 +397,19 @@ func toolAddComment(db *DB, input json.RawMessage) ToolResult {
|
|||||||
return errMsg("body required")
|
return errMsg("body required")
|
||||||
}
|
}
|
||||||
authorID := strings.TrimSpace(in.AuthorID)
|
authorID := strings.TrimSpace(in.AuthorID)
|
||||||
if authorID == "" {
|
if authorID == "" && in.AuthorUsername != "" {
|
||||||
if in.AuthorUsername == "" {
|
|
||||||
return errMsg("author_id or author_username required")
|
|
||||||
}
|
|
||||||
u, _, err := db.GetUserByUsername(in.AuthorUsername)
|
u, _, err := db.GetUserByUsername(in.AuthorUsername)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errResult(fmt.Errorf("author_username: %w", err))
|
return errResult(fmt.Errorf("author_username: %w", err))
|
||||||
}
|
}
|
||||||
authorID = u.ID
|
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)
|
m, err := db.CreateCardMessage(in.CardID, authorID, in.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errResult(err)
|
return errResult(err)
|
||||||
@@ -397,6 +417,55 @@ func toolAddComment(db *DB, input json.RawMessage) ToolResult {
|
|||||||
return okResult(m)
|
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
|
// toolListComments returns every comment (card_message) attached to a card
|
||||||
// sorted by created_at ascending.
|
// sorted by created_at ascending.
|
||||||
func toolListComments(db *DB, input json.RawMessage) ToolResult {
|
func toolListComments(db *DB, input json.RawMessage) ToolResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user