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:
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
const internalTokenHeader = "X-Internal-Token"
|
||||
|
||||
// generateInternalToken returns a 32-byte hex token used by the kanban-mcp
|
||||
// subprocess to call back into /api/tool/{name}. Generated fresh per process.
|
||||
func generateInternalToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("rand.Read: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// handleInternalTool exposes executeTool via HTTP for the MCP subprocess.
|
||||
// Auth: shared internal token in X-Internal-Token header. Constant-time compare.
|
||||
func handleInternalTool(db *DB, expectedToken string, logger *ChatLogger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
got := r.Header.Get(internalTokenHeader)
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(expectedToken)) != 1 {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid internal token"})
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
if name == "" {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: "tool name required"})
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if len(body) == 0 {
|
||||
body = []byte("{}")
|
||||
}
|
||||
input := json.RawMessage(body)
|
||||
if err := validateToolName(name); err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "unknown_tool", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
res := executeTool(db, name, input)
|
||||
if logger != nil {
|
||||
logger.Log(name, input, res)
|
||||
}
|
||||
// Always 200 — MCP-side maps res.OK to MCP isError.
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user