chore: auto-commit (12 archivos)
- app.md - backend/handlers.go - backend/main.go - frontend/src/App.tsx - frontend/src/api.ts - frontend/vite.config.ts - backend/mcp_http.go - backend/mcp_tokens.go - backend/mcp_tokens_handlers.go - backend/migrations/016_mcp_tokens.sql - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
+79
@@ -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://<host-windows>: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://<host-windows>:5180/mcp \
|
||||
--header "Authorization: Bearer kmcp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
Reemplaza `<host-windows>` 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. |
|
||||
+36
-3
@@ -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<ReturnType<typeof setTimeout> | 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
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<IconKey size={14} />}
|
||||
onClick={() => setMcpTokensOpen(true)}
|
||||
>
|
||||
MCP tokens
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={14} />}
|
||||
color="red"
|
||||
@@ -1279,6 +1311,7 @@ export function App() {
|
||||
{auth.user?.is_admin && (
|
||||
<ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
|
||||
)}
|
||||
<MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
@@ -443,6 +443,29 @@ export function listRequesters(): Promise<string[]> {
|
||||
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<MCPTokenCreated> {
|
||||
return fetchJSON("/mcp-tokens", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
export function listMCPTokens(): Promise<MCPToken[]> {
|
||||
return fetchJSON("/mcp-tokens");
|
||||
}
|
||||
|
||||
export function revokeMCPToken(id: string): Promise<void> {
|
||||
return fetchJSON(`/mcp-tokens/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||
const qs = new URLSearchParams();
|
||||
if (f.from) qs.set("from", f.from);
|
||||
|
||||
@@ -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<MCPToken[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [justCreated, setJustCreated] = useState<MCPTokenCreated | null>(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 (
|
||||
<Modal opened={opened} onClose={onClose} title="MCP Tokens" size="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
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.
|
||||
</Text>
|
||||
|
||||
<Group align="end">
|
||||
<TextInput
|
||||
label="Nombre del token"
|
||||
placeholder="ej. portatil, sobremesa..."
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
disabled={creating}
|
||||
/>
|
||||
<Button onClick={create} loading={creating}>
|
||||
Generar
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{justCreated && (
|
||||
<Alert color="yellow" title="Copia el token ahora — no se mostrara mas">
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs" align="center">
|
||||
<Code style={{ flex: 1, wordBreak: "break-all" }}>{justCreated.token}</Code>
|
||||
<CopyButton value={justCreated.token}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "Copiado" : "Copiar token"}>
|
||||
<ActionIcon variant="subtle" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Text size="xs" c="dimmed">
|
||||
Pega este comando en tu PC para registrar el MCP en Claude Code:
|
||||
</Text>
|
||||
<Group gap="xs" align="center">
|
||||
<Code block style={{ flex: 1 }}>{claudeCmd}</Code>
|
||||
<CopyButton value={claudeCmd}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "Copiado" : "Copiar comando"}>
|
||||
<ActionIcon variant="subtle" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider label="Tokens activos" labelPosition="left" />
|
||||
|
||||
{loading ? (
|
||||
<Group justify="center" p="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : tokens.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
Sin tokens. Genera uno arriba.
|
||||
</Text>
|
||||
) : (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Nombre</Table.Th>
|
||||
<Table.Th>Creado</Table.Th>
|
||||
<Table.Th>Ultimo uso</Table.Th>
|
||||
<Table.Th w={60} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{tokens.map((t) => (
|
||||
<Table.Tr key={t.id}>
|
||||
<Table.Td>{t.name}</Table.Td>
|
||||
<Table.Td>{formatDateTimeShort(t.created_at)}</Table.Td>
|
||||
<Table.Td>
|
||||
{t.last_used_at ? formatDateTimeShort(t.last_used_at) : <Text c="dimmed">nunca</Text>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Revocar">
|
||||
<ActionIcon color="red" variant="subtle" onClick={() => revoke(t.id)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
Endpoint MCP: <Code>{mcpURL}</Code>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user