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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
package server
import (
"crypto/subtle"
"net/http"
"strings"
)
// WithAuth wraps h with a bearer-token check. Every request must carry
// `Authorization: Bearer <token>`; mismatches get a 401 JSON error.
//
// When token is empty, WithAuth returns h unchanged — the caller has
// opted into unauthenticated mode (the server command enforces this is
// only safe with a localhost bind; see cmd/gortex/server.go).
//
// CORS preflights (OPTIONS) bypass the check so browsers on a different
// origin can negotiate headers before the real request is issued.
//
// As a browser-EventSource workaround the middleware also accepts the
// token via `?token=<t>`. Query-string auth leaks more readily into
// access logs and referrer headers than a Bearer header, so prefer the
// header when the client can set it.
func WithAuth(h http.Handler, token string) http.Handler {
if token == "" {
return h
}
return WithAuthFunc(h, func() string { return token })
}
// WithAuthFunc is WithAuth with a per-request token source: tokenFn is
// resolved on every request, so an operator can rotate the expected token
// (e.g. update $GORTEX_DAEMON_HTTP_TOKEN) without restarting the server.
// A tokenFn that returns "" serves the request unauthenticated, identical
// to a static empty token — so the token can also be added or removed at
// runtime, not only changed.
func WithAuthFunc(h http.Handler, tokenFn func() string) http.Handler {
if tokenFn == nil {
return h
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := tokenFn()
if token == "" {
h.ServeHTTP(w, r)
return
}
if r.Method == http.MethodOptions {
h.ServeHTTP(w, r)
return
}
if authMatches([]byte(r.Header.Get("Authorization")), []byte("Bearer "+token)) {
h.ServeHTTP(w, r)
return
}
if q := r.URL.Query().Get("token"); q != "" && tokenMatches([]byte(q), []byte(token)) {
h.ServeHTTP(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Bearer realm="gortex"`)
WriteJSONError(w, http.StatusUnauthorized, "missing or invalid bearer token")
})
}
// authMatches does a constant-time comparison to defeat timing attacks
// on token validation.
func authMatches(got, expected []byte) bool {
if !strings.HasPrefix(string(got), "Bearer ") {
return false
}
return tokenMatches(got, expected)
}
func tokenMatches(got, expected []byte) bool {
// subtle.ConstantTimeCompare needs equal-length slices to return 1.
// For unequal lengths the answer is obviously "no match" but we
// still scan a fixed-size buffer so an attacker can't learn the
// token length from timing.
if len(got) != len(expected) {
_ = subtle.ConstantTimeCompare(expected, expected)
return false
}
return subtle.ConstantTimeCompare(got, expected) == 1
}
+57
View File
@@ -0,0 +1,57 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
// TestWithAuthFunc_RotatesWithoutRestart asserts the per-request token
// source lets the expected token be added, changed, and removed at
// runtime without rebuilding the handler.
func TestWithAuthFunc_RotatesWithoutRestart(t *testing.T) {
current := ""
h := WithAuthFunc(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }),
func() string { return current },
)
call := func(bearer string) int {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
h.ServeHTTP(rec, req)
return rec.Code
}
// No token configured -> unauthenticated, any/no bearer passes.
if got := call(""); got != http.StatusOK {
t.Fatalf("empty token should allow unauthenticated; got %d", got)
}
// Add a token at runtime -> now enforced.
current = "alpha"
if got := call(""); got != http.StatusUnauthorized {
t.Errorf("after adding a token, no-bearer should be 401; got %d", got)
}
if got := call("alpha"); got != http.StatusOK {
t.Errorf("correct token should pass; got %d", got)
}
// Rotate the token -> the old one stops working, the new one works.
current = "beta"
if got := call("alpha"); got != http.StatusUnauthorized {
t.Errorf("rotated-away token should be 401; got %d", got)
}
if got := call("beta"); got != http.StatusOK {
t.Errorf("rotated-in token should pass; got %d", got)
}
// Remove the token -> unauthenticated again.
current = ""
if got := call(""); got != http.StatusOK {
t.Errorf("after removing the token, unauthenticated should pass; got %d", got)
}
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func okHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`ok`))
})
}
func TestWithAuth_EmptyTokenIsPassthrough(t *testing.T) {
h := WithAuth(okHandler(), "")
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestWithAuth_AcceptsValidBearer(t *testing.T) {
h := WithAuth(okHandler(), "secret-token")
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req.Header.Set("Authorization", "Bearer secret-token")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestWithAuth_RejectsMissingHeader(t *testing.T) {
h := WithAuth(okHandler(), "secret-token")
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Equal(t, `Bearer realm="gortex"`, rec.Header().Get("WWW-Authenticate"))
}
func TestWithAuth_RejectsWrongToken(t *testing.T) {
h := WithAuth(okHandler(), "secret-token")
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req.Header.Set("Authorization", "Bearer wrong")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestWithAuth_RejectsNonBearerScheme(t *testing.T) {
h := WithAuth(okHandler(), "secret-token")
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req.Header.Set("Authorization", "Basic c2VjcmV0LXRva2Vu")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
func TestWithAuth_BypassesOptionsForCORSPreflight(t *testing.T) {
h := WithAuth(okHandler(), "secret-token")
req := httptest.NewRequest(http.MethodOptions, "/v1/tools/search_symbols", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestWithAuth_AcceptsQueryStringToken(t *testing.T) {
h := WithAuth(okHandler(), "secret-token")
req := httptest.NewRequest(http.MethodGet, "/v1/events?token=secret-token", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestWithAuth_RejectsWrongQueryStringToken(t *testing.T) {
h := WithAuth(okHandler(), "secret-token")
req := httptest.NewRequest(http.MethodGet, "/v1/events?token=wrong", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
}
+238
View File
@@ -0,0 +1,238 @@
package server
import (
"net/http"
"github.com/zzet/gortex/internal/llm/conversationlog"
)
// Conversation-log inspection routes. They expose the opt-in
// conversation-log sink (raw LLM request/response JSONL per session) to
// a local inspector:
//
// GET /v1/conversations list recorded sessions
// GET /v1/conversations/{session} per-file/phase request/response steps + usage
// GET /v1/conversations/ui a minimal self-contained HTML inspector
//
// Because these routes egress raw LLM I/O, each one applies the
// route-scoped DNS-rebind guard (guardConversationRoute) before
// responding. The guard cooperates with the existing --http-auth-token
// model: it allows loopback / allowlisted hosts OR a valid token. It is
// deliberately NOT installed in ServeHTTP, so no other route is affected
// and a token-authed non-loopback dashboard keeps working.
// SetConversationDir enables the /v1/conversations* routes by pointing
// them at the conversation-log directory. An empty dir leaves the routes
// mounted but reporting no sessions (the sink is off).
func (h *Handler) SetConversationDir(dir string) { h.convDir = dir }
// SetConversationGuard wires the route-scoped DNS-rebind guard for the
// conversation routes: an extra Host allowlist (beyond loopback) and the
// auth-token source so a valid token-authed request passes. tokenFn may
// be nil (no token configured); allow may be empty (loopback-only).
func (h *Handler) SetConversationGuard(allow []string, tokenFn func() string) {
h.convAllow = allow
h.convTokenFn = tokenFn
}
// conversationTokenOK reports whether the request carries a valid auth
// token, matching the source-of-truth check in WithAuthFunc (Bearer
// header or ?token=). When no token is configured, there is nothing to
// present, so this returns false and the guard falls back to the
// loopback/allowlist check.
func (h *Handler) conversationTokenOK(r *http.Request) bool {
if h.convTokenFn == nil {
return false
}
expected := h.convTokenFn()
if expected == "" {
return false
}
if authMatches([]byte(r.Header.Get("Authorization")), []byte("Bearer "+expected)) {
return true
}
if q := r.URL.Query().Get("token"); q != "" && tokenMatches([]byte(q), []byte(expected)) {
return true
}
return false
}
// guardConversation runs the route-scoped guard for one request and
// writes the 403 response when it fails. Returns true when the request
// may proceed.
func (h *Handler) guardConversation(w http.ResponseWriter, r *http.Request) bool {
if guardConversationRoute(r, h.convAllow, h.conversationTokenOK(r)) {
return true
}
WriteJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden host"})
return false
}
// conversationReader returns a reader over the configured conversation
// directory (which may be empty, yielding an empty session list).
func (h *Handler) conversationReader() *conversationlog.Reader {
return conversationlog.NewReader(h.convDir)
}
// --- GET /v1/conversations ---
func (h *Handler) handleConversations(w http.ResponseWriter, r *http.Request) {
if !h.guardConversation(w, r) {
return
}
sessions, err := h.conversationReader().ListSessions()
if err != nil {
WriteJSONError(w, http.StatusInternalServerError, "failed to read conversation sessions")
return
}
if sessions == nil {
sessions = []conversationlog.SessionSummary{}
}
WriteJSON(w, http.StatusOK, map[string]any{"sessions": sessions})
}
// --- GET /v1/conversations/{session} ---
func (h *Handler) handleConversationSession(w http.ResponseWriter, r *http.Request) {
if !h.guardConversation(w, r) {
return
}
session := r.PathValue("session")
if session == "" {
WriteJSONError(w, http.StatusBadRequest, "missing session id")
return
}
recs, err := h.conversationReader().LoadSession(session)
if err != nil {
WriteJSONError(w, http.StatusInternalServerError, "failed to load session")
return
}
total := len(recs)
// Optional ?file= / ?phase= filters narrow the records to one
// file/phase, mirroring the /v1/activity filter idiom.
fileFilter := r.URL.Query().Get("file")
phaseFilter := r.URL.Query().Get("phase")
filtered := recs
if fileFilter != "" || phaseFilter != "" {
filtered = filtered[:0:0]
for _, rec := range recs {
if fileFilter != "" && rec.File != fileFilter {
continue
}
if phaseFilter != "" && rec.Phase != phaseFilter {
continue
}
filtered = append(filtered, rec)
}
}
if filtered == nil {
filtered = []conversationlog.Record{}
}
WriteJSON(w, http.StatusOK, map[string]any{
"session": session,
"records": filtered,
"total": total,
"filtered": len(filtered),
})
}
// --- GET /v1/conversations/ui ---
func (h *Handler) handleConversationsUI(w http.ResponseWriter, r *http.Request) {
if !h.guardConversation(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(conversationInspectorHTML))
}
// conversationInspectorHTML is a minimal, self-contained inspector page:
// it lists sessions, lets you pick one, and renders each turn's request
// messages, response, and token usage. No external assets — it talks to
// the same-origin /v1/conversations JSON routes.
const conversationInspectorHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gortex — Conversation Inspector</title>
<style>
body { font: 14px/1.5 -apple-system, system-ui, sans-serif; margin: 0; color: #1d2127; background: #f6f8fa; }
header { padding: 12px 16px; background: #24292f; color: #fff; }
header h1 { margin: 0; font-size: 16px; }
main { display: flex; height: calc(100vh - 45px); }
#sessions { width: 280px; overflow-y: auto; border-right: 1px solid #d0d7de; background: #fff; }
#sessions ul { list-style: none; margin: 0; padding: 0; }
#sessions li { padding: 10px 14px; cursor: pointer; border-bottom: 1px solid #eaeef2; }
#sessions li:hover { background: #f3f4f6; }
#sessions li.active { background: #ddf4ff; }
#sessions .meta { color: #57606a; font-size: 12px; }
#detail { flex: 1; overflow-y: auto; padding: 16px; }
.turn { background: #fff; border: 1px solid #d0d7de; border-radius: 6px; margin-bottom: 14px; padding: 12px; }
.turn .hd { font-size: 12px; color: #57606a; margin-bottom: 8px; }
.turn .tag { display: inline-block; background: #eaeef2; border-radius: 10px; padding: 1px 8px; margin-right: 6px; }
.msg { margin: 6px 0; }
.msg .role { font-weight: 600; color: #0969da; }
pre { white-space: pre-wrap; word-break: break-word; background: #f6f8fa; border-radius: 4px; padding: 8px; margin: 4px 0; }
.usage { font-size: 12px; color: #57606a; margin-top: 6px; }
.est { color: #9a6700; }
.empty { color: #57606a; padding: 24px; }
</style>
</head>
<body>
<header><h1>Gortex — Conversation Inspector</h1></header>
<main>
<div id="sessions"><div class="empty">Loading…</div></div>
<div id="detail"><div class="empty">Select a session.</div></div>
</main>
<script>
const esc = (s) => String(s == null ? "" : s).replace(/[&<>]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
async function loadSessions() {
const r = await fetch("/v1/conversations");
const j = await r.json();
const el = document.getElementById("sessions");
const list = (j.sessions || []);
if (!list.length) { el.innerHTML = '<div class="empty">No recorded sessions.</div>'; return; }
const ul = document.createElement("ul");
for (const s of list) {
const li = document.createElement("li");
li.innerHTML = '<div>' + esc(s.session) + '</div>' +
'<div class="meta">' + (s.records||0) + ' turns · ' + ((s.files||[]).length) + ' files</div>';
li.onclick = () => { document.querySelectorAll("#sessions li").forEach(n => n.classList.remove("active")); li.classList.add("active"); loadSession(s.session); };
ul.appendChild(li);
}
el.innerHTML = ""; el.appendChild(ul);
}
async function loadSession(session) {
const r = await fetch("/v1/conversations/" + encodeURIComponent(session));
const j = await r.json();
const el = document.getElementById("detail");
const recs = j.records || [];
if (!recs.length) { el.innerHTML = '<div class="empty">No turns in this session.</div>'; return; }
el.innerHTML = "";
for (const rec of recs) {
const d = document.createElement("div"); d.className = "turn";
let h = '<div class="hd">';
if (rec.file) h += '<span class="tag">file: ' + esc(rec.file) + '</span>';
if (rec.phase) h += '<span class="tag">phase: ' + esc(rec.phase) + '</span>';
if (rec.provider) h += '<span class="tag">' + esc(rec.provider) + '</span>';
if (rec.model) h += '<span class="tag">' + esc(rec.model) + '</span>';
h += '</div>';
for (const m of (rec.request || [])) {
h += '<div class="msg"><span class="role">' + esc(m.Role || m.role) + '</span><pre>' + esc(m.Content || m.content) + '</pre></div>';
}
h += '<div class="msg"><span class="role">response</span><pre>' + esc(rec.response) + '</pre></div>';
const est = rec.estimated ? ' <span class="est">(estimated)</span>' : '';
h += '<div class="usage">in ' + (rec.input_tokens||0) + ' · out ' + (rec.output_tokens||0) + est + ' · ' + (rec.elapsed_ms||0) + 'ms</div>';
if (rec.error) h += '<div class="usage est">error: ' + esc(rec.error) + '</div>';
d.innerHTML = h; el.appendChild(d);
}
}
loadSessions();
</script>
</body>
</html>
`
+235
View File
@@ -0,0 +1,235 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
mcpserver "github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/llm/conversationlog"
)
func newConversationHandler(t *testing.T, dir string) *Handler {
t.Helper()
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", mcpserver.WithToolCapabilities(false))
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
h.SetConversationDir(dir)
return h
}
// seedSession writes a session JSONL file directly into dir so the route
// tests don't need a live LLM.
func seedSession(t *testing.T, dir, session string, recs []conversationlog.Record) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(dir, session+".jsonl"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
enc := json.NewEncoder(f)
for _, rec := range recs {
if err := enc.Encode(&rec); err != nil {
t.Fatal(err)
}
}
}
func TestConversations_ListAndLoad(t *testing.T) {
dir := t.TempDir()
seedSession(t, dir, "sess-A", []conversationlog.Record{
{Session: "sess-A", File: "a.go", Phase: "plan", Response: "r1", InputTokens: 10, OutputTokens: 2, Estimated: true},
{Session: "sess-A", File: "b.go", Phase: "main", Response: "r2", InputTokens: 20, OutputTokens: 4, Estimated: true},
})
h := newConversationHandler(t, dir)
// Loopback host so the guard allows without a token.
srv := httptest.NewServer(h)
defer srv.Close()
// List.
resp, err := http.Get(srv.URL + "/v1/conversations")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("list status = %d", resp.StatusCode)
}
var list struct {
Sessions []conversationlog.SessionSummary `json:"sessions"`
}
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if len(list.Sessions) != 1 || list.Sessions[0].Session != "sess-A" {
t.Fatalf("list = %+v, want one session sess-A", list.Sessions)
}
if list.Sessions[0].Records != 2 {
t.Fatalf("session records = %d, want 2", list.Sessions[0].Records)
}
// Load session — full set.
resp2, err := http.Get(srv.URL + "/v1/conversations/sess-A")
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
var got struct {
Session string `json:"session"`
Records []conversationlog.Record `json:"records"`
Total int `json:"total"`
Filtered int `json:"filtered"`
}
if err := json.NewDecoder(resp2.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got.Total != 2 || got.Filtered != 2 || len(got.Records) != 2 {
t.Fatalf("load = %+v, want 2 records", got)
}
if got.Records[0].InputTokens != 10 || !got.Records[0].Estimated {
t.Fatalf("usage not returned: %+v", got.Records[0])
}
// Filter by file.
resp3, err := http.Get(srv.URL + "/v1/conversations/sess-A?file=b.go")
if err != nil {
t.Fatal(err)
}
defer resp3.Body.Close()
var gotF struct {
Records []conversationlog.Record `json:"records"`
Total int `json:"total"`
Filtered int `json:"filtered"`
}
if err := json.NewDecoder(resp3.Body).Decode(&gotF); err != nil {
t.Fatal(err)
}
if gotF.Total != 2 || gotF.Filtered != 1 || len(gotF.Records) != 1 {
t.Fatalf("file filter = %+v, want 1 of 2", gotF)
}
if gotF.Records[0].File != "b.go" {
t.Fatalf("file filter returned %q", gotF.Records[0].File)
}
}
func TestConversations_UI_HTML(t *testing.T) {
h := newConversationHandler(t, t.TempDir())
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/conversations/ui", nil)
req.Host = "localhost"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("ui status = %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
t.Fatalf("ui content-type = %q", ct)
}
if body := rec.Body.String(); len(body) == 0 || body[:9] != "<!DOCTYPE" {
t.Fatalf("ui body does not look like HTML: %.40q", body)
}
}
// TestConversations_RouteGuard is the route-scoped DNS-rebind table test.
func TestConversations_RouteGuard(t *testing.T) {
const token = "s3cret-token"
cases := []struct {
name string
host string
bearer string
allow []string
tokenFn func() string
wantAllow bool
}{
{name: "loopback ip", host: "127.0.0.1:7411", wantAllow: true},
{name: "localhost name", host: "localhost:7411", wantAllow: true},
{name: "ipv6 loopback", host: "[::1]:7411", wantAllow: true},
{name: "rebind no token", host: "evil.example", tokenFn: func() string { return token }, wantAllow: false},
{name: "rebind no token configured", host: "evil.example", wantAllow: false},
{name: "allowlisted host", host: "dash.internal", allow: []string{"dash.internal"}, wantAllow: true},
{name: "token authed non-loopback", host: "evil.example", bearer: token, tokenFn: func() string { return token }, wantAllow: true},
{name: "wrong token non-loopback", host: "evil.example", bearer: "nope", tokenFn: func() string { return token }, wantAllow: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newConversationHandler(t, t.TempDir())
h.SetConversationGuard(tc.allow, tc.tokenFn)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/conversations", nil)
req.Host = tc.host
if tc.bearer != "" {
req.Header.Set("Authorization", "Bearer "+tc.bearer)
}
h.ServeHTTP(rec, req)
if tc.wantAllow && rec.Code != http.StatusOK {
t.Fatalf("want allow (200), got %d: %s", rec.Code, rec.Body.String())
}
if !tc.wantAllow {
if rec.Code != http.StatusForbidden {
t.Fatalf("want 403, got %d", rec.Code)
}
var e map[string]string
_ = json.Unmarshal(rec.Body.Bytes(), &e)
if e["error"] != "forbidden host" {
t.Fatalf("403 body = %v, want forbidden host", e)
}
}
})
}
}
// TestConversations_GuardIsRouteScoped asserts the guard does NOT touch
// other routes: a token-authed (or even token-less) non-loopback request
// to a non-conversation route is not 403'd by this guard.
func TestConversations_GuardIsRouteScoped(t *testing.T) {
const token = "s3cret-token"
h := newConversationHandler(t, t.TempDir())
h.SetConversationGuard(nil, func() string { return token })
// A non-loopback request to /v1/health must NOT be blocked by the
// conversation route guard (the guard is route-scoped).
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req.Host = "evil.example"
req.Header.Set("Authorization", "Bearer "+token)
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("non-conversation route blocked by conversation guard: %d", rec.Code)
}
// And even with no token at all, /v1/health is untouched by this guard.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req2.Host = "evil.example"
h.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("non-conversation route blocked without token: %d", rec2.Code)
}
}
func TestConversations_QueryTokenAuth(t *testing.T) {
// A ?token= query param is a valid auth presentation (the EventSource
// workaround), so it should let a non-loopback conversation request
// pass — same as the Bearer header.
const token = "s3cret-token"
h := newConversationHandler(t, t.TempDir())
h.SetConversationGuard(nil, func() string { return token })
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/v1/conversations?token="+token, nil)
req.Host = "evil.example"
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("query-token auth should pass on a conversation route, got %d", rec.Code)
}
}
+47
View File
@@ -0,0 +1,47 @@
package server
import (
"net/http"
"slices"
)
// CORSOptions configures CORS behavior for the server HTTP API.
type CORSOptions struct {
AllowOrigins []string // Allowed origins; default ["*"].
}
// WithCORS wraps an http.Handler with CORS headers.
func WithCORS(h http.Handler, opts CORSOptions) http.Handler {
origins := opts.AllowOrigins
if len(origins) == 0 {
origins = []string{"*"}
}
wildcard := len(origins) == 1 && origins[0] == "*"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
matched := false
if wildcard {
w.Header().Set("Access-Control-Allow-Origin", "*")
matched = true
} else if slices.Contains(origins, origin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
matched = true
}
if matched {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "86400")
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h.ServeHTTP(w, r)
})
}
+79
View File
@@ -0,0 +1,79 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestCORS_Wildcard(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := WithCORS(inner, CORSOptions{})
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"))
}
func TestCORS_SpecificOrigin(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := WithCORS(inner, CORSOptions{AllowOrigins: []string{"http://localhost:3000", "http://example.com"}})
// Matching origin.
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, "http://localhost:3000", rec.Header().Get("Access-Control-Allow-Origin"))
assert.Equal(t, "Origin", rec.Header().Get("Vary"))
// Non-matching origin.
req = httptest.NewRequest(http.MethodGet, "/v1/health", nil)
req.Header.Set("Origin", "http://evil.com")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
}
func TestCORS_Preflight(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("should not reach"))
})
h := WithCORS(inner, CORSOptions{})
req := httptest.NewRequest(http.MethodOptions, "/v1/tools/echo", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Equal(t, "*", rec.Header().Get("Access-Control-Allow-Origin"))
assert.Empty(t, rec.Body.String(), "preflight should not forward to inner handler")
}
func TestCORS_NoOriginHeader(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := WithCORS(inner, CORSOptions{})
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"), "no CORS headers without Origin")
}
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
package server
import "testing"
func TestCanonicalContractKey(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"leaves non-http IDs alone", "grpc::user.v1.UserService/GetUser", "grpc::user.v1.UserService/GetUser"},
{"leaves unparameterised routes alone", "http::GET::/v1/health", "http::GET::/v1/health"},
{"rewrites single param", "http::DELETE::/v1/tucks/{id}", "http::DELETE::/v1/tucks/{p1}"},
{
"collapses differing param names to the same key — the exact provider/consumer pairing bug",
"http::DELETE::/v1/workspaces/{wid}/tags/{id}",
"http::DELETE::/v1/workspaces/{p1}/tags/{p2}",
},
{
"consumer variant canonicalises to the same key",
"http::DELETE::/v1/workspaces/{workspaceId}/tags/{id}",
"http::DELETE::/v1/workspaces/{p1}/tags/{p2}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := canonicalContractKey(tt.in)
if got != tt.want {
t.Errorf("canonicalContractKey(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestContractScope(t *testing.T) {
tests := []struct {
name string
rawType string
producer string
want string
}{
{"go.mod dep always external", "dependency", "core-api", "external"},
{"http with provider is own", "http", "core-api", "own"},
{"http with no provider is external", "http", "", "external"},
{"topic with producer is own", "topic", "worker", "own"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := contractScope(tt.rawType, tt.producer); got != tt.want {
t.Errorf("contractScope(%q, %q) = %q, want %q", tt.rawType, tt.producer, got, tt.want)
}
})
}
}
+806
View File
@@ -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
}
}
}
+93
View File
@@ -0,0 +1,93 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mcpserver "github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
)
func newHealthHandler(t *testing.T) *Handler {
t.Helper()
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", mcpserver.WithToolCapabilities(false))
return NewHandler(srv, g, "0.0.1-test", zap.NewNop())
}
// TestHealth_AdvertisesCapsAndReadOnly asserts /v1/health carries the
// federation negotiation fields a peer needs: schema_version,
// api_version, read_only, and capabilities.
func TestHealth_AdvertisesCapsAndReadOnly(t *testing.T) {
h := newHealthHandler(t)
h.SetReadOnly(true)
h.SetCapabilities([]string{"events", "subgraph"})
rec := httptest.NewRecorder()
h.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/v1/health", nil))
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rec.Code)
}
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.SchemaVersion != SchemaVersion {
t.Errorf("schema_version = %d, want %d", resp.SchemaVersion, SchemaVersion)
}
if resp.APIVersion != APIVersion {
t.Errorf("api_version = %d, want %d", resp.APIVersion, APIVersion)
}
if !resp.ReadOnly {
t.Error("read_only should reflect SetReadOnly(true)")
}
if !hasCap(resp.Capabilities, "subgraph") {
t.Errorf("capabilities should include subgraph, got %v", resp.Capabilities)
}
// The raw JSON must carry the keys by name (a peer reads them off
// the wire, not off this struct).
var raw map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
t.Fatal(err)
}
for _, k := range []string{"schema_version", "api_version", "read_only", "capabilities"} {
if _, ok := raw[k]; !ok {
t.Errorf("health JSON missing key %q", k)
}
}
}
// TestHealth_DefaultPosture asserts the baseline when no posture is set:
// read_only false, capabilities default to the always-mounted event
// stream.
func TestHealth_DefaultPosture(t *testing.T) {
h := newHealthHandler(t)
rec := httptest.NewRecorder()
h.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/v1/health", nil))
var resp HealthResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.ReadOnly {
t.Error("default read_only should be false")
}
if !hasCap(resp.Capabilities, "events") {
t.Errorf("default capabilities should include events, got %v", resp.Capabilities)
}
}
func hasCap(caps []string, want string) bool {
for _, c := range caps {
if c == want {
return true
}
}
return false
}
@@ -0,0 +1,94 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/mcp/streamable"
)
// TestHandler_StreamableMCPRouteReturns404UntilWired — until
// SetStreamableTransport runs, /mcp returns 404 (no route
// registered). This is the load-bearing contract for "default off,
// opt-in transport".
func TestHandler_StreamableMCPRouteReturns404UntilWired(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader([]byte(`{}`)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// TestHandler_StreamableTransportMountedOnMCP exercises the full
// server.Handler → streamable.Transport wiring path: SetStreamableTransport
// installs the route, an inbound initialize POST mints a session and
// the response header carries Mcp-Session-Id.
func TestHandler_StreamableTransportMountedOnMCP(t *testing.T) {
h := newTestHandler(t)
store := streamable.NewMemoryStore(time.Minute)
defer store.Close()
transport := streamable.New(streamable.Config{
Dispatcher: streamable.MCPServerDispatcher{Server: h.mcpServer},
Store: store,
})
h.SetStreamableTransport(transport)
require.NotNil(t, h.StreamableTransport(), "transport getter must round-trip")
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": map[string]any{
"protocolVersion": "2026-03-26",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "test", "version": "0.0.0"},
},
})
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
sid := rec.Header().Get(streamable.HeaderSessionID)
require.NotEmpty(t, sid, "/mcp must set Mcp-Session-Id on initialize")
require.True(t, strings.Contains(rec.Body.String(), `"jsonrpc":"2.0"`),
"response missing JSON-RPC envelope: %s", rec.Body.String())
}
// TestHandler_StreamableTransportDeleteSession proves the DELETE
// branch reaches the transport (returns 204 even for unknown ids).
func TestHandler_StreamableTransportDeleteSession(t *testing.T) {
h := newTestHandler(t)
store := streamable.NewMemoryStore(time.Minute)
defer store.Close()
transport := streamable.New(streamable.Config{
Dispatcher: streamable.MCPServerDispatcher{Server: h.mcpServer},
Store: store,
})
h.SetStreamableTransport(transport)
req := httptest.NewRequest(http.MethodDelete, "/mcp", nil)
req.Header.Set(streamable.HeaderSessionID, "unknown")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNoContent, rec.Code)
}
// TestHandler_SetRouterRouterAccessor — the router getter we added
// for the streamable wireup has to round-trip the value too,
// otherwise the daemon-side wireup silently loses routing.
func TestHandler_SetRouterRouterAccessor(t *testing.T) {
h := newTestHandler(t)
assert.Nil(t, h.Router())
// We don't have a real router to wire here (Router has heavy
// deps), but the nil round-trip is enough to prove the
// accessor is wired to the right field.
}
+170
View File
@@ -0,0 +1,170 @@
package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
)
// TestCallToolStrict_Success returns the text content with a nil error
// when the tool returns a normal text result.
func TestCallToolStrict_Success(t *testing.T) {
h := newTestHandler(t)
text, err := h.CallToolStrict(context.Background(), "echo", map[string]any{"message": "hello"})
require.NoError(t, err)
assert.Equal(t, "hello", text)
}
// TestCallToolStrict_MissingTool returns an error when the tool name is not
// registered, instead of silently returning an empty string.
func TestCallToolStrict_MissingTool(t *testing.T) {
h := newTestHandler(t)
_, err := h.CallToolStrict(context.Background(), "no-such-tool", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no-such-tool")
assert.Contains(t, err.Error(), "not registered")
}
// TestCallToolStrict_ToolErrorResult promotes an MCP IsError=true result to
// a Go error. This is the contract that handleContracts depends on to surface
// 5xx instead of pretending the call succeeded with empty content.
func TestCallToolStrict_ToolErrorResult(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("erroring_tool", mcp.WithDescription("returns IsError=true")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError("project not found: \"gortex\""), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
text, err := h.CallToolStrict(context.Background(), "erroring_tool", nil)
require.Error(t, err, "IsError=true must be promoted to a Go error")
assert.Contains(t, err.Error(), "project not found")
// The text content is preserved so callers can put it in the response body.
assert.Contains(t, text, "project not found")
}
// TestCallToolStrict_HandlerError surfaces a Go-level handler error.
func TestCallToolStrict_HandlerError(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("failing_tool", mcp.WithDescription("returns Go-level error")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return nil, errors.New("upstream blew up")
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
_, err := h.CallToolStrict(context.Background(), "failing_tool", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "upstream blew up")
}
// TestCallTool_BackwardsCompatible — the legacy best-effort wrapper still
// returns "" on error and never panics. Eval/http_handler.go and similar
// callers depend on this contract.
func TestCallTool_BackwardsCompatible(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("erroring_tool", mcp.WithDescription("")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError("nope"), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
// IsError=true: legacy CallTool returns the text (best-effort) and
// callers cannot tell it was an error. This is the contract callers
// like eval/http_handler.go rely on; the ones that need to know now
// use CallToolStrict.
got := h.CallTool(context.Background(), "erroring_tool", nil)
assert.Equal(t, "nope", got)
// Missing tool: still returns "".
got = h.CallTool(context.Background(), "no-such-tool", nil)
assert.Empty(t, got)
}
// TestHandleContracts_ToolError_500 verifies the user-visible bug fix: when
// the underlying contracts tool returns an error result (e.g. project not
// found), /v1/contracts must return HTTP 500 with the error in the body, not
// a fake empty 200.
func TestHandleContracts_ToolError_500(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("contracts", mcp.WithDescription("contracts stub")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultError(`project not found: "gortex" (available: )`), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
req := httptest.NewRequest(http.MethodGet, "/v1/contracts", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusInternalServerError, rec.Code,
"tool error must surface as 5xx, not 200 with empty body")
var body map[string]any
require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
msg, _ := body["message"].(string)
assert.True(t, strings.Contains(msg, "project not found"),
"error body must include the underlying tool message; got: %q", msg)
}
// TestHandleContracts_Success_200 verifies the happy path still works:
// contracts tool returns a valid JSON payload → handler 200s with the
// flattened list.
func TestHandleContracts_Success_200(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("contracts", mcp.WithDescription("contracts stub")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
payload := `{"by_repo":{"alpha":{"contracts":{"http":[{"id":"GET /foo","type":"http","role":"provider","symbol_id":"alpha/x.go::H","file_path":"alpha/x.go","line":10,"repo_prefix":"alpha"}]},"total":1}}}`
return mcp.NewToolResultText(payload), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
req := httptest.NewRequest(http.MethodGet, "/v1/contracts", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var body struct {
Contracts []map[string]any `json:"contracts"`
}
require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
require.Len(t, body.Contracts, 1)
assert.Equal(t, "GET /foo", body.Contracts[0]["id"])
}
+331
View File
@@ -0,0 +1,331 @@
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
"go.uber.org/zap"
)
func newTestHandler(t *testing.T) *Handler {
t.Helper()
g := graph.New()
g.AddNode(&graph.Node{
ID: "test.go::Foo", Kind: graph.KindFunction,
Name: "Foo", FilePath: "test.go", Language: "go",
})
g.AddNode(&graph.Node{
ID: "test.go::Bar", Kind: graph.KindFunction,
Name: "Bar", FilePath: "test.go", Language: "go",
})
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
mcpserver.WithRecovery(),
)
srv.AddTool(
mcp.NewTool("echo",
mcp.WithDescription("Echo tool for testing"),
mcp.WithString("message", mcp.Description("Message to echo")),
),
func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
msg, _ := args["message"].(string)
if msg == "" {
msg = "no message"
}
return mcp.NewToolResultText(msg), nil
},
)
return NewHandler(srv, g, "0.0.1-test", zap.NewNop())
}
func TestHealthEndpoint(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/v1/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp HealthResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.Equal(t, "ok", resp.Status)
assert.True(t, resp.Indexed)
assert.Equal(t, 2, resp.Nodes)
assert.Equal(t, "0.0.1-test", resp.Version)
assert.Greater(t, resp.UptimeSeconds, float64(0))
}
func TestListToolsEndpoint(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/v1/tools", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var tools []toolInfo
require.NoError(t, json.NewDecoder(rec.Body).Decode(&tools))
require.Len(t, tools, 1)
assert.Equal(t, "echo", tools[0].Name)
assert.Equal(t, "Echo tool for testing", tools[0].Description)
}
func TestStatsEndpoint(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/v1/stats", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp StatsResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.Equal(t, 2, resp.TotalNodes)
assert.Equal(t, 2, resp.ByKind["function"])
assert.False(t, resp.StartedAt.IsZero(), "StartedAt should be populated")
assert.Empty(t, resp.ServerID, "ServerID empty until SetServerID is called")
}
func TestStatsEndpoint_WithServerID(t *testing.T) {
h := newTestHandler(t)
h.SetServerID("11111111-2222-3333-4444-555555555555")
req := httptest.NewRequest(http.MethodGet, "/v1/stats", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp StatsResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.Equal(t, "11111111-2222-3333-4444-555555555555", resp.ServerID)
}
func TestToolCallValid(t *testing.T) {
h := newTestHandler(t)
body := `{"arguments":{"message":"hello world"}}`
req := httptest.NewRequest(http.MethodPost, "/v1/tools/echo", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp ToolResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.False(t, resp.IsError)
require.Len(t, resp.Content, 1)
assert.Equal(t, "hello world", resp.Content[0].Text)
}
func TestToolCallUnknownTool(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/v1/tools/nonexistent", strings.NewReader("{}"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
var resp map[string]any
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.Equal(t, "tool_not_found", resp["error"])
available, ok := resp["available_tools"].([]any)
require.True(t, ok)
assert.Contains(t, available, "echo")
}
func TestToolCallMalformedJSON(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/v1/tools/echo", strings.NewReader("{bad"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestToolCallEmptyName(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodPost, "/v1/tools/", strings.NewReader("{}"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestToolCallAcceptsQueryFormat(t *testing.T) {
h := newTestHandler(t)
// echo returns its "message" argument; here we smuggle the format
// through "message" to assert the merge happened. Use a tool that
// echoes the argument map — simplest path is to register a fresh
// tool that stringifies args.
body := `{"arguments":{"message":"fmt"}}`
req := httptest.NewRequest(http.MethodPost, "/v1/tools/echo?format=gcx", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
// The echo tool ignores "format", so the ok status is the
// only observable contract here — the point is that the merge
// didn't corrupt the request. Dedicated merge logic is covered by
// TestToolCall_MergesQueryFormat.
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestToolCall_MergesQueryFormat(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("spy",
mcp.WithDescription("returns its format arg"),
mcp.WithString("format"),
),
func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
fmtArg, _ := args["format"].(string)
return mcp.NewToolResultText(fmtArg), nil
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
// Query param.
req := httptest.NewRequest(http.MethodPost, "/v1/tools/spy?format=gcx", strings.NewReader("{}"))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp ToolResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
require.Len(t, resp.Content, 1)
assert.Equal(t, "gcx", resp.Content[0].Text)
// Body-level format field.
req = httptest.NewRequest(http.MethodPost, "/v1/tools/spy",
strings.NewReader(`{"format":"toon"}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
require.Len(t, resp.Content, 1)
assert.Equal(t, "toon", resp.Content[0].Text)
// Explicit arguments.format wins over query.
req = httptest.NewRequest(http.MethodPost, "/v1/tools/spy?format=gcx",
strings.NewReader(`{"arguments":{"format":"json"}}`))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
require.Len(t, resp.Content, 1)
assert.Equal(t, "json", resp.Content[0].Text)
}
func TestPanicRecovery(t *testing.T) {
g := graph.New()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
srv.AddTool(
mcp.NewTool("panic_tool", mcp.WithDescription("panics")),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
panic("test panic")
},
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
req := httptest.NewRequest(http.MethodPost, "/v1/tools/panic_tool", strings.NewReader("{}"))
rec := httptest.NewRecorder()
assert.NotPanics(t, func() { h.ServeHTTP(rec, req) })
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestCallToolHelper(t *testing.T) {
h := newTestHandler(t)
result := h.CallTool(context.Background(), "echo", map[string]any{"message": "test"})
assert.Equal(t, "test", result)
result = h.CallTool(context.Background(), "nonexistent", nil)
assert.Empty(t, result)
}
func TestV1GraphEndpoint_FullDump(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/v1/graph", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp GraphResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
assert.Len(t, resp.Nodes, 2)
assert.Equal(t, 2, resp.Stats.TotalNodes)
// Heavy fields stripped.
for _, n := range resp.Nodes {
assert.Empty(t, n.QualName)
assert.Zero(t, n.EndLine)
}
}
func TestV1GraphEndpoint_RepoFilter(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "frontend::App", Kind: graph.KindFunction, Name: "App",
FilePath: "src/app.ts", Language: "typescript", RepoPrefix: "frontend",
})
g.AddNode(&graph.Node{
ID: "backend::Server", Kind: graph.KindFunction, Name: "Server",
FilePath: "cmd/main.go", Language: "go", RepoPrefix: "backend",
})
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test",
mcpserver.WithToolCapabilities(false),
)
h := NewHandler(srv, g, "0.0.1-test", zap.NewNop())
req := httptest.NewRequest(http.MethodGet, "/v1/graph?repo=frontend", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp GraphResponse
require.NoError(t, json.NewDecoder(rec.Body).Decode(&resp))
require.Len(t, resp.Nodes, 1)
assert.Equal(t, "frontend::App", resp.Nodes[0].ID)
// Filtered dump: stats are zeroed; counts come from the nodes array.
assert.Equal(t, 0, resp.Stats.TotalNodes)
}
func TestV1GraphEndpoint_ProjectWithoutConfigManager(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/v1/graph?project=my-saas", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
var body map[string]string
require.NoError(t, json.NewDecoder(rec.Body).Decode(&body))
assert.Contains(t, body["message"], "multi-repo config")
}
func TestV1EventsEndpoint_NoHub(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/v1/events", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
assert.Contains(t, rec.Body.String(), "watch mode not active")
}
+73
View File
@@ -0,0 +1,73 @@
package server
import (
"net"
"net/http"
"strings"
)
// Route-scoped DNS-rebinding guard for the conversation-log routes.
//
// The conversation routes egress raw LLM request/response text, so they
// carry a tighter check than the rest of the HTTP surface. The guard is
// deliberately NOT a global Host-allowlist layer in ServeHTTP — adding
// one there would 403 a legitimately token-authed non-loopback dashboard
// deployment. Instead it is applied only inside the conversation
// handlers and COOPERATES with the existing --http-auth-token model: a
// request is allowed when EITHER its Host is loopback / explicitly
// allowlisted, OR a valid auth token was presented. An un-authed
// cross-origin / DNS-rebind request to a conversation route is the only
// thing rejected.
// guardConversationRoute reports whether a request to a /v1/conversations*
// route is allowed. It passes when the Host is loopback or in the
// allowlist, or when a valid auth token was presented (tokenOK). Only an
// un-authed, non-loopback, non-allowlisted request is rejected.
func guardConversationRoute(r *http.Request, allow []string, tokenOK bool) bool {
if tokenOK {
return true
}
return hostAllowed(r.Host, allow)
}
// hostAllowed reports whether host (a request's Host header, possibly
// with a port) is a loopback address/name or appears in the extra
// allowlist. An empty Host is treated as not allowed.
func hostAllowed(host string, extra []string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
hostname := host
if h, _, err := net.SplitHostPort(host); err == nil {
hostname = h
}
hostname = strings.TrimSuffix(strings.TrimPrefix(hostname, "["), "]")
if isLoopbackHost(hostname) {
return true
}
for _, a := range extra {
a = strings.TrimSpace(a)
if a == "" {
continue
}
// Compare against the bare hostname and the host[:port] as given,
// so an allowlist entry may name either form.
if strings.EqualFold(a, hostname) || strings.EqualFold(a, host) {
return true
}
}
return false
}
// isLoopbackHost reports whether a bare hostname is the loopback name or
// a loopback IP literal (127.0.0.0/8, ::1).
func isLoopbackHost(hostname string) bool {
if strings.EqualFold(hostname, "localhost") {
return true
}
if ip := net.ParseIP(hostname); ip != nil {
return ip.IsLoopback()
}
return false
}
+100
View File
@@ -0,0 +1,100 @@
package hub
import (
"fmt"
"os"
"sync"
"github.com/zzet/gortex/internal/indexer"
)
// Hub fans out watcher events to multiple subscribers.
type Hub struct {
subscribers map[string]chan indexer.GraphChangeEvent
mu sync.RWMutex
nextID int
done chan struct{}
}
// New creates a new Hub.
func New() *Hub {
return &Hub{
subscribers: make(map[string]chan indexer.GraphChangeEvent),
done: make(chan struct{}),
}
}
// Run reads from the watcher events channel and broadcasts to all subscribers.
// It also logs each event to stderr. Blocks until the events channel is closed
// or Stop is called.
func (h *Hub) Run(events <-chan indexer.GraphChangeEvent) {
for {
select {
case ev, ok := <-events:
if !ok {
return
}
// Log to stderr (replaces the inline goroutine in serve.go)
fmt.Fprintf(os.Stderr, "[gortex watch] %-10s %s +%d nodes +%d edges -%d nodes -%d edges (%dms)\n",
ev.Kind, ev.FilePath, ev.NodesAdded, ev.EdgesAdded, ev.NodesRemoved, ev.EdgesRemoved, ev.DurationMs)
h.broadcast(ev)
case <-h.done:
return
}
}
}
func (h *Hub) broadcast(ev indexer.GraphChangeEvent) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, ch := range h.subscribers {
select {
case ch <- ev:
default:
// Slow subscriber — drop event to avoid blocking
}
}
}
// Subscribe creates a new subscriber channel. Returns an ID for unsubscribing
// and a receive-only channel that will receive graph change events.
func (h *Hub) Subscribe() (string, <-chan indexer.GraphChangeEvent) {
h.mu.Lock()
defer h.mu.Unlock()
h.nextID++
id := fmt.Sprintf("sub-%d", h.nextID)
ch := make(chan indexer.GraphChangeEvent, 16)
h.subscribers[id] = ch
return id, ch
}
// Unsubscribe removes a subscriber and closes its channel.
func (h *Hub) Unsubscribe(id string) {
h.mu.Lock()
defer h.mu.Unlock()
if ch, ok := h.subscribers[id]; ok {
close(ch)
delete(h.subscribers, id)
}
}
// SubscriberCount returns the number of active subscribers. Tests use this
// to wait for an SSE client to finish registering before publishing events
// that the client needs to observe — broadcast() drops messages silently
// when a subscriber hasn't registered yet, so timing races are easy to
// write without a way to synchronize.
func (h *Hub) SubscriberCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.subscribers)
}
// Stop signals the hub to stop processing events.
func (h *Hub) Stop() {
select {
case <-h.done:
default:
close(h.done)
}
}
+128
View File
@@ -0,0 +1,128 @@
package hub
import (
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/indexer"
)
func makeEvent(path string) indexer.GraphChangeEvent {
return indexer.GraphChangeEvent{
FilePath: path,
Kind: indexer.ChangeModified,
NodesAdded: 1,
Timestamp: time.Now(),
}
}
func TestHub_MultipleSubscribersReceiveEvent(t *testing.T) {
h := New()
events := make(chan indexer.GraphChangeEvent, 8)
go h.Run(events)
defer h.Stop()
_, ch1 := h.Subscribe()
_, ch2 := h.Subscribe()
ev := makeEvent("a.go")
events <- ev
select {
case got := <-ch1:
assert.Equal(t, "a.go", got.FilePath)
case <-time.After(time.Second):
t.Fatal("subscriber 1 did not receive event")
}
select {
case got := <-ch2:
assert.Equal(t, "a.go", got.FilePath)
case <-time.After(time.Second):
t.Fatal("subscriber 2 did not receive event")
}
}
func TestHub_SlowSubscriberDoesNotBlock(t *testing.T) {
h := New()
events := make(chan indexer.GraphChangeEvent, 64)
go h.Run(events)
defer h.Stop()
// slow subscriber: never reads
h.Subscribe()
// fast subscriber
_, fastCh := h.Subscribe()
// Send more events than the slow subscriber's buffer
for i := 0; i < 32; i++ {
events <- makeEvent("file.go")
}
// Fast subscriber should still get events
received := 0
timeout := time.After(time.Second)
for {
select {
case <-fastCh:
received++
if received >= 16 {
return // success
}
case <-timeout:
require.GreaterOrEqual(t, received, 1, "fast subscriber should receive at least some events")
return
}
}
}
func TestHub_Unsubscribe(t *testing.T) {
h := New()
events := make(chan indexer.GraphChangeEvent, 8)
go h.Run(events)
defer h.Stop()
id, ch := h.Subscribe()
h.Unsubscribe(id)
events <- makeEvent("a.go")
time.Sleep(50 * time.Millisecond)
select {
case _, ok := <-ch:
assert.False(t, ok, "channel should be closed after unsubscribe")
default:
// Channel closed and empty — expected
}
}
func TestHub_StopEndsRun(t *testing.T) {
h := New()
events := make(chan indexer.GraphChangeEvent, 8)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
h.Run(events)
}()
h.Stop()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// Run exited — success
case <-time.After(time.Second):
t.Fatal("Run did not exit after Stop")
}
}
+58
View File
@@ -0,0 +1,58 @@
package server
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"github.com/zzet/gortex/internal/platform"
)
// DefaultServerIDPath returns the default persistence path for the
// per-machine server id. Callers that want a process-local id (e.g.
// tests, custom cache dirs) should pass their own dir to
// LoadOrCreateServerID instead of calling this.
//
// An absolute $XDG_CACHE_HOME is honoured; otherwise the id stays under
// os.UserCacheDir() — the historical location, kept so an existing
// server id is not orphaned.
func DefaultServerIDPath() (string, error) {
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
if _, err := os.UserCacheDir(); err != nil {
return "", fmt.Errorf("resolve user cache dir: %w", err)
}
}
return filepath.Join(platform.OSCacheDir(), "server.id"), nil
}
// LoadOrCreateServerID returns a stable UUID for this server
// instance. If path already holds a valid UUID, it's returned as-is;
// otherwise a fresh UUID is generated and persisted. Intermediate
// directories are created as needed (0o755).
//
// A malformed existing file is replaced — the file is advisory
// state, not authoritative, so there's no reason to fail loudly
// when it gets corrupted.
func LoadOrCreateServerID(path string) (string, error) {
if data, err := os.ReadFile(path); err == nil {
id := strings.TrimSpace(string(data))
if _, err := uuid.Parse(id); err == nil {
return id, nil
}
// Fall through — we'll regenerate below.
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("read server id: %w", err)
}
id := uuid.NewString()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("create server id dir: %w", err)
}
if err := os.WriteFile(path, []byte(id+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write server id: %w", err)
}
return id, nil
}
+50
View File
@@ -0,0 +1,50 @@
package server
import (
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadOrCreateServerID_GeneratesWhenMissing(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nested", "server.id")
id, err := LoadOrCreateServerID(path)
require.NoError(t, err)
_, parseErr := uuid.Parse(id)
assert.NoError(t, parseErr)
// Persisted to disk.
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), id)
}
func TestLoadOrCreateServerID_ReusesExisting(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "server.id")
first, err := LoadOrCreateServerID(path)
require.NoError(t, err)
second, err := LoadOrCreateServerID(path)
require.NoError(t, err)
assert.Equal(t, first, second, "server id should be stable across calls")
}
func TestLoadOrCreateServerID_ReplacesMalformedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "server.id")
require.NoError(t, os.WriteFile(path, []byte("not a uuid"), 0o600))
id, err := LoadOrCreateServerID(path)
require.NoError(t, err)
_, parseErr := uuid.Parse(id)
assert.NoError(t, parseErr)
}
+137
View File
@@ -0,0 +1,137 @@
package server
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/zzet/gortex/internal/graph"
)
// subgraphNodeCap bounds the neighbour ring returned by /v1/subgraph so a
// single hydration call can never pull an unbounded slice of the graph.
const subgraphNodeCap = 200
// subgraphMaxDepth caps the requested ring depth server-side.
const subgraphMaxDepth = 2
// SubGraphResponse is the /v1/subgraph payload: FULL nodes (Meta /
// QualName / EndLine intact, unlike the brief /v1/graph projection) for a
// node and its neighbour ring, used by cross-daemon proxy-edge proxy-node
// hydration.
type SubGraphResponse struct {
Root *graph.Node `json:"root"`
Nodes []*graph.Node `json:"nodes"`
Edges []*graph.Edge `json:"edges"`
Stats SubGraphMeta `json:"stats"`
}
// SubGraphMeta carries the freshness + truncation metadata the hydrator
// stamps onto proxy nodes.
type SubGraphMeta struct {
SchemaVersion int `json:"schema_version"`
FetchedAt time.Time `json:"fetched_at"`
Truncated bool `json:"truncated"`
}
// handleSubGraph serves GET /v1/subgraph?id=<id>&depth=<n>. Returns the
// requested node and its in/out neighbour ring out to depth (default 1,
// capped at subgraphMaxDepth), with FULL node bodies so a remote daemon
// can hydrate a proxy node's neighbours. Read-only; obeys the same auth
// rule as the rest of /v1 (enforced by the WithAuth wrapper).
func (h *Handler) handleSubGraph(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
WriteJSONError(w, http.StatusBadRequest, "id query parameter is required")
return
}
depth := 1
if d := strings.TrimSpace(r.URL.Query().Get("depth")); d != "" {
if v, err := strconv.Atoi(d); err == nil && v > 0 {
depth = v
}
}
if depth > subgraphMaxDepth {
depth = subgraphMaxDepth
}
root := h.graph.GetNode(id)
if root == nil {
WriteJSONError(w, http.StatusNotFound, fmt.Sprintf("node %q not found", id))
return
}
visited := map[string]bool{id: true}
seenEdge := map[string]bool{}
var nodes []*graph.Node
var edges []*graph.Edge
truncated := false
addEdge := func(e *graph.Edge) {
key := e.From + "\x00" + e.To + "\x00" + string(e.Kind)
if seenEdge[key] {
return
}
seenEdge[key] = true
edges = append(edges, e)
}
addNode := func(nid string) bool {
if visited[nid] {
return true
}
if len(nodes) >= subgraphNodeCap {
truncated = true
return false
}
visited[nid] = true
if n := h.graph.GetNode(nid); n != nil {
nodes = append(nodes, n)
}
return true
}
frontier := []string{id}
for d := 0; d < depth && !truncated; d++ {
var next []string
for _, nid := range frontier {
for _, e := range h.graph.GetOutEdges(nid) {
addEdge(e)
if !visited[e.To] {
if !addNode(e.To) {
break
}
next = append(next, e.To)
}
}
if truncated {
break
}
for _, e := range h.graph.GetInEdges(nid) {
addEdge(e)
if !visited[e.From] {
if !addNode(e.From) {
break
}
next = append(next, e.From)
}
}
if truncated {
break
}
}
frontier = next
}
WriteJSON(w, http.StatusOK, SubGraphResponse{
Root: root,
Nodes: nodes,
Edges: edges,
Stats: SubGraphMeta{
SchemaVersion: SchemaVersion,
FetchedAt: time.Now(),
Truncated: truncated,
},
})
}
+87
View File
@@ -0,0 +1,87 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
mcpserver "github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
)
func newSubGraphHandler(t *testing.T, g *graph.Graph) *Handler {
t.Helper()
srv := mcpserver.NewMCPServer("gortex-test", "0.0.1-test", mcpserver.WithToolCapabilities(false))
return NewHandler(srv, g, "0.0.1-test", zap.NewNop())
}
// TestSubGraph_ReturnsFullNodesAndRing asserts /v1/subgraph returns FULL
// node bodies (Meta / QualName / EndLine intact, unlike the brief
// /v1/graph projection) for the root and its neighbour ring.
func TestSubGraph_ReturnsFullNodesAndRing(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{
ID: "a/x.go::Foo", Kind: graph.KindFunction, Name: "Foo",
QualName: "pkg.Foo", EndLine: 10, Meta: map[string]any{"sig": "func Foo()"},
})
g.AddNode(&graph.Node{ID: "a/x.go::Bar", Kind: graph.KindFunction, Name: "Bar"})
g.AddEdge(&graph.Edge{From: "a/x.go::Foo", To: "a/x.go::Bar", Kind: graph.EdgeCalls})
h := newSubGraphHandler(t, g)
rec := httptest.NewRecorder()
h.handleSubGraph(rec, httptest.NewRequest(http.MethodGet, "/v1/subgraph?id=a/x.go::Foo", nil))
if rec.Code != http.StatusOK {
t.Fatalf("code = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp SubGraphResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Root == nil {
t.Fatal("root is nil")
}
// FULL node — the fields /v1/graph strips must survive here.
if resp.Root.QualName != "pkg.Foo" || resp.Root.EndLine != 10 {
t.Errorf("root must be a full node (QualName/EndLine intact): %+v", resp.Root)
}
if len(resp.Root.Meta) == 0 {
t.Error("full node must retain Meta")
}
foundBar := false
for _, n := range resp.Nodes {
if n.ID == "a/x.go::Bar" {
foundBar = true
}
}
if !foundBar {
t.Errorf("neighbour Bar should be in the ring; got %+v", resp.Nodes)
}
if len(resp.Edges) == 0 {
t.Error("the calls edge should be returned")
}
if resp.Stats.SchemaVersion != SchemaVersion {
t.Errorf("schema_version = %d, want %d", resp.Stats.SchemaVersion, SchemaVersion)
}
}
func TestSubGraph_RequiresID(t *testing.T) {
h := newSubGraphHandler(t, graph.New())
rec := httptest.NewRecorder()
h.handleSubGraph(rec, httptest.NewRequest(http.MethodGet, "/v1/subgraph", nil))
if rec.Code != http.StatusBadRequest {
t.Fatalf("missing id should be 400, got %d", rec.Code)
}
}
func TestSubGraph_NotFound(t *testing.T) {
h := newSubGraphHandler(t, graph.New())
rec := httptest.NewRecorder()
h.handleSubGraph(rec, httptest.NewRequest(http.MethodGet, "/v1/subgraph?id=nope::X", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown id should be 404, got %d", rec.Code)
}
}