chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,806 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user