package eval import ( "encoding/json" "io" "net/http" "strings" mcpserver "github.com/mark3labs/mcp-go/server" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/server" "go.uber.org/zap" ) // Handler extends server.Handler with eval-specific endpoints. // It inherits every /v1/* route from the base handler and adds // POST /v1/augment for the eval augment pipeline. type Handler struct { *server.Handler } // NewHandler creates an eval HTTP handler that dispatches to MCP tools. // It provides all server /v1/* endpoints plus POST /v1/augment. func NewHandler(mcpServer *mcpserver.MCPServer, g *graph.Graph, version string, logger *zap.Logger) *Handler { base := server.NewHandler(mcpServer, g, version, logger) h := &Handler{Handler: base} base.Mux().HandleFunc("POST /v1/augment", h.handleAugment) return h } // --- /augment (eval-specific) --- type augmentRequest struct { Pattern string `json:"pattern"` } type augmentResponse struct { Pattern string `json:"pattern"` Symbols []augmentSymbol `json:"symbols"` } type augmentSymbol struct { ID string `json:"id"` Name string `json:"name"` File string `json:"file"` Kind string `json:"kind"` Callers []string `json:"callers,omitempty"` Callees []string `json:"callees,omitempty"` CallChain []string `json:"call_chain,omitempty"` } func (h *Handler) handleAugment(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { server.WriteJSONError(w, http.StatusBadRequest, "failed to read request body") return } var req augmentRequest if err := json.Unmarshal(body, &req); err != nil { server.WriteJSONError(w, http.StatusBadRequest, "malformed JSON: "+err.Error()) return } if req.Pattern == "" { server.WriteJSONError(w, http.StatusBadRequest, "missing 'pattern' field") return } ctx := r.Context() searchResults := h.CallTool(ctx, "search_symbols", map[string]any{ "query": req.Pattern, "compact": true, }) symbolIDs := extractSymbolIDs(searchResults) var symbols []augmentSymbol for _, id := range symbolIDs { sym := augmentSymbol{ID: id} if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { sym.File = parts[0] sym.Name = parts[1] } else { sym.Name = id } if node := h.Graph().GetNode(id); node != nil { sym.Kind = string(node.Kind) } usageResults := h.CallTool(ctx, "find_usages", map[string]any{ "id": id, "compact": true, }) sym.Callers = extractLines(usageResults) chainResults := h.CallTool(ctx, "get_call_chain", map[string]any{ "function_id": id, "compact": true, "depth": float64(2), }) sym.CallChain = extractLines(chainResults) symbols = append(symbols, sym) } resp := augmentResponse{ Pattern: req.Pattern, Symbols: symbols, } server.WriteJSON(w, http.StatusOK, resp) } // --- Helpers --- // extractSymbolIDs parses symbol IDs from compact search_symbols output. func extractSymbolIDs(text string) []string { if text == "" { return nil } var ids []string seen := make(map[string]bool) for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Fields(line) if len(parts) >= 3 { name := parts[1] fileLine := parts[2] file := fileLine if idx := strings.LastIndex(fileLine, ":"); idx > 0 { file = fileLine[:idx] } id := file + "::" + name if !seen[id] { seen[id] = true ids = append(ids, id) } } } return ids } // extractLines splits text into non-empty trimmed lines. func extractLines(text string) []string { if text == "" { return nil } var lines []string for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) if line != "" { lines = append(lines, line) } } return lines }