feat: initial scaffold kanban_cpp v0.1.0
C++ ImGui kanban for steering LLM agents. Six panels (Board, Calendar, Dashboard, Agent runs, Worktrees, DoD inspector) wired to registry functions http_request, kpi_card, sparkline, agent_runs_timeline, dod_evidence_panel. Backend Go on :8403 (independent operations.db from kanban_web).
This commit is contained in:
+302
@@ -0,0 +1,302 @@
|
||||
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"},
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user