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 }