Files
zeekay 114b9701db refactor: /v1/ canonical paths (sweep)
Per global rule: /v1/ only, never /api/. Internal API calls + handler
comments + route registrations updated.

- Control plane: agentAPI mount moves from /api/v1 -> /v1; uiAPI from
  /api/ui/v1 -> /v1/ui; uiAPIV2 from /api/ui/v2 -> /v2/ui. Auth middleware
  /api/v1/health -> /v1/health. All UI handler comments updated.
- Go SDK (sdk/go/agent, sdk/go/client): control_plane_memory_backend,
  discovery, agent, note, client now use /v1/* and /v1/ui/* canonically.
- LegacyExecutionEndpoints catalog in pkg/types/execution.go normalized
  to /v1/* and /v1/ui/* (UIWorkflowRunList stays /v2/ui as it's the v2 surface).
- All test fixtures updated to match the new contract.

Build note: pre-existing rot in internal/utils/paths.go and
internal/handlers/ui/env.go (hanzo-agentsHome is not a valid Go identifier;
hyphens not allowed) prevents `go build ./...` from succeeding — this
predates the sweep and is not introduced here.
2026-05-18 22:34:48 -07:00

322 lines
8.8 KiB
Go

package ui
import (
"net/http"
"time"
"github.com/hanzoai/agents/control-plane/internal/core/domain"
"github.com/hanzoai/agents/control-plane/internal/core/interfaces"
"github.com/hanzoai/agents/control-plane/internal/services"
"github.com/gin-gonic/gin"
)
// MCPHandler provides handlers for MCP-related operations
type MCPHandler struct {
uiService *services.UIService
agentClient interfaces.AgentClient
}
// NewMCPHandler creates a new MCPHandler
func NewMCPHandler(uiService *services.UIService, agentClient interfaces.AgentClient) *MCPHandler {
return &MCPHandler{
uiService: uiService,
agentClient: agentClient,
}
}
// GetMCPHealthHandler handles requests for detailed MCP health information
// GET /v1/ui/nodes/{nodeId}/mcp/health?mode=developer|user
func (h *MCPHandler) GetMCPHealthHandler(c *gin.Context) {
ctx := c.Request.Context()
nodeID := c.Param("nodeId")
if nodeID == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "nodeId is required"})
return
}
// Get mode parameter (default to developer)
modeParam := c.DefaultQuery("mode", "developer")
var mode domain.MCPHealthMode
switch modeParam {
case "user":
mode = domain.MCPHealthModeUser
case "developer":
mode = domain.MCPHealthModeDeveloper
default:
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "mode must be 'user' or 'developer'"})
return
}
// Get detailed node information with MCP data
nodeDetails, err := h.uiService.GetNodeDetailsWithMCP(ctx, nodeID, mode)
if err != nil {
c.JSON(http.StatusNotFound, ErrorResponse{Error: "node not found or failed to retrieve MCP health"})
return
}
// Return the appropriate response based on mode
if mode == domain.MCPHealthModeUser {
// User mode: return only summary
response := map[string]interface{}{
"node_id": nodeID,
"mcp_summary": nodeDetails.MCPSummary,
"timestamp": time.Now(),
}
c.JSON(http.StatusOK, response)
} else {
// Developer mode: return full details
response := map[string]interface{}{
"node_id": nodeID,
"mcp_summary": nodeDetails.MCPSummary,
"mcp_servers": nodeDetails.MCPServers,
"timestamp": time.Now(),
}
c.JSON(http.StatusOK, response)
}
}
// RestartMCPServerHandler handles requests to restart a specific MCP server
// POST /v1/ui/nodes/{nodeId}/mcp/servers/{alias}/restart
func (h *MCPHandler) RestartMCPServerHandler(c *gin.Context) {
nodeID := c.Param("nodeId")
alias := c.Param("alias")
if nodeID == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "nodeId is required"})
return
}
if alias == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "alias is required"})
return
}
// Check mode - only allow in developer mode
mode := c.DefaultQuery("mode", "developer")
if mode != "developer" {
c.JSON(http.StatusForbidden, ErrorResponse{Error: "MCP server restart is only available in developer mode"})
return
}
// Create context with timeout
ctx := c.Request.Context()
// Call agent client to restart the MCP server
err := h.agentClient.RestartMCPServer(ctx, nodeID, alias)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to restart MCP server: " + err.Error()})
return
}
// Return success response
response := map[string]interface{}{
"success": true,
"message": "MCP server restart initiated successfully",
"node_id": nodeID,
"alias": alias,
"timestamp": time.Now(),
}
c.JSON(http.StatusOK, response)
}
// GetMCPToolsHandler handles requests to get tools from a specific MCP server
// GET /v1/ui/nodes/{nodeId}/mcp/servers/{alias}/tools
func (h *MCPHandler) GetMCPToolsHandler(c *gin.Context) {
nodeID := c.Param("nodeId")
alias := c.Param("alias")
if nodeID == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "nodeId is required"})
return
}
if alias == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "alias is required"})
return
}
// Check mode - only allow in developer mode
mode := c.DefaultQuery("mode", "developer")
if mode != "developer" {
c.JSON(http.StatusForbidden, ErrorResponse{Error: "MCP tools listing is only available in developer mode"})
return
}
// Create context with timeout
ctx := c.Request.Context()
// Call agent client to get MCP tools
toolsResponse, err := h.agentClient.GetMCPTools(ctx, nodeID, alias)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to get MCP tools: " + err.Error()})
return
}
// Return tools response
response := map[string]interface{}{
"node_id": nodeID,
"alias": alias,
"tools": toolsResponse.Tools,
"count": len(toolsResponse.Tools),
"timestamp": time.Now(),
}
c.JSON(http.StatusOK, response)
}
// GetMCPStatusHandler handles requests for overall MCP status across all nodes
// GET /v1/ui/mcp/status
func (h *MCPHandler) GetMCPStatusHandler(c *gin.Context) {
ctx := c.Request.Context()
// Get mode parameter
modeParam := c.DefaultQuery("mode", "user")
var mode domain.MCPHealthMode
switch modeParam {
case "user":
mode = domain.MCPHealthModeUser
case "developer":
mode = domain.MCPHealthModeDeveloper
default:
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "mode must be 'user' or 'developer'"})
return
}
// Get all node summaries (which now include MCP data)
summaries, _, err := h.uiService.GetNodesSummary(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to get nodes summary"})
return
}
// Aggregate MCP status across all nodes
totalNodes := 0
nodesWithMCP := 0
totalMCPServers := 0
runningMCPServers := 0
totalTools := 0
var overallHealth float64 = 1.0
for _, summary := range summaries {
totalNodes++
if summary.MCPSummary != nil {
nodesWithMCP++
totalMCPServers += summary.MCPSummary.TotalServers
runningMCPServers += summary.MCPSummary.RunningServers
totalTools += summary.MCPSummary.TotalTools
// Calculate weighted average health
if summary.MCPSummary.TotalServers > 0 {
overallHealth = (overallHealth + summary.MCPSummary.OverallHealth) / 2
}
}
}
// Build response
response := map[string]interface{}{
"total_nodes": totalNodes,
"nodes_with_mcp": nodesWithMCP,
"total_mcp_servers": totalMCPServers,
"running_mcp_servers": runningMCPServers,
"total_tools": totalTools,
"overall_health": overallHealth,
"timestamp": time.Now(),
"mode": mode,
}
// Add detailed node data for developer mode
if mode == domain.MCPHealthModeDeveloper {
nodeDetails := make([]map[string]interface{}, 0)
for _, summary := range summaries {
nodeDetail := map[string]interface{}{
"node_id": summary.ID,
"team_id": summary.TeamID,
"version": summary.Version,
"health": summary.HealthStatus,
"mcp_summary": summary.MCPSummary,
}
nodeDetails = append(nodeDetails, nodeDetail)
}
response["nodes"] = nodeDetails
}
c.JSON(http.StatusOK, response)
}
// GetMCPEventsHandler handles requests for MCP events (SSE endpoint)
// GET /v1/ui/nodes/{nodeId}/mcp/events
func (h *MCPHandler) GetMCPEventsHandler(c *gin.Context) {
nodeID := c.Param("nodeId")
if nodeID == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "nodeId is required"})
return
}
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Headers", "Cache-Control")
// Get the response writer
w := c.Writer
// Send initial connection event
initialEvent := map[string]interface{}{
"type": "connection",
"node_id": nodeID,
"timestamp": time.Now().Format(time.RFC3339),
"message": "Connected to MCP events stream",
}
// Write SSE formatted data
c.SSEvent("mcp-event", initialEvent)
w.Flush()
// Keep connection alive with periodic heartbeat
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Create a channel to handle client disconnect
clientGone := c.Request.Context().Done()
for {
select {
case <-clientGone:
// Client disconnected
return
case <-ticker.C:
// Send heartbeat
heartbeat := map[string]interface{}{
"type": "heartbeat",
"node_id": nodeID,
"timestamp": time.Now().Format(time.RFC3339),
}
c.SSEvent("heartbeat", heartbeat)
w.Flush()
}
}
}
// GetMCPMetricsHandler handles requests for MCP metrics
// GET /v1/ui/nodes/{nodeId}/mcp/metrics
func (h *MCPHandler) GetMCPMetricsHandler(c *gin.Context) {
nodeID := c.Param("nodeId")
if nodeID == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "nodeId is required"})
return
}
// For now, return empty metrics as this endpoint is not fully implemented
// TODO: Implement real MCP metrics collection
response := map[string]interface{}{
"metrics": map[string]interface{}{},
"node_id": nodeID,
"timestamp": time.Now(),
}
c.JSON(http.StatusOK, response)
}