Files
kanban/backend/mcp_tokens.go
T
egutierrez 65771ebb12 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.
2026-05-28 09:36:48 +02:00

175 lines
4.9 KiB
Go

package main
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"flag"
"fmt"
)
// MCPToken is a per-user access token used by remote Claude clients to talk to
// the kanban MCP HTTP endpoint. The plaintext value is shown ONCE at creation
// time; we only persist the SHA-256 hash.
type MCPToken struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt *string `json:"last_used_at,omitempty"`
}
const mcpTokenPrefix = "kmcp_"
var errMCPTokenNotFound = errors.New("mcp token not found")
// MintMCPToken creates a new active token for userID and returns the plaintext
// value (caller must surface it to the user immediately; it cannot be
// recovered later) along with the row metadata.
func (db *DB) MintMCPToken(userID, name string) (string, *MCPToken, error) {
if userID == "" {
return "", nil, fmt.Errorf("user_id required")
}
plaintext, err := generateMCPTokenPlaintext()
if err != nil {
return "", nil, fmt.Errorf("generate token: %w", err)
}
tok := &MCPToken{
ID: newID(),
Name: name,
CreatedAt: nowRFC3339(),
}
_, err = db.conn.Exec(
`INSERT INTO mcp_tokens (id, user_id, token_hash, name, created_at) VALUES (?, ?, ?, ?, ?)`,
tok.ID, userID, hashMCPToken(plaintext), tok.Name, tok.CreatedAt,
)
if err != nil {
return "", nil, err
}
return plaintext, tok, nil
}
func (db *DB) ListMCPTokens(userID string) ([]MCPToken, error) {
rows, err := db.conn.Query(
`SELECT id, name, created_at, last_used_at FROM mcp_tokens
WHERE user_id=? AND revoked_at IS NULL
ORDER BY created_at DESC`, userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MCPToken{}
for rows.Next() {
var t MCPToken
var lastUsed sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.CreatedAt, &lastUsed); err != nil {
return nil, err
}
if lastUsed.Valid {
t.LastUsedAt = &lastUsed.String
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeMCPToken sets revoked_at on the token belonging to userID. Returns
// errMCPTokenNotFound if no active row matches.
func (db *DB) RevokeMCPToken(userID, tokenID string) error {
res, err := db.conn.Exec(
`UPDATE mcp_tokens SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL`,
nowRFC3339(), tokenID, userID,
)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return errMCPTokenNotFound
}
return nil
}
// LookupMCPToken hashes plaintext and returns the owning user_id if the token
// is active. Updates last_used_at as a side effect. Returns "" + nil when the
// token does not match an active row.
func (db *DB) LookupMCPToken(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
hash := hashMCPToken(plaintext)
var userID, id string
err := db.conn.QueryRow(
`SELECT id, user_id FROM mcp_tokens WHERE token_hash=? AND revoked_at IS NULL`, hash,
).Scan(&id, &userID)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
if _, err := db.conn.Exec(`UPDATE mcp_tokens SET last_used_at=? WHERE id=?`, nowRFC3339(), id); err != nil {
return userID, fmt.Errorf("touch last_used_at: %w", err)
}
return userID, nil
}
func hashMCPToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
func generateMCPTokenPlaintext() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
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
}