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:
2026-05-22 14:38:17 +02:00
parent c9e15513c7
commit c28ae7d3c0
13 changed files with 771 additions and 4 deletions
+4 -1
View File
@@ -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`.
+3
View File
@@ -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)},
+2
View File
@@ -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)
+55
View File
@@ -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,
})
}
+132
View File
@@ -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
}
+83
View File
@@ -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)
}
}
+26
View File
@@ -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;
+132
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+23
View File
@@ -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);
+192
View File
@@ -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>
);
}
+4
View File
@@ -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: {