chore: import upstream snapshot with attribution
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 => ({"&":"&","<":"<",">":">"}[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>
|
||||
`
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user