diff --git a/app.md b/app.md index 459ea65..09ad756 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: kanban lang: go domain: tools -version: 0.3.0 +version: 0.4.0 description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go." tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking] uses_functions: @@ -38,6 +38,7 @@ uses_functions: - fetch_json_ts_infra - claude_stream_go_core - mcp_server_stdio_go_infra + - mcp_server_http_go_infra - ws_upgrader_go_infra uses_types: - DurationStats_go_datascience @@ -182,3 +183,5 @@ Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`: - `patch`: bugfix sin cambio observable. - v0.1.0 (2026-05-18) — baseline. +- v0.3.1 (2026-05-21) — patch: debounce board.invalidated (300ms trailing) + autoClose 4s en toasts de notification.created. Fix de blow-up de memoria en navegador por ráfagas de SSE. +- v0.4.0 (2026-05-22) — minor: endpoint MCP Streamable HTTP `/mcp` con per-user bearer tokens (tabla `mcp_tokens`, migration 016). Modal "MCP tokens" en avatar menu para generar/listar/revocar. Vite proxy enruta `/mcp` a WSL. Usa nueva funcion `mcp_server_http_go_infra`. Doc en `docs/MCP.md`. diff --git a/backend/handlers.go b/backend/handlers.go index 5995716..9e76810 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -670,6 +670,9 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str {Method: "GET", Path: "/api/notifications/unread-count", Handler: handleUnreadCount(db)}, {Method: "POST", Path: "/api/notifications/{id}/read", Handler: handleMarkNotificationRead(db, hub)}, {Method: "POST", Path: "/api/notifications/read-all", Handler: handleMarkAllNotificationsRead(db, hub)}, + {Method: "POST", Path: "/api/mcp-tokens", Handler: handleCreateMCPToken(db)}, + {Method: "GET", Path: "/api/mcp-tokens", Handler: handleListMCPTokens(db)}, + {Method: "DELETE", Path: "/api/mcp-tokens/{id}", Handler: handleRevokeMCPToken(db)}, {Method: "GET", Path: "/api/modules", Handler: handleListModules(db)}, {Method: "POST", Path: "/api/modules", Handler: handleCreateModule(db)}, {Method: "PATCH", Path: "/api/modules/{id}", Handler: handleUpdateModule(db)}, diff --git a/backend/main.go b/backend/main.go index 4f8ce7b..9edbcda 100644 --- a/backend/main.go +++ b/backend/main.go @@ -74,6 +74,8 @@ func main() { defer dispatcher.Stop() mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags, hub, dispatcher)) + mux.Handle("/mcp", mcpHTTPHandler(db)) + feHandler := frontendHandler() if feHandler != nil { mux.Handle("/", feHandler) diff --git a/backend/mcp_http.go b/backend/mcp_http.go new file mode 100644 index 0000000..638854e --- /dev/null +++ b/backend/mcp_http.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os" + "strings" + + "fn-registry/functions/infra" +) + +// 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. +func mcpHTTPHandler(db *DB) http.Handler { + auth := func(r *http.Request) (context.Context, error) { + header := r.Header.Get("Authorization") + token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + if token == "" || token == header { + return nil, errors.New("missing bearer token") + } + userID, err := db.LookupMCPToken(token) + if err != nil { + return nil, err + } + if userID == "" { + return nil, errors.New("invalid or revoked token") + } + return context.WithValue(r.Context(), userCtxKey, userID), nil + } + + handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) { + body := input + if len(body) == 0 { + body = json.RawMessage(`{}`) + } + res := executeTool(db, name, body) + if !res.OK { + return res.Error, true, nil + } + return res.Result, false, nil + } + + return infra.MCPHTTPHandler(infra.MCPHTTPOpts{ + Name: "kanban", + Version: Version, + Tools: mcpToolDefs(), + Handler: handler, + Auth: auth, + Logger: os.Stderr, + }) +} diff --git a/backend/mcp_tokens.go b/backend/mcp_tokens.go new file mode 100644 index 0000000..6c2e37b --- /dev/null +++ b/backend/mcp_tokens.go @@ -0,0 +1,132 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "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 +} diff --git a/backend/mcp_tokens_handlers.go b/backend/mcp_tokens_handlers.go new file mode 100644 index 0000000..636220f --- /dev/null +++ b/backend/mcp_tokens_handlers.go @@ -0,0 +1,83 @@ +package main + +import ( + "errors" + "net/http" + "strings" + + "fn-registry/functions/infra" +) + +// POST /api/mcp-tokens {name} +// +// Mints a new MCP token for the current user. The plaintext token is returned +// ONLY in this response — there is no way to retrieve it again. +func handleCreateMCPToken(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if userID == "" { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"}) + return + } + var body struct { + Name string `json:"name"` + } + if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil { + badRequest(w, err.Error()) + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + name = "default" + } + plaintext, tok, err := db.MintMCPToken(userID, name) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusCreated, map[string]any{ + "id": tok.ID, + "name": tok.Name, + "created_at": tok.CreatedAt, + "token": plaintext, + }) + } +} + +// GET /api/mcp-tokens +func handleListMCPTokens(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if userID == "" { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"}) + return + } + tokens, err := db.ListMCPTokens(userID) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, tokens) + } +} + +// DELETE /api/mcp-tokens/{id} +func handleRevokeMCPToken(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + if userID == "" { + infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "login required"}) + return + } + id := r.PathValue("id") + if err := db.RevokeMCPToken(userID, id); err != nil { + if errors.Is(err, errMCPTokenNotFound) { + notFound(w, "token not found") + return + } + serverError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/backend/migrations/016_mcp_tokens.sql b/backend/migrations/016_mcp_tokens.sql new file mode 100644 index 0000000..4a4a5c3 --- /dev/null +++ b/backend/migrations/016_mcp_tokens.sql @@ -0,0 +1,26 @@ +-- Per-user MCP access tokens. Users mint tokens from the settings UI and +-- paste them into their local Claude (`claude mcp add --transport http ...`). +-- The plaintext token is shown ONCE at creation time; we only store the hash. +-- +-- token_hash is a SHA-256 hex digest of the plaintext token. Lookup on +-- incoming requests: hash the bearer, look up the row, accept if not revoked. +-- +-- revoked_at is NULL for active tokens. Tokens are never deleted (audit +-- trail); revocation is a soft delete. +CREATE TABLE IF NOT EXISTS mcp_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_mcp_tokens_user_active + ON mcp_tokens(user_id) + WHERE revoked_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_mcp_tokens_hash_active + ON mcp_tokens(token_hash) + WHERE revoked_at IS NULL; diff --git a/docs/INSTALL_CLAUDE_MCP.md b/docs/INSTALL_CLAUDE_MCP.md new file mode 100644 index 0000000..5f58f3f --- /dev/null +++ b/docs/INSTALL_CLAUDE_MCP.md @@ -0,0 +1,132 @@ +# Prompt para instalar el MCP del kanban en Claude Code + +Este documento esta escrito **para Claude**, no para un humano. Pegalo en tu sesion de Claude Code junto con tu URL y tu token, y Claude registrara el MCP server `kanban` por ti, verificara la conexion y te dira como invocarlo. + +--- + +## Contexto + +El kanban (https://github.com/... — repo interno) expone un endpoint **MCP Streamable HTTP** en `/mcp` con autenticacion Bearer por usuario. Cada usuario genera su propio token en la UI ("avatar menu → MCP tokens → Generar"). El token cifrado no se recupera: si se pierde, hay que generar otro y revocar el anterior. + +El MCP server expone 14 tools para gestionar el board: + +| Tool | Que hace | +|---|---| +| `list_board` | Lista columnas y tarjetas | +| `find_cards` | Busca por texto/columna/solicitante | +| `create_card` / `update_card` / `delete_card` | CRUD de tarjetas | +| `move_card` | Mueve tarjeta a otra columna | +| `assign_card` | Asigna o desasigna usuario | +| `card_history` | Cambios historicos de una tarjeta | +| `create_column` / `update_column` / `rename_column` / `delete_column` / `reorder_columns` | Admin de columnas | +| `list_users` | Lista usuarios disponibles | + +--- + +## Datos que necesitas pedir al usuario (o que recibes con el prompt) + +- `MCP_URL`: URL completa del endpoint, por ejemplo `http://192.168.1.50:5180/mcp`. Suele ser el mismo host:puerto donde el usuario abre el kanban en el navegador + sufijo `/mcp`. +- `MCP_TOKEN`: token que empieza por `kmcp_` seguido de 64 caracteres hex. El usuario lo copio del modal "MCP tokens". + +Si el usuario no te ha dado uno o ambos, pidelo antes de continuar. NO inventes valores ni uses placeholders. + +--- + +## Pasos que tienes que ejecutar + +### 1. Comprobar prerequisitos + +```bash +claude --version +claude mcp list 2>&1 | head +``` + +Si `claude` no esta en el PATH, indica al usuario que instale Claude Code antes de continuar (no intentes instalarlo tu). + +### 2. Registrar el MCP server + +```bash +claude mcp add kanban \ + --transport http \ + --header "Authorization: Bearer ${MCP_TOKEN}" \ + -- ${MCP_URL} +``` + +Notas: + +- Si ya existe un MCP `kanban` previo, primero `claude mcp remove kanban` y luego registra el nuevo. NO intentes editarlo en place. +- Si el comando devuelve error sobre `--transport`, comprueba que la version de Claude Code soporta MCP HTTP (>= 2.0.0). En versiones antiguas usa `mcp-remote` como bridge: + ```bash + claude mcp add kanban -- npx -y mcp-remote "${MCP_URL}" --header "Authorization: Bearer ${MCP_TOKEN}" + ``` + +### 3. Verificar conexion + +```bash +claude mcp list +``` + +Tiene que aparecer una linea como: + +``` +kanban http ✓ connected +``` + +Si aparece `✗ failed` o un error de conexion, comprueba: + +- Que el host del kanban es accesible desde esta maquina (`curl -s -o /dev/null -w '%{http_code}\n' ${MCP_URL}` debe devolver `405` — es POST-only). +- Que el token no caduco ni fue revocado. +- Que la URL termina exactamente en `/mcp` (sin barra final). + +### 4. Probar una llamada real + +```bash +claude -p "Usa la tool mcp__kanban__list_board y dime cuantas columnas tiene mi tablero y cuantas tarjetas hay en total." \ + --allowed-tools mcp__kanban__list_board +``` + +Output esperado: un resumen en lenguaje natural con el numero de columnas y tarjetas. Si Claude responde "no tengo acceso a esa tool" o "MCP no esta configurado", vuelve al paso 2. + +### 5. Resumir al usuario + +Cuando termines, dile al usuario: + +- Si la conexion esta OK y el smoke test paso. +- Que tools tiene disponibles. +- Como invocarlas en futuras sesiones (por ejemplo: "crea una tarjeta para revisar el reporte mensual" o "muevela a la columna Doing"). +- Como revocar el token si pierde el control de esta maquina. + +--- + +## Errores frecuentes + +| Sintoma | Causa probable | Accion | +|---|---|---| +| `claude mcp add` no acepta `--transport http` | Version vieja de Claude Code | Usar `mcp-remote` (ver paso 2). | +| `connection refused` | El kanban no esta corriendo o el puerto cambio | Confirmar con el usuario que abre el kanban en el navegador. | +| `401 unauthorized` | Token mal copiado o revocado | Generar nuevo token en la UI, repetir paso 2. | +| `405 Method Not Allowed` en smoke test | URL apuntando a un GET en vez de POST | El endpoint es POST-only; el flujo de `claude mcp` lo gestiona, pero un `curl` manual con GET fallara. | +| Tools no aparecen tras instalar | Sesion de Claude Code cacheo la config vieja | Cierra y vuelve a abrir Claude Code. | + +--- + +## Que NO hacer + +- No escribas el token en plain text en ningun archivo del repositorio del usuario, ni en logs, ni en commits, ni en mensajes que persistan. +- No intentes "probar" el token llamando al endpoint con `curl` y pegandolo visible — solo usa el comando `claude mcp add`. +- No modifiques `~/.claude.json` a mano; usa siempre `claude mcp add/remove`. +- No expongas el endpoint `/mcp` a redes mas amplias que las del usuario sin consultarle. +- No crees, modifiques ni borres tarjetas durante el smoke test salvo que el usuario lo pida explicitamente. Usa solo `list_board` para validar. + +--- + +## Si algo no esta claro + +Pidele al usuario: + +- El URL exacto que abre en el navegador para usar el kanban (sin `/mcp`; lo añades tu). +- El token recien generado (NO uno viejo). +- La version de Claude Code (`claude --version`). +- El SO en el que esta (`uname -a` o, en Windows, `ver`). + +Con eso puedes terminar la instalacion en menos de un minuto. diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..87a5e31 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,79 @@ +# Conectar Claude al kanban via MCP + +El kanban expone un endpoint **MCP HTTP** (`/mcp`) que permite a un cliente Claude leer y modificar el tablero de cada usuario. + +## Cuando usarlo + +- Pedir a Claude que cree, actualice, mueva o busque tarjetas desde tu terminal local sin abrir el navegador. +- Listar el board en lenguaje natural. +- Asignar tarjetas, consultar historial, etc. + +## Configuracion (una vez por PC) + +### 1. Generar token en el kanban + +1. Abre el kanban en el navegador (mismo URL que usas normalmente, por ejemplo `http://:5180`). +2. Click en tu avatar (esquina superior derecha) → **MCP tokens**. +3. Pulsa **Generar**, dale un nombre descriptivo (por ejemplo `portatil-trabajo`). +4. **Copia el token inmediatamente** — solo se muestra una vez. Tambien tendras el comando `claude mcp add` listo para pegar. + +### 2. Registrar el MCP en Claude Code + +En el PC desde el que vas a usar Claude: + +```bash +claude mcp add kanban --transport http http://:5180/mcp \ + --header "Authorization: Bearer kmcp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +``` + +Reemplaza `` por la IP o nombre del PC Windows que sirve el kanban en la LAN, y el token por el valor que copiaste. + +Verifica con: + +```bash +claude mcp list +``` + +Tienes que ver `kanban` con estado **connected**. + +## Tools disponibles + +Una vez conectado, Claude puede invocar: + +| Tool | Que hace | +|---|---| +| `list_board` | Devuelve columnas y tarjetas del tablero | +| `create_column` | Crea una columna nueva | +| `update_column` | Modifica nombre, ancho, WIP, ubicacion, terminal | +| `rename_column` | Alias rapido de `update_column` con `{id, name}` | +| `delete_column` | Borra una columna (cards a papelera) | +| `reorder_columns` | Reordena columnas | +| `create_card` | Crea tarjeta en una columna | +| `update_card` | Edita titulo, descripcion, color, lock, asignado | +| `delete_card` | Envia tarjeta a papelera | +| `move_card` | Mueve tarjeta a otra columna | +| `card_history` | Historial de cambios de una tarjeta | +| `find_cards` | Busca por texto/columna/solicitante | +| `list_users` | Usuarios disponibles para asignar | +| `assign_card` | Asigna o desasigna usuario | + +## Revocar acceso + +Si pierdes el PC o quieres rotar el token, vuelve al modal **MCP tokens** y pulsa el icono de papelera en la fila correspondiente. El cliente Claude perdera acceso al instante. + +## Limitaciones actuales + +- Las acciones por MCP no registran `actor_id` en el historial — quedan como anonimas. (Mejora pendiente.) +- No hay rate limiting por token; revoca si detectas mal uso. +- El endpoint NO soporta SSE server→client (solicitudes Claude→kanban funcionan, sin streaming inverso). +- Solo POST `/mcp` esta soportado; GET y DELETE devuelven 405. +- Body limit 1 MiB. + +## Troubleshooting + +| Sintoma | Probable causa | +|---|---| +| `claude mcp list` muestra error de conexion | Vite (puerto 5180) o backend (8095) parados. Lanza el `control.sh` del kanban. | +| `401 unauthorized` | Token mal pegado, revocado, o caducado. Genera uno nuevo. | +| `405 Method Not Allowed` | Estas haciendo GET; el MCP solo acepta POST. | +| Tools listan pero `list_board` falla | Backend devuelve error real — mira `kanban.log` en WSL. | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 15630ee..7d0505a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,7 @@ import { IconLayoutKanban, IconLogout, IconPlug, + IconKey, IconMenu2, IconMessageChatbot, IconMoodSmile, @@ -84,6 +85,7 @@ import { AVATAR_COLORS } from "./components/colors"; import { colorBg, colorBorder } from "./components/colors"; import { NotificationsBell } from "./components/NotificationsBell"; import { ModulesModal } from "./components/ModulesModal"; +import { MCPTokensModal } from "./components/MCPTokensModal"; import { useEventStream } from "./hooks/useEventStream"; import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types"; @@ -255,6 +257,23 @@ export function App() { } }, []); + // Coalesce ráfagas de board.invalidated (trailing debounce 300ms) — sin esto + // cada mutación remota dispara un refetch /api/board completo y la memoria + // del navegador crece sin techo. + const reloadTimerRef = useRef | null>(null); + const debouncedReload = useCallback(() => { + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = setTimeout(() => { + reloadTimerRef.current = null; + reload(); + }, 300); + }, [reload]); + useEffect(() => { + return () => { + if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); + }; + }, []); + useEffect(() => { reload(); }, [reload]); @@ -344,6 +363,7 @@ export function App() { }, []); const [modulesOpen, setModulesOpen] = useState(false); + const [mcpTokensOpen, setMcpTokensOpen] = useState(false); const reloadNotifs = useCallback(async () => { try { @@ -367,7 +387,7 @@ export function App() { useMemo( () => ({ "board.invalidated": () => { - reload(); + debouncedReload(); }, "notification.created": (payload: unknown) => { const n = payload as Notification; @@ -377,6 +397,7 @@ export function App() { const who = n.actor_name || "Alguien"; const card = n.card_seq_num ? `#${n.card_seq_num}` : n.card_title; notifications.show({ + autoClose: 4000, color: n.kind === "mention" ? "grape" : "blue", title: `${who} en ${card}`, message: n.snippet, @@ -393,7 +414,7 @@ export function App() { setNotifUnread(0); }, }), - [reload], + [debouncedReload], ), !!auth.user, ); @@ -428,16 +449,21 @@ export function App() { (c: Card): boolean => { const term = searchTerm.trim().toLowerCase(); if (term) { + const seqStr = c.seq_num > 0 ? String(c.seq_num) : ""; + const seqPadded = c.seq_num > 0 ? String(c.seq_num).padStart(5, "0") : ""; const hay = [ c.title, c.description, c.requester, + seqStr, + seqPadded, ...(c.tags || []), ] .filter(Boolean) .join(" ") .toLowerCase(); - if (!hay.includes(term)) return false; + const normalizedTerm = term.replace(/^#/, "").replace(/^0+(?=\d)/, ""); + if (!hay.includes(term) && !(normalizedTerm && hay.includes(normalizedTerm))) return false; } if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false; if (filterUnassigned && c.assignee_id) return false; @@ -1266,6 +1292,12 @@ export function App() { Modulos )} + } + onClick={() => setMcpTokensOpen(true)} + > + MCP tokens + } color="red" @@ -1279,6 +1311,7 @@ export function App() { {auth.user?.is_admin && ( setModulesOpen(false)} /> )} + setMcpTokensOpen(false)} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f700b7a..e221f71 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -443,6 +443,29 @@ export function listRequesters(): Promise { return fetchJSON("/requesters"); } +export interface MCPToken { + id: string; + name: string; + created_at: string; + last_used_at?: string; +} + +export interface MCPTokenCreated extends MCPToken { + token: string; +} + +export function createMCPToken(name: string): Promise { + return fetchJSON("/mcp-tokens", { method: "POST", body: JSON.stringify({ name }) }); +} + +export function listMCPTokens(): Promise { + return fetchJSON("/mcp-tokens"); +} + +export function revokeMCPToken(id: string): Promise { + return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" }); +} + export function getMetrics(f: MetricsFilter): Promise { const qs = new URLSearchParams(); if (f.from) qs.set("from", f.from); diff --git a/frontend/src/components/MCPTokensModal.tsx b/frontend/src/components/MCPTokensModal.tsx new file mode 100644 index 0000000..743cf31 --- /dev/null +++ b/frontend/src/components/MCPTokensModal.tsx @@ -0,0 +1,192 @@ +import { + ActionIcon, + Alert, + Box, + Button, + Code, + CopyButton, + Divider, + Group, + Loader, + Modal, + Stack, + Table, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { notifications } from "@mantine/notifications"; +import { IconCopy, IconCheck, IconTrash } from "@tabler/icons-react"; +import { useCallback, useEffect, useState } from "react"; +import * as api from "../api"; +import type { MCPToken, MCPTokenCreated } from "../api"; +import { formatDateTimeShort } from "./format"; + +interface Props { + opened: boolean; + onClose: () => void; +} + +export function MCPTokensModal({ opened, onClose }: Props) { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(false); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + const [justCreated, setJustCreated] = useState(null); + + const reload = useCallback(async () => { + setLoading(true); + try { + setTokens(await api.listMCPTokens()); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (opened) { + reload(); + setJustCreated(null); + setNewName(""); + } + }, [opened, reload]); + + const create = async () => { + const name = newName.trim() || "default"; + setCreating(true); + try { + const t = await api.createMCPToken(name); + setJustCreated(t); + setNewName(""); + await reload(); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + } finally { + setCreating(false); + } + }; + + const revoke = async (id: string) => { + if (!confirm("Revocar este token? Quien lo este usando dejara de tener acceso.")) return; + try { + await api.revokeMCPToken(id); + await reload(); + } catch (e) { + notifications.show({ color: "red", message: (e as Error).message }); + } + }; + + const mcpURL = `${window.location.origin}/mcp`; + const claudeCmd = justCreated + ? `claude mcp add kanban --transport http ${mcpURL} --header "Authorization: Bearer ${justCreated.token}"` + : ""; + + return ( + + + + Cada token deja conectar un cliente Claude al kanban como tu usuario. + El valor solo aparece UNA vez al crearlo. Si lo pierdes, generas otro y revocas el antiguo. + + + + setNewName(e.currentTarget.value)} + style={{ flex: 1 }} + disabled={creating} + /> + + + + {justCreated && ( + + + + {justCreated.token} + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + + Pega este comando en tu PC para registrar el MCP en Claude Code: + + + {claudeCmd} + + {({ copied, copy }) => ( + + + {copied ? : } + + + )} + + + + + )} + + + + {loading ? ( + + + + ) : tokens.length === 0 ? ( + + Sin tokens. Genera uno arriba. + + ) : ( + + + + Nombre + Creado + Ultimo uso + + + + + {tokens.map((t) => ( + + {t.name} + {formatDateTimeShort(t.created_at)} + + {t.last_used_at ? formatDateTimeShort(t.last_used_at) : nunca} + + + + revoke(t.id)}> + + + + + + ))} + +
+ )} + + + + Endpoint MCP: {mcpURL} + + +
+
+ ); +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7852d5c..b9a2538 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -17,6 +17,10 @@ export default defineConfig({ ws: true, changeOrigin: true, }, + "/mcp": { + target: process.env.VITE_API_TARGET || "http://127.0.0.1:8095", + changeOrigin: true, + }, }, }, build: {