feat(chat): MCP server + WebSocket streaming, replace XML actions

- Backend: kanban binary gana subcomando `kanban mcp` que actua como MCP
  server via stdio. Tools = mismo set que executeTool (14). El subprocess
  llama de vuelta al backend via /api/tool/{name} con token interno.
- Backend: nuevo endpoint POST /api/tool/{name} (auth: X-Internal-Token).
- Backend: chat.go refactor — POST /api/chat reemplazado por GET
  /api/chat/ws (WebSocket). Lanza claude -p con --output-format stream-json
  --verbose --mcp-config y reenvia eventos (delta/tool_use/tool_result/
  result/done/error) como mensajes JSON al cliente.
- Backend: usa funciones nuevas del registry claude_stream_go_core (spawn
  + parser NDJSON) y mcp_server_stdio_go_infra (JSON-RPC stdio).
- Frontend: streamChat sobre WebSocket. ChatPanel renderiza deltas en
  vivo, chips para tool_use, badges teal/red para tool_result.
- Borrado: extractActions, actionsBlockMarker, XML system prompt.
- Tests: 7 nuevos en backend (chat_ws_test.go + endpoint /api/tool).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 14:54:38 +02:00
parent 9e333b0e3e
commit ce49fdf9ff
14 changed files with 2175 additions and 1493 deletions
+184 -271
View File
@@ -1,64 +1,40 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/core"
"fn-registry/functions/infra"
"nhooyr.io/websocket"
)
const chatSystemPrompt = `Eres el asistente del tablero kanban. Tu trabajo es responder al usuario y, cuando pida cambios, modificar el tablero llamando a tools.
const chatSystemPrompt = `Eres el asistente del tablero kanban. Responde al usuario y, cuando pida cambios, modifica el tablero llamando a tools nativas (MCP).
Cuando necesites modificar el tablero, responde EXCLUSIVAMENTE con un bloque <actions>...</actions> que contenga JSON valido (un array de acciones). Sin texto antes ni despues.
Tools disponibles via MCP server "kanban":
- list_board / find_cards / card_history / list_users — lectura
- create_column / update_column / delete_column / reorder_columns — columnas
- create_card / update_card / delete_card / move_card / assign_card — tarjetas
Ejemplo:
<actions>
[
{"tool": "create_card", "input": {"column_id": "abc123", "requester": "Lucas", "title": "Revisar PR", "description": ""}},
{"tool": "rename_column", "input": {"id": "def456", "name": "En curso"}}
]
</actions>
Llama directamente a las tools cuando necesites mutar el tablero. Usa list_board al principio si necesitas resolver nombres a IDs. NUNCA inventes IDs.
Tools disponibles (todas con sus inputs):
- list_board {} -> {columns, cards}
- create_column {name}
- update_column {id, name?, location?, width?, wip_limit?, is_done?} // location: "board" | "sidebar". width: 200..800 px. wip_limit: max tarjetas (0 = sin limite). is_done: marca columna como terminal (cards dentro se cuentan como completadas para metricas y se muestran tachadas).
- delete_column {id}
- reorder_columns {ids:[...]}
- create_card {column_id, requester?, title, description?}
- update_card {id, requester?, title?, description?, color?, locked?, assignee_id?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default). locked: true bloquea la tarjeta (no se puede mover entre columnas hasta desbloquear). assignee_id: ID del usuario asignado o null para desasignar.
- delete_card {id}
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
- card_history {id}
- find_cards {query?, column_id?, requester?}
- list_users {} -> [{id, username, display_name}]
- assign_card {id, assignee_id} // alias rapido de update_card. assignee_id puede ser null para desasignar.
Cuando termines, responde texto natural en markdown (sin llamadas extra) — eso señala el fin de la conversacion.`
Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
Para resolver IDs a partir de nombres, mira el board_state que viene al final del prompt del usuario. NO inventes IDs.
LOOP ITERATIVO: Despues de aplicar tus acciones, el sistema te volvera a llamar con:
- Los resultados de las tool calls anteriores (incluyendo IDs reales de columnas/tarjetas creadas).
- El board_state actualizado.
- Tu mensaje de usuario original.
Cuando recibas resultados de iteraciones anteriores, USA LOS IDs REALES devueltos en lugar de inventar placeholders. Continua emitiendo mas <actions> hasta completar la tarea.
Cuando hayas terminado COMPLETAMENTE la tarea, responde texto natural (markdown) SIN bloque <actions> — eso señala el fin del loop.`
const claudeBin = "claude"
const claudeModel = "claude-sonnet-4-6"
const claudeTimeout = 120 * time.Second
const maxChatIterations = 8
const claudeTimeout = 300 * time.Second
func claudeBinary() string {
if b := os.Getenv("KANBAN_CLAUDE_BIN"); b != "" {
return b
}
return "claude"
}
type chatMessage struct {
Role string `json:"role"`
@@ -69,83 +45,163 @@ type chatRequest struct {
Messages []chatMessage `json:"messages"`
}
type chatResponse struct {
Role string `json:"role"`
Content string `json:"content"`
BoardChanged bool `json:"board_changed"`
ToolCalls []toolCallInfo `json:"tool_calls,omitempty"`
// wsEvent is the envelope sent to the browser. Type discriminates the payload.
type wsEvent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Tool string `json:"tool,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Result string `json:"result,omitempty"`
IsError bool `json:"is_error,omitempty"`
BoardChanged bool `json:"board_changed,omitempty"`
Error string `json:"error,omitempty"`
}
type toolCallInfo struct {
Tool string `json:"tool"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Iteration int `json:"iteration,omitempty"`
// Result is included only for the loop's internal feedback to claude;
// it is omitted from the JSON response sent to the frontend (clients
// can use board_changed + reload to fetch fresh state).
Result any `json:"-"`
}
type claudeJSONResult struct {
Type string `json:"type"`
IsError bool `json:"is_error"`
Result string `json:"result"`
StopReason string `json:"stop_reason"`
}
// runClaude invokes the `claude` CLI in print mode with the given system prompt
// and user message. The board JSON is appended to the user message under a
// `board_state` marker so the assistant can resolve names to IDs.
// handleChatWS upgrades the request to WebSocket and streams claude events.
//
// stdin: the user-facing prompt (history flattened).
// returns: assistant's text reply.
func runClaude(ctx context.Context, systemPrompt, userInput, boardJSON, workdir string) (string, error) {
if _, err := exec.LookPath(claudeBin); err != nil {
return "", errors.New("claude CLI not found in PATH")
// Wire protocol:
// client → server (one message): { "messages": [{role, content}, ...] }
// server → client (many): wsEvent ndjson-style messages
// types: "delta" (assistant text), "tool_use", "tool_result", "result", "error"
// server closes connection at end.
func handleChatWS(db *DB, workdir string, logger *ChatLogger, internalToken string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := infra.WSUpgrader(w, r, []string{"*"})
if err != nil {
return
}
defer conn.Close(websocket.StatusInternalError, "internal")
ctx, cancel := context.WithTimeout(r.Context(), claudeTimeout)
defer cancel()
// Read the initial chat request.
_, raw, err := conn.Read(ctx)
if err != nil {
return
}
var req chatRequest
if err := json.Unmarshal(raw, &req); err != nil {
sendWS(ctx, conn, wsEvent{Type: "error", Error: "invalid chat request: " + err.Error()})
return
}
if len(req.Messages) == 0 {
sendWS(ctx, conn, wsEvent{Type: "error", Error: "messages required"})
return
}
boardChanged, err := streamChat(ctx, conn, db, workdir, internalToken, req.Messages, logger)
if err != nil {
sendWS(ctx, conn, wsEvent{Type: "error", Error: err.Error()})
return
}
sendWS(ctx, conn, wsEvent{Type: "done", BoardChanged: boardChanged})
conn.Close(websocket.StatusNormalClosure, "")
}
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, claudeBin,
"-p",
"--model", claudeModel,
"--output-format", "json",
"--no-session-persistence",
"--tools", "",
"--system-prompt", systemPrompt,
)
cmd.Dir = workdir
prompt := userInput
if boardJSON != "" {
prompt += "\n\n<board_state>\n" + boardJSON + "\n</board_state>\n"
}
cmd.Stdin = bytes.NewBufferString(prompt)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("claude exec: %w (stderr: %s)", err, stderr.String())
}
var res claudeJSONResult
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
return "", fmt.Errorf("parse claude json: %w (raw: %s)", err, stdout.String())
}
if res.IsError {
return "", fmt.Errorf("claude error: %s", res.Result)
}
return res.Result, nil
}
// flattenMessages converts a chat history into a single text prompt for `claude -p`.
// Format: lines of `Usuario: ...` / `Asistente: ...`. Last user message ends the prompt.
func streamChat(ctx context.Context, conn *websocket.Conn, db *DB, workdir, token string, msgs []chatMessage, logger *ChatLogger) (bool, error) {
binPath, err := os.Executable()
if err != nil {
return false, fmt.Errorf("locate kanban binary: %w", err)
}
// Backend URL: trust X-Forwarded or fall back to localhost (kanban listens
// on its main port). The MCP subprocess hits the loopback interface.
backendURL := os.Getenv("KANBAN_PUBLIC_URL")
if backendURL == "" {
port := os.Getenv("KANBAN_LISTEN_PORT")
if port == "" {
port = "8095"
}
backendURL = "http://127.0.0.1:" + port
}
mcpPath, err := writeMCPConfig(binPath, backendURL, token)
if err != nil {
return false, fmt.Errorf("write mcp config: %w", err)
}
defer os.Remove(mcpPath)
prompt := flattenMessages(msgs)
stdin := strings.NewReader(prompt)
events, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{
Bin: claudeBinary(),
Args: []string{
"--model", claudeModel,
"--mcp-config", mcpPath,
"--system-prompt", chatSystemPrompt,
"--allowedTools",
"mcp__kanban__list_board,mcp__kanban__create_column,mcp__kanban__update_column,mcp__kanban__rename_column,mcp__kanban__delete_column,mcp__kanban__reorder_columns,mcp__kanban__create_card,mcp__kanban__update_card,mcp__kanban__delete_card,mcp__kanban__move_card,mcp__kanban__card_history,mcp__kanban__find_cards,mcp__kanban__list_users,mcp__kanban__assign_card",
},
Stdin: stdin,
Workdir: workdir,
})
if err != nil {
return false, fmt.Errorf("spawn claude: %w", err)
}
boardChanged := false
for ev := range events {
switch ev.Type {
case core.ClaudeEventTextDelta:
sendWS(ctx, conn, wsEvent{Type: "delta", Text: ev.Text})
case core.ClaudeEventToolUse:
toolName := stripMCPPrefix(ev.ToolName)
sendWS(ctx, conn, wsEvent{
Type: "tool_use",
ToolID: ev.ToolUseID,
Tool: toolName,
Input: ev.ToolInput,
})
if toolMutates(toolName) {
boardChanged = true
}
case core.ClaudeEventToolResult:
sendWS(ctx, conn, wsEvent{
Type: "tool_result",
ToolID: ev.ToolResultID,
Result: ev.ToolResultContent,
IsError: ev.ToolResultIsError,
})
case core.ClaudeEventResult:
sendWS(ctx, conn, wsEvent{
Type: "result",
Text: ev.Result,
IsError: ev.IsError,
})
case core.ClaudeEventError:
sendWS(ctx, conn, wsEvent{Type: "error", Error: ev.Error})
}
}
return boardChanged, nil
}
// stripMCPPrefix removes the "mcp__<server>__" prefix added by claude when
// tools come from an MCP server, leaving the bare tool name.
func stripMCPPrefix(name string) string {
const pre = "mcp__kanban__"
if strings.HasPrefix(name, pre) {
return name[len(pre):]
}
return name
}
func sendWS(ctx context.Context, conn *websocket.Conn, ev wsEvent) {
b, err := json.Marshal(ev)
if err != nil {
return
}
wctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_ = conn.Write(wctx, websocket.MessageText, b)
}
// flattenMessages converts chat history into a single prompt for `claude -p`.
func flattenMessages(msgs []chatMessage) string {
var b bytes.Buffer
var b strings.Builder
for _, m := range msgs {
role := "Usuario"
if m.Role == "assistant" {
@@ -159,165 +215,7 @@ func flattenMessages(msgs []chatMessage) string {
return b.String()
}
func handleChat(db *DB, workdir string, logger *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req chatRequest
if err := infra.HTTPParseBody(r, &req, 1<<20); err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
return
}
if len(req.Messages) == 0 {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "messages required"})
return
}
baseUserInput := flattenMessages(req.Messages)
allCalls := []toolCallInfo{}
var finalText string
boardChanged := false
for iter := 1; iter <= maxChatIterations; iter++ {
boardJSON, err := boardSnapshot(db)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "internal", Message: err.Error()})
return
}
prompt := buildIterationPrompt(baseUserInput, allCalls, iter)
assistantText, err := runClaude(r.Context(), chatSystemPrompt, prompt, boardJSON, workdir)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "claude_error", Message: err.Error()})
return
}
actionsJSON, stripped, found := extractActions(assistantText)
if !found {
finalText = assistantText
break
}
calls, changed := applyActions(db, actionsJSON, logger)
for i := range calls {
calls[i].Iteration = iter
}
allCalls = append(allCalls, calls...)
if changed {
boardChanged = true
}
finalText = stripped // tentative; overwritten if next iter responds free text
if iter == maxChatIterations {
finalText = strings.TrimSpace(stripped + "\n\n_Limite de iteraciones alcanzado._")
break
}
}
// Strip Result fields before serializing (not exported but defensive).
respCalls := make([]toolCallInfo, len(allCalls))
for i, c := range allCalls {
respCalls[i] = toolCallInfo{Tool: c.Tool, OK: c.OK, Error: c.Error, Iteration: c.Iteration}
}
resp := chatResponse{
Role: "assistant",
Content: finalText,
ToolCalls: respCalls,
BoardChanged: boardChanged,
}
if resp.Content == "" {
resp.Content = summarizeCalls(respCalls)
}
infra.HTTPJSONResponse(w, http.StatusOK, resp)
}
}
// buildIterationPrompt composes the user prompt for iteration N.
// Iteration 1 = original user input; later iterations also include a summary
// of previous tool calls so the assistant can use real IDs.
func buildIterationPrompt(baseUserInput string, prevCalls []toolCallInfo, iter int) string {
if iter == 1 || len(prevCalls) == 0 {
return baseUserInput
}
var b bytes.Buffer
b.WriteString(baseUserInput)
b.WriteString("\n[Resultados de iteraciones anteriores]\n")
for _, c := range prevCalls {
if c.OK {
summary := summarizeResult(c.Result)
fmt.Fprintf(&b, "- iter %d %s: ok %s\n", c.Iteration, c.Tool, summary)
} else {
fmt.Fprintf(&b, "- iter %d %s: ERROR %s\n", c.Iteration, c.Tool, c.Error)
}
}
fmt.Fprintf(&b, "\n[Iteracion %d] Continua con las acciones pendientes. Si terminaste, responde texto natural sin <actions>.\n", iter)
return b.String()
}
func boardSnapshot(db *DB) (string, error) {
cols, err := db.ListColumns()
if err != nil {
return "", err
}
cards, err := db.ListCardsWithTime()
if err != nil {
return "", err
}
b, err := json.MarshalIndent(map[string]any{"columns": cols, "cards": cards}, "", " ")
if err != nil {
return "", err
}
return string(b), nil
}
func applyActions(db *DB, actionsJSON string, logger *ChatLogger) ([]toolCallInfo, bool) {
var actions []struct {
Tool string `json:"tool"`
Input json.RawMessage `json:"input"`
}
if err := json.Unmarshal([]byte(actionsJSON), &actions); err != nil {
return []toolCallInfo{{Tool: "<parse>", OK: false, Error: err.Error()}}, false
}
results := make([]toolCallInfo, 0, len(actions))
changed := false
for _, a := range actions {
if err := validateToolName(a.Tool); err != nil {
info := toolCallInfo{Tool: a.Tool, OK: false, Error: err.Error()}
results = append(results, info)
logger.Log(a.Tool, a.Input, ToolResult{OK: false, Error: err.Error()})
continue
}
res := executeTool(db, a.Tool, a.Input)
logger.Log(a.Tool, a.Input, res)
info := toolCallInfo{Tool: a.Tool, OK: res.OK, Result: res.Result}
if !res.OK {
info.Error = res.Error
} else if toolMutates(a.Tool) {
changed = true
}
results = append(results, info)
}
return results, changed
}
func summarizeCalls(calls []toolCallInfo) string {
if len(calls) == 0 {
return ""
}
var b bytes.Buffer
b.WriteString("Acciones aplicadas:\n")
for _, c := range calls {
if c.OK {
fmt.Fprintf(&b, "- %s: ok\n", c.Tool)
} else {
fmt.Fprintf(&b, "- %s: error (%s)\n", c.Tool, c.Error)
}
}
return b.String()
}
// chatWorkdir resolves an absolute working directory for `claude -p` (avoids
// inheriting CLAUDE.md from parent directories with unrelated context).
// chatWorkdir resolves an absolute working directory for `claude -p`.
func chatWorkdir(dbPath string) string {
abs, err := filepath.Abs(dbPath)
if err != nil {
@@ -325,3 +223,18 @@ func chatWorkdir(dbPath string) string {
}
return filepath.Dir(abs)
}
// --- Legacy handleChat retained as a thin shim that returns 410 Gone. -------
// Kept so existing clients see a clear error instead of a 404 while they
// migrate to the WebSocket endpoint.
func handleChat(_ *DB, _ string, _ *ChatLogger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusGone,
Code: "deprecated",
Message: "POST /api/chat removed; use WebSocket at /api/chat/ws",
})
}
}