65771ebb12
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.
354 lines
11 KiB
Go
354 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"time"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
// runMCPServer is the entry point for the `kanban mcp` subcommand. It runs
|
|
// stdio JSON-RPC and forwards each tool call to the kanban backend's
|
|
// /api/tool/{name} endpoint, authenticated with a shared internal token.
|
|
//
|
|
// Required env vars (set by the parent kanban process when generating mcp.json):
|
|
// KANBAN_BACKEND_URL — e.g. http://127.0.0.1:8095
|
|
// KANBAN_INTERNAL_TOKEN — token to send in X-Internal-Token header
|
|
func runMCPServer(args []string) error {
|
|
fs := flag.NewFlagSet("kanban mcp", flag.ContinueOnError)
|
|
urlFlag := fs.String("url", os.Getenv("KANBAN_BACKEND_URL"), "kanban backend URL")
|
|
tokenFlag := fs.String("token", os.Getenv("KANBAN_INTERNAL_TOKEN"), "internal token")
|
|
if err := fs.Parse(args); err != nil {
|
|
return err
|
|
}
|
|
if *urlFlag == "" {
|
|
return fmt.Errorf("--url or KANBAN_BACKEND_URL required")
|
|
}
|
|
if *tokenFlag == "" {
|
|
return fmt.Errorf("--token or KANBAN_INTERNAL_TOKEN required")
|
|
}
|
|
|
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
|
|
|
tools := mcpToolDefs()
|
|
handler := func(ctx context.Context, name string, input json.RawMessage) (any, bool, error) {
|
|
body := []byte(input)
|
|
if len(body) == 0 {
|
|
body = []byte("{}")
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "POST", *urlFlag+"/api/tool/"+name, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set(internalTokenHeader, *tokenFlag)
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
buf, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
return nil, false, fmt.Errorf("backend %d: %s", resp.StatusCode, string(buf))
|
|
}
|
|
// 4xx and 2xx both serialize as ToolResult JSON. Decode and map.
|
|
var tr ToolResult
|
|
if err := json.Unmarshal(buf, &tr); err != nil {
|
|
// Non-ToolResult body (e.g. unauthorized error envelope from infra).
|
|
return string(buf), resp.StatusCode >= 400, nil
|
|
}
|
|
if !tr.OK {
|
|
return tr.Error, true, nil
|
|
}
|
|
return tr.Result, false, nil
|
|
}
|
|
|
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer cancel()
|
|
|
|
return infra.ServeMCP(ctx, infra.MCPServerOpts{
|
|
Name: "kanban",
|
|
Version: "1.0.0",
|
|
Tools: tools,
|
|
Handler: handler,
|
|
In: os.Stdin,
|
|
Out: os.Stdout,
|
|
Logger: os.Stderr,
|
|
})
|
|
}
|
|
|
|
// mcpToolDefs returns the JSON-Schema definitions for every kanban tool.
|
|
// Names match the executeTool dispatch table in tools.go.
|
|
func mcpToolDefs() []infra.MCPToolDef {
|
|
return []infra.MCPToolDef{
|
|
{
|
|
Name: "list_board",
|
|
Description: "Lista columnas y tarjetas del tablero. Sin argumentos. Devuelve {columns, cards}.",
|
|
InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}),
|
|
},
|
|
{
|
|
Name: "create_column",
|
|
Description: "Crea una columna nueva. Devuelve la columna creada con su id.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"name": map[string]any{"type": "string", "description": "Nombre de la columna"},
|
|
},
|
|
"required": []string{"name"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "update_column",
|
|
Description: "Modifica una columna existente. Pasa al menos uno: name, location ('board'|'sidebar'), width (200..800 px), wip_limit (0=sin limite), is_done (terminal: cards cuentan como completadas).",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
"name": map[string]any{"type": "string"},
|
|
"location": map[string]any{"type": "string", "enum": []string{"board", "sidebar"}},
|
|
"width": map[string]any{"type": "integer"},
|
|
"wip_limit": map[string]any{"type": "integer"},
|
|
"is_done": map[string]any{"type": "boolean"},
|
|
},
|
|
"required": []string{"id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "rename_column",
|
|
Description: "Alias de update_column con solo {id, name}.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
"name": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"id", "name"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "delete_column",
|
|
Description: "Elimina una columna y todas sus tarjetas (las envia a la papelera).",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "reorder_columns",
|
|
Description: "Reordena columnas. ids es el array completo de columnas en el nuevo orden.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
},
|
|
"required": []string{"ids"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "create_card",
|
|
Description: "Crea una tarjeta en una columna. column_id y title obligatorios.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"column_id": map[string]any{"type": "string"},
|
|
"requester": map[string]any{"type": "string"},
|
|
"title": map[string]any{"type": "string"},
|
|
"description": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"column_id", "title"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "update_card",
|
|
Description: "Edita campos de una tarjeta. Color: blue|teal|violet|pink|orange|green|yellow|red|''. locked bloquea movimiento. assignee_id null para desasignar.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
"requester": map[string]any{"type": "string"},
|
|
"title": map[string]any{"type": "string"},
|
|
"description": map[string]any{"type": "string"},
|
|
"color": map[string]any{"type": "string"},
|
|
"locked": map[string]any{"type": "boolean"},
|
|
"assignee_id": map[string]any{"type": []string{"string", "null"}},
|
|
},
|
|
"required": []string{"id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "delete_card",
|
|
Description: "Envia una tarjeta a la papelera.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "move_card",
|
|
Description: "Mueve una tarjeta a otra columna. Si omites ordered_ids, se anade al final.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
"column_id": map[string]any{"type": "string"},
|
|
"ordered_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
},
|
|
"required": []string{"id", "column_id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "card_history",
|
|
Description: "Devuelve el historial de cambios de una tarjeta.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "find_cards",
|
|
Description: "Busca tarjetas. query (texto en title/description/requester), column_id (filtra por columna), requester (filtra por solicitante).",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]any{"type": "string"},
|
|
"column_id": map[string]any{"type": "string"},
|
|
"requester": map[string]any{"type": "string"},
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
Name: "list_users",
|
|
Description: "Lista usuarios disponibles para asignar tarjetas.",
|
|
InputSchema: rawSchema(map[string]any{"type": "object", "properties": map[string]any{}}),
|
|
},
|
|
{
|
|
Name: "assign_card",
|
|
Description: "Asigna o desasigna una tarjeta. assignee_id null para desasignar.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string"},
|
|
"assignee_id": map[string]any{"type": []string{"string", "null"}},
|
|
},
|
|
"required": []string{"id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "add_comment",
|
|
Description: "Anade un comentario (card_message) a una tarjeta. Requiere card_id, body y autor (author_id o author_username). Devuelve el CardMessage creado.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"card_id": map[string]any{"type": "string"},
|
|
"body": map[string]any{"type": "string"},
|
|
"author_id": map[string]any{"type": "string"},
|
|
"author_username": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"card_id", "body"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "list_comments",
|
|
Description: "Lista los comentarios (card_messages) de una tarjeta en orden cronologico.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"card_id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []string{"card_id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "delete_comment",
|
|
Description: "Borra un comentario propio. Solo el autor original puede borrar (validado en server). " +
|
|
"Requiere autenticacion via MCP HTTP — el actor se infiere del bearer token. " +
|
|
"Output: {ok:true}.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string", "description": "ID del card_message a borrar (no de la card)."},
|
|
},
|
|
"required": []string{"id"},
|
|
}),
|
|
},
|
|
{
|
|
Name: "get_card",
|
|
Description: "Devuelve una tarjeta activa (no archivada) por id o por seq_num. Read-only. " +
|
|
"Pasa exactamente UNO de los dos: id (hash interno) o seq_num (entero visible, ej. la '115' de 'card 00115'). " +
|
|
"Output: Card completa con time_in_column_ms, total_locked_ms, tags, stickers, deadline.",
|
|
InputSchema: rawSchema(map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"id": map[string]any{"type": "string", "description": "ID hash de la tarjeta (16 hex)."},
|
|
"seq_num": map[string]any{"type": "integer", "description": "Numero secuencial visible al usuario."},
|
|
},
|
|
}),
|
|
},
|
|
}
|
|
}
|
|
|
|
func rawSchema(s map[string]any) json.RawMessage {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// writeMCPConfig writes a temporary mcp.json that points to this binary's
|
|
// `mcp` subcommand with the given URL and token. Returns the absolute path of
|
|
// the file created. Caller is responsible for removing it.
|
|
func writeMCPConfig(binPath, backendURL, token string) (string, error) {
|
|
cfg := map[string]any{
|
|
"mcpServers": map[string]any{
|
|
"kanban": map[string]any{
|
|
"command": binPath,
|
|
"args": []string{"mcp"},
|
|
"env": map[string]string{
|
|
"KANBAN_BACKEND_URL": backendURL,
|
|
"KANBAN_INTERNAL_TOKEN": token,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
b, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
f, err := os.CreateTemp("", "kanban-mcp-*.json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := f.Write(b); err != nil {
|
|
f.Close()
|
|
os.Remove(f.Name())
|
|
return "", err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
os.Remove(f.Name())
|
|
return "", err
|
|
}
|
|
return f.Name(), nil
|
|
}
|