a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
807 lines
27 KiB
Go
807 lines
27 KiB
Go
// Package server exposes Gortex MCP tools over HTTP/JSON.
|
|
// It provides the general-purpose HTTP handler used by both the standalone
|
|
// server command and the eval server.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
mcpserver "github.com/mark3labs/mcp-go/server"
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
gortexmcp "github.com/zzet/gortex/internal/mcp"
|
|
"github.com/zzet/gortex/internal/mcp/streamable"
|
|
"github.com/zzet/gortex/internal/server/hub"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Handler wraps an MCP server's tool dispatch as an HTTP handler.
|
|
// All routes live under /v1/*:
|
|
//
|
|
// GET /v1/health status + node/edge counts + uptime
|
|
// GET /v1/tools list of available MCP tools
|
|
// POST /v1/tools/{name} invoke a tool with JSON arguments
|
|
// GET /v1/stats graph stats by kind/language
|
|
// GET /v1/graph full brief-graph dump (nodes+edges+stats)
|
|
// GET /v1/events SSE stream of graph-change events
|
|
// GET /v1/activity ring buffer of recent graph-change events
|
|
// GET /v1/caveats aggregated hotspots/dead-code/cycles/guards
|
|
// GET /v1/dashboard bundled snapshot for the dashboard hero
|
|
// GET /v1/repos per-repository node/edge/kind breakdown
|
|
// GET /v1/processes discovered execution flows
|
|
// GET /v1/contracts detected API/event/URL contracts
|
|
// GET /v1/communities community detection result
|
|
// GET /v1/guards guard rule evaluation status
|
|
//
|
|
// /v1/graph scoping (?project/?repo) and /v1/events streaming require
|
|
// a ConfigManager and an event hub respectively, wired via
|
|
// SetConfigManager / SetEventHub after construction.
|
|
type Handler struct {
|
|
mcpServer *mcpserver.MCPServer
|
|
graph graph.Store
|
|
version string
|
|
logger *zap.Logger
|
|
mux *http.ServeMux
|
|
startTime time.Time
|
|
eventHub *hub.Hub // nil when watch mode is off
|
|
configManager *config.ConfigManager // nil in single-repo mode
|
|
serverID string // UUID; empty until SetServerID wires it
|
|
activity *activityBuffer // ring buffer of recent graph events
|
|
overlays *daemon.OverlayManager // nil when overlay support is off
|
|
router *daemon.Router // nil when single-server (no servers.toml)
|
|
decision *daemon.ProxyDecision // shared peek→route→outcome helper; nil until SetRouter
|
|
streamable *streamable.Transport // nil when the MCP 2026 Streamable HTTP path is off
|
|
readOnly bool // self-advertised /v1/health write posture
|
|
capabilities []string // self-advertised federation caps; nil => baseline
|
|
|
|
// Conversation-log inspection. convDir enables the /v1/conversations*
|
|
// routes (empty => the sink is off and the routes report no sessions).
|
|
// convAllow extends the loopback allowlist the route-scoped
|
|
// DNS-rebind guard honors; convTokenFn supplies the configured auth
|
|
// token so the guard can let a valid token-authed non-loopback
|
|
// request pass (cooperating with --http-auth-token, not duplicating it).
|
|
convDir string
|
|
convAllow []string
|
|
convTokenFn func() string
|
|
}
|
|
|
|
// NewHandler creates an HTTP handler that dispatches to MCP tools.
|
|
func NewHandler(mcpServer *mcpserver.MCPServer, g graph.Store, version string, logger *zap.Logger) *Handler {
|
|
h := &Handler{
|
|
mcpServer: mcpServer,
|
|
graph: g,
|
|
version: version,
|
|
logger: logger,
|
|
mux: http.NewServeMux(),
|
|
startTime: time.Now(),
|
|
activity: newActivityBuffer(100),
|
|
}
|
|
h.registerRoutes()
|
|
return h
|
|
}
|
|
|
|
// Mux returns the underlying ServeMux so sub-handlers can register
|
|
// additional routes (e.g. eval-specific /augment endpoint).
|
|
func (h *Handler) Mux() *http.ServeMux { return h.mux }
|
|
|
|
// Graph returns the graph instance for sub-handlers that need direct access.
|
|
func (h *Handler) Graph() graph.Store { return h.graph }
|
|
|
|
// SetEventHub wires the watch-mode event hub so /v1/events can stream
|
|
// graph-change events to subscribers, and starts the activity-buffer
|
|
// collector so /v1/activity can backfill the dashboard feed. When nil,
|
|
// /v1/events responds with a single keepalive frame and closes.
|
|
func (h *Handler) SetEventHub(h2 *hub.Hub) {
|
|
h.eventHub = h2
|
|
h.startActivityCollector(h2)
|
|
}
|
|
|
|
// SetConfigManager wires the multi-repo config so /v1/graph can scope
|
|
// its dump by ?project=<name>. Without it, only ?repo=<name> filtering
|
|
// is available.
|
|
func (h *Handler) SetConfigManager(cm *config.ConfigManager) { h.configManager = cm }
|
|
|
|
// SetServerID attaches a stable UUID to /v1/stats responses so daemon
|
|
// clients can detect server restarts (and therefore index-restart
|
|
// races) by watching for id changes.
|
|
func (h *Handler) SetServerID(id string) { h.serverID = id }
|
|
|
|
// ServeHTTP implements http.Handler with panic recovery middleware.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
stack := debug.Stack()
|
|
h.logger.Error("panic recovered in HTTP handler",
|
|
zap.Any("panic", rec),
|
|
zap.String("stack", string(stack)),
|
|
zap.String("method", r.Method),
|
|
zap.String("path", r.URL.Path),
|
|
)
|
|
WriteJSONError(w, http.StatusInternalServerError, "internal server error")
|
|
}
|
|
}()
|
|
h.mux.ServeHTTP(w, r)
|
|
}
|
|
|
|
func (h *Handler) registerRoutes() {
|
|
h.mux.HandleFunc("GET /v1/health", h.handleHealth)
|
|
h.mux.HandleFunc("GET /v1/tools", h.handleListTools)
|
|
h.mux.HandleFunc("POST /v1/tools/", h.handleToolCall)
|
|
h.mux.HandleFunc("GET /v1/stats", h.handleStats)
|
|
h.mux.HandleFunc("GET /v1/graph", h.handleGetGraph)
|
|
h.mux.HandleFunc("GET /v1/subgraph", h.handleSubGraph)
|
|
h.mux.HandleFunc("GET /v1/events", h.handleEvents)
|
|
h.mux.HandleFunc("GET /v1/activity", h.handleActivity)
|
|
h.mux.HandleFunc("GET /v1/caveats", h.handleCaveats)
|
|
h.mux.HandleFunc("GET /v1/dashboard", h.handleDashboard)
|
|
h.mux.HandleFunc("GET /v1/repos", h.handleRepos)
|
|
h.mux.HandleFunc("GET /v1/processes", h.handleProcesses)
|
|
h.mux.HandleFunc("GET /v1/contracts", h.handleContracts)
|
|
h.mux.HandleFunc("GET /v1/contracts/validate", h.handleContractsValidate)
|
|
h.mux.HandleFunc("GET /v1/communities", h.handleCommunities)
|
|
h.mux.HandleFunc("GET /v1/guards", h.handleGuards)
|
|
// Agent-conversation session inspection. These routes egress raw LLM
|
|
// I/O, so each one applies the route-scoped DNS-rebind guard
|
|
// (guardConversationRoute) internally — the guard is NOT in
|
|
// ServeHTTP, so other routes are unaffected.
|
|
h.mux.HandleFunc("GET /v1/conversations", h.handleConversations)
|
|
h.mux.HandleFunc("GET /v1/conversations/ui", h.handleConversationsUI)
|
|
h.mux.HandleFunc("GET /v1/conversations/{session}", h.handleConversationSession)
|
|
// Workspace roster discovery. The daemon side calls this when it
|
|
// doesn't yet know which server owns a given workspace; the
|
|
// response lets the daemon's lookup path skip a roundtrip on every
|
|
// subsequent query against the workspace.
|
|
h.mux.HandleFunc("GET /v1/workspaces/{ws}/repos", h.handleWorkspaceRoster)
|
|
// Editor overlay sessions. Clients register a session, push file
|
|
// overlays for in-flight edits, and the server merges them on top
|
|
// of the indexed graph for the duration of the session. The actual
|
|
// merge is the daemon's responsibility (router); these endpoints
|
|
// just expose the OverlayManager to MCP clients.
|
|
h.mux.HandleFunc("POST /v1/overlay/sessions", h.handleOverlayRegister)
|
|
h.mux.HandleFunc("DELETE /v1/overlay/sessions/{id}", h.handleOverlayDrop)
|
|
h.mux.HandleFunc("PUT /v1/overlay/sessions/{id}/files", h.handleOverlayPush)
|
|
h.mux.HandleFunc("DELETE /v1/overlay/sessions/{id}/files", h.handleOverlayDelete)
|
|
h.mux.HandleFunc("GET /v1/overlay/sessions/{id}/files", h.handleOverlayList)
|
|
}
|
|
|
|
// SetOverlayManager wires an OverlayManager into the handler so the
|
|
// /v1/overlay/* endpoints become live. Called by the server / daemon
|
|
// during construction; nil disables those endpoints (they return 503).
|
|
func (h *Handler) SetOverlayManager(m *daemon.OverlayManager) { h.overlays = m }
|
|
|
|
// SetRouter wires the hybrid-read query router. When set,
|
|
// /v1/tools/<name> calls flow through the router;
|
|
// remote workspaces proxy via daemon.ServerClient.ProxyTool, local
|
|
// ones fall through to the in-process MCP tool dispatch. Nil
|
|
// disables routing (the legacy single-server behaviour).
|
|
func (h *Handler) SetRouter(r *daemon.Router) {
|
|
h.router = r
|
|
h.decision = daemon.NewProxyDecision(func() *daemon.Router { return h.router })
|
|
}
|
|
|
|
// Router returns the currently-wired router (or nil). Exposed so
|
|
// composite wire-ups can share a single router instance across the
|
|
// /v1/tools/* surface and the new /mcp Streamable HTTP transport.
|
|
func (h *Handler) Router() *daemon.Router { return h.router }
|
|
|
|
// SetStreamableTransport wires the MCP 2026 Streamable HTTP transport
|
|
// onto /mcp (POST/GET/DELETE). The same Handler still serves the
|
|
// legacy /v1/tools/<name> shape so existing clients keep working;
|
|
// new clients negotiate the stateless transport on the canonical
|
|
// endpoint. Passing nil hides the route (returns 404).
|
|
func (h *Handler) SetStreamableTransport(t *streamable.Transport) {
|
|
h.streamable = t
|
|
if t == nil {
|
|
return
|
|
}
|
|
h.mux.Handle("POST /mcp", t)
|
|
h.mux.Handle("GET /mcp", t)
|
|
h.mux.Handle("DELETE /mcp", t)
|
|
h.mux.Handle("OPTIONS /mcp", t)
|
|
}
|
|
|
|
// StreamableTransport returns the wired transport, or nil. Exposed so
|
|
// callers can register diagnostics push notifications onto the SSE
|
|
// stream via Transport.Push.
|
|
func (h *Handler) StreamableTransport() *streamable.Transport { return h.streamable }
|
|
|
|
// peekRouteContext sniffs the `workspace` / `cwd` arg overrides out
|
|
// of an MCP tool-call body without disturbing it. The body is left
|
|
// available for the local executor to re-parse; we only read enough
|
|
// to make a routing decision. Both nested-args (`{"arguments":
|
|
// {"workspace": "..."}}`) and flat-args (`{"workspace": "..."}`)
|
|
// shapes are handled — the local handler tolerates both, so the
|
|
// router does too.
|
|
func (h *Handler) peekRouteContext(body []byte, r *http.Request) (scope, cwd string) {
|
|
if len(body) > 0 {
|
|
var nested struct {
|
|
Arguments struct {
|
|
Workspace string `json:"workspace"`
|
|
Cwd string `json:"cwd"`
|
|
} `json:"arguments"`
|
|
Workspace string `json:"workspace"`
|
|
Cwd string `json:"cwd"`
|
|
}
|
|
if err := json.Unmarshal(body, &nested); err == nil {
|
|
if nested.Arguments.Workspace != "" {
|
|
scope = nested.Arguments.Workspace
|
|
} else if nested.Workspace != "" {
|
|
scope = nested.Workspace
|
|
}
|
|
if nested.Arguments.Cwd != "" {
|
|
cwd = nested.Arguments.Cwd
|
|
} else if nested.Cwd != "" {
|
|
cwd = nested.Cwd
|
|
}
|
|
}
|
|
}
|
|
if cwd == "" {
|
|
// HTTP clients without an explicit cwd in the body can pass
|
|
// it via header — matches the daemon's session-cwd plumbing.
|
|
cwd = r.Header.Get("X-Gortex-Cwd")
|
|
}
|
|
return scope, cwd
|
|
}
|
|
|
|
// --- /health ---
|
|
|
|
const (
|
|
// APIVersion is the major version of the /v1 HTTP contract this
|
|
// server speaks. A federation peer refuses to federate across an
|
|
// incompatible major.
|
|
APIVersion = 1
|
|
// SchemaVersion is the major version of the graph schema (node/edge
|
|
// shape) this server exposes. Federation refuses across an
|
|
// incompatible major schema.
|
|
SchemaVersion = 1
|
|
)
|
|
|
|
// HealthResponse is the JSON structure for the /health endpoint. The
|
|
// schema_version / api_version / read_only / capabilities fields let a
|
|
// federation peer negotiate compatibility and posture before it routes
|
|
// any query; a remote that does not advertise read_only is treated as
|
|
// read-only (fail-safe) by the consumer.
|
|
type HealthResponse struct {
|
|
Status string `json:"status"`
|
|
Indexed bool `json:"indexed"`
|
|
Nodes int `json:"nodes"`
|
|
Edges int `json:"edges"`
|
|
Version string `json:"version"`
|
|
UptimeSeconds float64 `json:"uptime_seconds"`
|
|
SchemaVersion int `json:"schema_version"`
|
|
APIVersion int `json:"api_version"`
|
|
ReadOnly bool `json:"read_only"`
|
|
Capabilities []string `json:"capabilities,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
|
stats := h.graph.Stats()
|
|
resp := HealthResponse{
|
|
Status: "ok",
|
|
Indexed: stats.TotalNodes > 0,
|
|
Nodes: stats.TotalNodes,
|
|
Edges: stats.TotalEdges,
|
|
Version: h.version,
|
|
UptimeSeconds: time.Since(h.startTime).Seconds(),
|
|
SchemaVersion: SchemaVersion,
|
|
APIVersion: APIVersion,
|
|
ReadOnly: h.readOnly,
|
|
Capabilities: h.advertisedCapabilities(),
|
|
}
|
|
WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// advertisedCapabilities returns the federation capability set this
|
|
// server exposes. The baseline is whatever registerRoutes always mounts
|
|
// (the SSE event stream); SetCapabilities lets a richer build (e.g. the
|
|
// full-node /v1/subgraph endpoint) extend it.
|
|
func (h *Handler) advertisedCapabilities() []string {
|
|
if h.capabilities != nil {
|
|
return h.capabilities
|
|
}
|
|
// Baseline: the SSE event stream and the full-node /v1/subgraph
|
|
// endpoint are always mounted by registerRoutes.
|
|
return []string{"events", "subgraph"}
|
|
}
|
|
|
|
// SetReadOnly records the server's self-advertised write posture, echoed
|
|
// in /v1/health.read_only. v1 denies all remote writes regardless, but a
|
|
// remote that advertises read_only:true makes its intent explicit.
|
|
func (h *Handler) SetReadOnly(ro bool) { h.readOnly = ro }
|
|
|
|
// SetCapabilities overrides the advertised federation capability set.
|
|
func (h *Handler) SetCapabilities(caps []string) { h.capabilities = caps }
|
|
|
|
// --- /tools ---
|
|
|
|
type toolInfo struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
func (h *Handler) handleListTools(w http.ResponseWriter, _ *http.Request) {
|
|
tools := h.mcpServer.ListTools()
|
|
result := make([]toolInfo, 0, len(tools))
|
|
for name, t := range tools {
|
|
result = append(result, toolInfo{
|
|
Name: name,
|
|
Description: t.Tool.Description,
|
|
})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
|
WriteJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// --- /tool/{name} ---
|
|
|
|
// ToolRequest is the expected JSON body for POST /v1/tools/{tool_name}.
|
|
// Format is a convenience top-level alias for arguments["format"],
|
|
// merged into Arguments before the tool is invoked.
|
|
type ToolRequest struct {
|
|
Arguments map[string]any `json:"arguments"`
|
|
Format string `json:"format,omitempty"`
|
|
}
|
|
|
|
// ToolResponse wraps the MCP tool call result for JSON serialization.
|
|
type ToolResponse struct {
|
|
Content []ToolContent `json:"content"`
|
|
IsError bool `json:"isError,omitempty"`
|
|
}
|
|
|
|
// ToolContent is a simplified content item from the MCP tool result.
|
|
type ToolContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
func (h *Handler) handleToolCall(w http.ResponseWriter, r *http.Request) {
|
|
toolName := strings.TrimPrefix(r.URL.Path, "/v1/tools/")
|
|
if toolName == "" {
|
|
WriteJSONError(w, http.StatusBadRequest, "missing tool name in path")
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
WriteJSONError(w, http.StatusBadRequest, "failed to read request body")
|
|
return
|
|
}
|
|
|
|
// If a Router is wired, peek the body for `workspace` / `cwd`
|
|
// overrides and let the router
|
|
// decide local vs remote. Local path falls through to the
|
|
// existing in-process tool dispatch below; remote path returns
|
|
// the proxied response verbatim. Only the proxy short-circuits
|
|
// — local routing reuses the legacy code so downstream features
|
|
// (combo / frecency / session state) keep working unchanged.
|
|
if h.router != nil && h.decision != nil {
|
|
scope, cwd := h.peekRouteContext(body, r)
|
|
outcome := h.decision.Decide(r.Context(), daemon.RouteInputs{
|
|
ToolName: toolName,
|
|
Body: body,
|
|
Cwd: cwd,
|
|
Scope: scope,
|
|
}, nil)
|
|
if outcome.Proxied {
|
|
// Proxied to a remote server (or a gate refusal); relay
|
|
// the response verbatim.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(outcome.Status)
|
|
_, _ = w.Write(outcome.Out)
|
|
return
|
|
}
|
|
if outcome.Err != nil && !errors.Is(outcome.Err, daemon.ErrRouteUnresolved) {
|
|
h.logger.Warn("router: proxy failed, falling back to local",
|
|
zap.String("tool", toolName),
|
|
zap.Error(outcome.Err))
|
|
}
|
|
// Either ErrRouteUnresolved (no remote claims this scope) or
|
|
// the local-fast path — both fall through to the in-process
|
|
// dispatch below.
|
|
}
|
|
|
|
tool := h.mcpServer.GetTool(toolName)
|
|
if tool == nil {
|
|
available := h.availableToolNames()
|
|
WriteJSON(w, http.StatusNotFound, map[string]any{
|
|
"error": "tool_not_found",
|
|
"message": fmt.Sprintf("tool '%s' not found", toolName),
|
|
"available_tools": available,
|
|
})
|
|
return
|
|
}
|
|
|
|
var args map[string]any
|
|
var bodyFormat string
|
|
if len(body) > 0 {
|
|
var req ToolRequest
|
|
if err := json.Unmarshal(body, &req); err != nil {
|
|
if err2 := json.Unmarshal(body, &args); err2 != nil {
|
|
WriteJSONError(w, http.StatusBadRequest, fmt.Sprintf("malformed JSON: %s", err.Error()))
|
|
return
|
|
}
|
|
} else {
|
|
args = req.Arguments
|
|
bodyFormat = req.Format
|
|
if args == nil {
|
|
_ = json.Unmarshal(body, &args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Merge ?format=<fmt> query param or body-level "format" into the
|
|
// arguments map so tools that understand the argument (gcx, toon,
|
|
// compact, ...) honor it without callers having to nest it under
|
|
// "arguments". Explicit arguments.format still wins.
|
|
if format := firstNonEmpty(r.URL.Query().Get("format"), bodyFormat); format != "" {
|
|
if args == nil {
|
|
args = make(map[string]any)
|
|
}
|
|
if _, ok := args["format"]; !ok {
|
|
args["format"] = format
|
|
}
|
|
}
|
|
|
|
mcpReq := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: toolName,
|
|
Arguments: args,
|
|
},
|
|
}
|
|
|
|
// Overlay session binding for the HTTP transport. The standard
|
|
// `Mcp-Session-Id` header (set by mcp-go's Streamable HTTP
|
|
// client) is preferred; a gortex-specific
|
|
// `X-Gortex-Overlay-Session` header takes precedence when
|
|
// callers want to scope an overlay to a session ID that differs
|
|
// from their MCP transport session (e.g. a CI harness that
|
|
// orchestrates several overlay scopes from one connection). A
|
|
// `?session_id=` query parameter is the final fallback so curl /
|
|
// integration tests can attach overlays without setting HTTP
|
|
// headers. The session ID flows through gortexmcp.WithSessionID
|
|
// so the MCP overlay middleware (overlay.go::wrapToolHandler)
|
|
// finds the right overlay snapshot.
|
|
ctx := r.Context()
|
|
if sid := firstNonEmpty(
|
|
r.Header.Get("X-Gortex-Overlay-Session"),
|
|
r.Header.Get("Mcp-Session-Id"),
|
|
r.URL.Query().Get("session_id"),
|
|
); sid != "" {
|
|
ctx = gortexmcp.WithSessionID(ctx, sid)
|
|
}
|
|
result, err := tool.Handler(ctx, mcpReq)
|
|
if err != nil {
|
|
h.logger.Error("tool call failed",
|
|
zap.String("tool", toolName),
|
|
zap.Error(err),
|
|
)
|
|
WriteJSON(w, http.StatusInternalServerError, map[string]any{
|
|
"error": "tool_error",
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
resp := ToolResponse{
|
|
IsError: result.IsError,
|
|
}
|
|
for _, c := range result.Content {
|
|
if tc, ok := c.(mcp.TextContent); ok {
|
|
resp.Content = append(resp.Content, ToolContent{
|
|
Type: "text",
|
|
Text: tc.Text,
|
|
})
|
|
}
|
|
}
|
|
|
|
WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// --- /stats ---
|
|
|
|
// StatsResponse is the JSON structure for the /v1/stats endpoint.
|
|
// ServerID is a per-machine UUID that changes on server restart so
|
|
// daemon clients can detect reconnects; StartedAt is the wall-clock
|
|
// time of this process start.
|
|
type StatsResponse struct {
|
|
ServerID string `json:"server_id,omitempty"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
TotalNodes int `json:"total_nodes"`
|
|
TotalEdges int `json:"total_edges"`
|
|
ByKind map[string]int `json:"by_kind"`
|
|
ByLanguage map[string]int `json:"by_language"`
|
|
}
|
|
|
|
func (h *Handler) handleStats(w http.ResponseWriter, _ *http.Request) {
|
|
stats := h.graph.Stats()
|
|
resp := StatsResponse{
|
|
ServerID: h.serverID,
|
|
StartedAt: h.startTime,
|
|
TotalNodes: stats.TotalNodes,
|
|
TotalEdges: stats.TotalEdges,
|
|
ByKind: stats.ByKind,
|
|
ByLanguage: stats.ByLanguage,
|
|
}
|
|
WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// --- Tool invocation helper ---
|
|
|
|
// CallTool invokes an MCP tool by name and returns the concatenated text content.
|
|
// Returns empty string on error, missing tool, or tool-level error result.
|
|
//
|
|
// This is the best-effort variant: callers cannot distinguish "tool returned
|
|
// no content" from "tool returned an error result." Use CallToolStrict when
|
|
// you need that distinction (e.g. an HTTP endpoint that should surface 5xx
|
|
// instead of pretending the call succeeded with empty data).
|
|
func (h *Handler) CallTool(ctx context.Context, toolName string, args map[string]any) string {
|
|
text, _ := h.CallToolStrict(ctx, toolName, args)
|
|
return text
|
|
}
|
|
|
|
// CallToolStrict invokes an MCP tool by name and returns the concatenated
|
|
// text content together with a non-nil error when the call did not produce a
|
|
// successful result. The four error cases are:
|
|
//
|
|
// - tool name is not registered on this server (nil error from Go but
|
|
// callers want to distinguish "no such tool" from "no content")
|
|
// - tool handler returned a Go-level error
|
|
// - tool handler returned a result with IsError == true (the upstream MCP
|
|
// contract; the text content is the human-readable error message)
|
|
// - tool returned a successful result but no text content (degenerate;
|
|
// surfaced as an error so callers do not silently render an empty UI)
|
|
//
|
|
// The returned string carries the text content in every case, including the
|
|
// error cases — callers that want to render the message verbatim can do so
|
|
// regardless of whether they treat it as an error.
|
|
func (h *Handler) CallToolStrict(ctx context.Context, toolName string, args map[string]any) (string, error) {
|
|
tool := h.mcpServer.GetTool(toolName)
|
|
if tool == nil {
|
|
return "", fmt.Errorf("tool %q is not registered", toolName)
|
|
}
|
|
|
|
req := mcp.CallToolRequest{
|
|
Params: mcp.CallToolParams{
|
|
Name: toolName,
|
|
Arguments: args,
|
|
},
|
|
}
|
|
|
|
result, err := tool.Handler(ctx, req)
|
|
if err != nil {
|
|
h.logger.Debug("internal tool call failed",
|
|
zap.String("tool", toolName),
|
|
zap.Error(err),
|
|
)
|
|
return "", fmt.Errorf("tool %q invocation failed: %w", toolName, err)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for _, c := range result.Content {
|
|
if tc, ok := c.(mcp.TextContent); ok {
|
|
if sb.Len() > 0 {
|
|
sb.WriteString("\n")
|
|
}
|
|
sb.WriteString(tc.Text)
|
|
}
|
|
}
|
|
text := sb.String()
|
|
|
|
if result.IsError {
|
|
// MCP contract: IsError=true means the text content describes the
|
|
// error. Surface it as a Go error so callers can distinguish it
|
|
// from a real result. Keep the text in the returned string so the
|
|
// caller can include it in the response body if it wishes.
|
|
h.logger.Debug("internal tool call returned error result",
|
|
zap.String("tool", toolName),
|
|
zap.String("text", text),
|
|
)
|
|
if text == "" {
|
|
return "", fmt.Errorf("tool %q returned an error result", toolName)
|
|
}
|
|
return text, fmt.Errorf("tool %q error: %s", toolName, text)
|
|
}
|
|
|
|
return text, nil
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func (h *Handler) availableToolNames() []string {
|
|
tools := h.mcpServer.ListTools()
|
|
names := make([]string, 0, len(tools))
|
|
for name := range tools {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// WriteJSON writes a JSON response with the given status code.
|
|
func WriteJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// WriteJSONError writes a JSON error response.
|
|
func WriteJSONError(w http.ResponseWriter, status int, message string) {
|
|
WriteJSON(w, status, map[string]string{
|
|
"error": http.StatusText(status),
|
|
"message": message,
|
|
})
|
|
}
|
|
|
|
// --- /v1/graph ---
|
|
|
|
// GraphResponse is the full brief-graph dump returned by /v1/graph.
|
|
// Nodes carry only the fields needed for force-directed rendering;
|
|
// heavy fields (Meta, QualName, EndLine) are stripped.
|
|
type GraphResponse struct {
|
|
Nodes []*graph.Node `json:"nodes"`
|
|
Edges []*graph.Edge `json:"edges"`
|
|
Stats graph.GraphStats `json:"stats"`
|
|
}
|
|
|
|
func (h *Handler) handleGetGraph(w http.ResponseWriter, r *http.Request) {
|
|
project := strings.TrimSpace(r.URL.Query().Get("project"))
|
|
repo := strings.TrimSpace(r.URL.Query().Get("repo"))
|
|
|
|
allowedPrefixes, err := h.resolveRepoFilter(project, repo)
|
|
if err != nil {
|
|
WriteJSONError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
nodes := h.graph.AllNodes()
|
|
edges := h.graph.AllEdges()
|
|
|
|
briefNodes := make([]*graph.Node, 0, len(nodes))
|
|
keptIDs := make(map[string]struct{}, len(nodes))
|
|
for _, n := range nodes {
|
|
if allowedPrefixes != nil {
|
|
if _, ok := allowedPrefixes[n.RepoPrefix]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
briefNodes = append(briefNodes, &graph.Node{
|
|
ID: n.ID,
|
|
Kind: n.Kind,
|
|
Name: n.Name,
|
|
FilePath: n.FilePath,
|
|
StartLine: n.StartLine,
|
|
Language: n.Language,
|
|
RepoPrefix: n.RepoPrefix,
|
|
})
|
|
keptIDs[n.ID] = struct{}{}
|
|
}
|
|
|
|
var filteredEdges []*graph.Edge
|
|
if allowedPrefixes == nil {
|
|
filteredEdges = edges
|
|
} else {
|
|
filteredEdges = make([]*graph.Edge, 0, len(edges))
|
|
for _, e := range edges {
|
|
if _, ok := keptIDs[e.From]; !ok {
|
|
continue
|
|
}
|
|
if _, ok := keptIDs[e.To]; !ok {
|
|
continue
|
|
}
|
|
filteredEdges = append(filteredEdges, e)
|
|
}
|
|
}
|
|
|
|
// When unfiltered, report full graph stats; otherwise return zero
|
|
// stats — the UI can derive counts from the nodes/edges arrays.
|
|
var stats graph.GraphStats
|
|
if allowedPrefixes == nil {
|
|
stats = h.graph.Stats()
|
|
}
|
|
|
|
WriteJSON(w, http.StatusOK, GraphResponse{
|
|
Nodes: briefNodes,
|
|
Edges: filteredEdges,
|
|
Stats: stats,
|
|
})
|
|
}
|
|
|
|
// resolveRepoFilter returns a set of allowed RepoPrefix values based on
|
|
// the ?project / ?repo query parameters. Returns nil when no filter
|
|
// was requested (meaning "return everything").
|
|
func (h *Handler) resolveRepoFilter(project, repo string) (map[string]struct{}, error) {
|
|
if project == "" && repo == "" {
|
|
return nil, nil
|
|
}
|
|
allowed := make(map[string]struct{})
|
|
if project != "" {
|
|
if h.configManager == nil {
|
|
return nil, fmt.Errorf("?project= requires multi-repo config, none loaded")
|
|
}
|
|
repos, err := h.configManager.Global().ResolveRepos(project)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, entry := range repos {
|
|
allowed[filepath.Base(entry.Path)] = struct{}{}
|
|
}
|
|
}
|
|
if repo != "" {
|
|
allowed[repo] = struct{}{}
|
|
}
|
|
return allowed, nil
|
|
}
|
|
|
|
// --- /v1/events (SSE) ---
|
|
|
|
func (h *Handler) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
// Without a hub (watch mode off), emit a single comment frame and
|
|
// close so clients can distinguish "no events ever" from "stream
|
|
// dropped mid-session".
|
|
if h.eventHub == nil {
|
|
fmt.Fprintf(w, ": watch mode not active\n\n")
|
|
flusher.Flush()
|
|
return
|
|
}
|
|
|
|
flusher.Flush()
|
|
|
|
subID, ch := h.eventHub.Subscribe()
|
|
defer h.eventHub.Unsubscribe(subID)
|
|
|
|
keepalive := time.NewTicker(15 * time.Second)
|
|
defer keepalive.Stop()
|
|
|
|
ctx := r.Context()
|
|
for {
|
|
select {
|
|
case ev, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
data, _ := json.Marshal(ev)
|
|
fmt.Fprintf(w, "event: graph_change\nid: %d\ndata: %s\n\n",
|
|
ev.Timestamp.UnixMilli(), string(data))
|
|
flusher.Flush()
|
|
|
|
case <-keepalive.C:
|
|
fmt.Fprintf(w, ": keepalive\n\n")
|
|
flusher.Flush()
|
|
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|