Files
kanban/backend/mcp.go
T
egutierrez 12729b5166 chore: auto-commit (2 archivos)
- backend/mcp.go
- backend/tools.go

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:35:39 +02:00

328 lines
10 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"},
}),
},
}
}
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
}