diff --git a/backend/main.go b/backend/main.go index 9edbcda..dc6c9d7 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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") diff --git a/backend/mcp.go b/backend/mcp.go index 66ec55a..bca18b0 100644 --- a/backend/mcp.go +++ b/backend/mcp.go @@ -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."}, + }, + }), + }, } } diff --git a/backend/mcp_http.go b/backend/mcp_http.go index 638854e..db5766e 100644 --- a/backend/mcp_http.go +++ b/backend/mcp_http.go @@ -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 } diff --git a/backend/mcp_tokens.go b/backend/mcp_tokens.go index 6c2e37b..b9e6287 100644 --- a/backend/mcp_tokens.go +++ b/backend/mcp_tokens.go @@ -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 --name `. +// 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 +} diff --git a/backend/tools.go b/backend/tools.go index d5f957b..870d53e 100644 --- a/backend/tools.go +++ b/backend/tools.go @@ -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 {