zzet--gortex
a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
411 行
14 KiB
Go
411 行
14 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
)
|
|
|
|
// freshnessRiderFor returns a small structured freshness block for a
|
|
// file-reading tool whose target file has changed on disk since it was
|
|
// indexed — so an agent reading a just-edited file sees, inline, that the
|
|
// graph view may lag the working tree. It returns nil (no rider, zero
|
|
// extra tokens) for the overwhelmingly common fresh case, for non-file
|
|
// tools, and in multi-repo mode (where the legacy single-indexer's
|
|
// staleness signal is all-noise — per-repo watchers own freshness there).
|
|
//
|
|
// The check is O(1): one map lookup + one stat on the single file the
|
|
// tool targets, only for the handful of read/source tools.
|
|
func (s *Server) freshnessRiderFor(toolName string, req mcp.CallToolRequest) map[string]any {
|
|
if s.indexer == nil && s.multiIndexer == nil {
|
|
return nil
|
|
}
|
|
if os.Getenv("GORTEX_NO_FRESHNESS_RIDER") == "1" {
|
|
return nil
|
|
}
|
|
raw := targetRepoFileRaw(toolName, req)
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
// Route the target to its OWNING indexer. In multi-repo mode this is the
|
|
// per-repo sub-indexer that tracks the file's own mtimes / SHA — so the
|
|
// staleness signal is reclaimed in the flagship deployment instead of
|
|
// being suppressed wholesale. In single-repo mode it is the lone indexer.
|
|
owner, repoRel := s.indexerForRel(raw)
|
|
if owner == nil || repoRel == "" {
|
|
return nil
|
|
}
|
|
state := owner.TrackedFileState(repoRel)
|
|
mismatch := s.detectWorktreeMismatch()
|
|
|
|
// Extractor-version staleness: which languages' extractors this binary
|
|
// bumped since the OWNING repo was indexed. Fetched alongside the index
|
|
// state so a graph indexed by an older extractor is flagged even when the
|
|
// touched file's content is unchanged — per-language, so the advisory
|
|
// names the exact languages to reindex rather than implying a full rebuild.
|
|
var indexState graph.RepoIndexState
|
|
var haveState bool
|
|
var staleLangs []string
|
|
if r, ok := graph.Store(s.graph).(graph.RepoIndexStateReader); ok {
|
|
if st, found, _ := r.GetRepoIndexState(owner.RepoPrefix()); found {
|
|
indexState, haveState = st, true
|
|
staleLangs = indexer.ExtractorVersionStaleLangs(st.ExtractorVersions)
|
|
}
|
|
}
|
|
|
|
// Whole-index "frozen" banner: when the file watcher is degraded (inotify
|
|
// or FD exhaustion) the graph may silently lag every live edit, not just
|
|
// this file — a condition distinct from per-file staleness. Surface it even
|
|
// when the targeted file itself looks fresh.
|
|
frozen := s.watchDegradedReason()
|
|
|
|
if state == indexer.FileFresh && !mismatch && len(staleLangs) == 0 && frozen == "" {
|
|
return nil
|
|
}
|
|
out := map[string]any{"file": repoRel}
|
|
if frozen != "" {
|
|
out["index_frozen"] = true
|
|
out["index_frozen_hint"] = frozen
|
|
}
|
|
if p := owner.RepoPrefix(); p != "" {
|
|
out["repo"] = p
|
|
}
|
|
switch state {
|
|
case indexer.FileStale:
|
|
out["stale"] = true
|
|
out["hint"] = "this file changed on disk since it was last indexed; the graph view may lag the working tree"
|
|
if haveState {
|
|
if indexState.IndexedSHA != "" {
|
|
out["indexed_sha"] = shortFreshSHA(indexState.IndexedSHA)
|
|
}
|
|
if indexState.Dirty {
|
|
out["working_tree_dirty_at_index"] = true
|
|
}
|
|
}
|
|
case indexer.FileMissing:
|
|
out["missing"] = true
|
|
out["hint"] = "this file is recorded in the graph but no longer exists on disk; it was deleted or moved since indexing"
|
|
}
|
|
if len(staleLangs) > 0 {
|
|
out["extractor_stale_langs"] = staleLangs
|
|
if fileLang := indexer.ExtractorLangForFile(repoRel); fileLang != "" && slices.Contains(staleLangs, fileLang) {
|
|
out["extractor_stale_hint"] = "this file's language extractor was upgraded since indexing; reindex to pick up the newer extraction (gortex index .)"
|
|
}
|
|
}
|
|
if mismatch {
|
|
out["worktree_mismatch"] = true
|
|
out["worktree_hint"] = "the working directory is a linked git worktree the indexed graph does not cover — results reflect another checkout"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// watchDegradedReason returns the file watcher's degraded reason (inotify / FD
|
|
// exhaustion) when the index may be silently frozen, or "" when watching is
|
|
// healthy or unavailable. Read through an interface assertion so the rider
|
|
// stays decoupled from the concrete *indexer.Watcher behind the server's
|
|
// watcher field.
|
|
func (s *Server) watchDegradedReason() string {
|
|
if s.watcher == nil {
|
|
return ""
|
|
}
|
|
if dr, ok := s.watcher.(interface{ DegradedReason() string }); ok {
|
|
return dr.DegradedReason()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// indexerForRel routes a graph file path (as it appears in tool output or
|
|
// requests — repo-prefixed in multi-repo mode) to the per-repo indexer that
|
|
// owns it, returning that indexer and the repo-relative subpath its mtime keys
|
|
// use. In single-repo mode it returns the lone indexer with the configured
|
|
// prefix stripped. Returns (nil, "") when the path maps to no tracked repo.
|
|
func (s *Server) indexerForRel(graphPath string) (*indexer.Indexer, string) {
|
|
graphPath = filepath.ToSlash(graphPath)
|
|
if s.multiIndexer == nil {
|
|
if s.indexer == nil {
|
|
return nil, ""
|
|
}
|
|
rel := graphPath
|
|
if p := s.indexer.RepoPrefix(); p != "" {
|
|
rel = strings.TrimPrefix(rel, p+"/")
|
|
}
|
|
return s.indexer, rel
|
|
}
|
|
abs := s.multiIndexer.ResolveFilePath(graphPath)
|
|
if abs == "" {
|
|
return nil, ""
|
|
}
|
|
owner, prefix := s.multiIndexer.IndexerForFile(abs)
|
|
if owner == nil {
|
|
return nil, ""
|
|
}
|
|
return owner, strings.TrimPrefix(graphPath, prefix+"/")
|
|
}
|
|
|
|
// detectWorktreeMismatch reports (once per server, cached) whether the
|
|
// current working directory is a linked git worktree that the indexed graph
|
|
// does not cover — i.e. the agent is working in a worktree but the graph
|
|
// reflects a different checkout, so its results may not match the files on
|
|
// disk. Single-repo only; multi-repo routing owns its own worktree scoping.
|
|
func (s *Server) detectWorktreeMismatch() bool {
|
|
s.worktreeMismatchOnce.Do(func() {
|
|
if s.indexer == nil || s.multiIndexer != nil {
|
|
return
|
|
}
|
|
cwd, err := os.Getwd()
|
|
if err != nil || cwd == "" {
|
|
return
|
|
}
|
|
if !indexer.ResolveWorktree(cwd).IsWorktree {
|
|
return // not a linked worktree — nothing to warn about
|
|
}
|
|
root := s.indexer.RootPath()
|
|
if root == "" {
|
|
return
|
|
}
|
|
// The cwd is a linked worktree. If the indexed root does not contain
|
|
// it, the graph reflects another checkout.
|
|
cwdResolved, _ := filepath.EvalSymlinks(cwd)
|
|
rootResolved, _ := filepath.EvalSymlinks(root)
|
|
if cwdResolved == "" {
|
|
cwdResolved = cwd
|
|
}
|
|
if rootResolved == "" {
|
|
rootResolved = root
|
|
}
|
|
if !pathWithin(cwdResolved, rootResolved) {
|
|
s.worktreeMismatch = true
|
|
}
|
|
})
|
|
return s.worktreeMismatch
|
|
}
|
|
|
|
// pathWithin reports whether child is equal to or nested under parent, on a
|
|
// slash-segment boundary (so /a/bc is not "within" /a/b).
|
|
func pathWithin(child, parent string) bool {
|
|
child = filepath.Clean(child)
|
|
parent = filepath.Clean(parent)
|
|
if child == parent {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(child, parent+string(filepath.Separator))
|
|
}
|
|
|
|
// targetRepoFileRaw extracts the single file a read tool targets, verbatim
|
|
// (no prefix stripping) so the caller can route it to its owning repo. Returns
|
|
// "" for tools that are not file-scoped.
|
|
func targetRepoFileRaw(toolName string, req mcp.CallToolRequest) string {
|
|
var raw string
|
|
switch toolName {
|
|
case "read_file", "get_file_summary", "get_editing_context":
|
|
raw = req.GetString("path", "")
|
|
case "get_symbol_source", "get_symbol":
|
|
id := req.GetString("id", "")
|
|
if i := strings.Index(id, "::"); i >= 0 {
|
|
raw = id[:i]
|
|
}
|
|
default:
|
|
return ""
|
|
}
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
return filepath.ToSlash(raw)
|
|
}
|
|
|
|
// targetRepoRelFile extracts the repo-relative path of the single file a
|
|
// read tool targets, or "" when the tool is not file-scoped. A leading
|
|
// repo prefix is stripped so the result matches the indexer's mtime keys;
|
|
// a path that does not match is simply reported not-stale (an unknown key
|
|
// reads as fresh), so imperfect normalization is safe.
|
|
func targetRepoRelFile(toolName string, req mcp.CallToolRequest, prefix string) string {
|
|
raw := targetRepoFileRaw(toolName, req)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if prefix != "" {
|
|
raw = strings.TrimPrefix(raw, prefix+"/")
|
|
}
|
|
return raw
|
|
}
|
|
|
|
// isFreshnessListTool reports whether a tool returns a list of graph hits
|
|
// whose underlying files should be swept for on-disk drift / deletion.
|
|
func isFreshnessListTool(name string) bool {
|
|
switch name {
|
|
case "search_symbols", "find_usages", "smart_context", "get_callers":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// freshnessPathKeys are the JSON keys whose string value names a file across
|
|
// the list-shaped tool results (search_symbols / find_usages / smart_context /
|
|
// get_callers).
|
|
var freshnessPathKeys = map[string]bool{
|
|
"file": true, "file_path": true, "path": true, "filePath": true,
|
|
}
|
|
|
|
// maxFreshnessSweep bounds how many distinct files one list result is swept
|
|
// for, so a pathological response can't turn the rider into a hot loop.
|
|
const maxFreshnessSweep = 256
|
|
|
|
// decorateListResultWithFreshness sweeps the file paths a list result
|
|
// references and attaches a `freshness` block naming the hits that are stale
|
|
// or missing on disk — each carrying its owning repo prefix plus (for stale)
|
|
// the indexed SHA / dirty provenance, so the signal is per-repo rather than a
|
|
// flat workspace banner. Only JSON-object payloads are touched; GCX / TOON /
|
|
// array wire formats the caller opted into pass through unchanged, and a clean
|
|
// sweep adds nothing.
|
|
func (s *Server) decorateListResultWithFreshness(res *mcp.CallToolResult) *mcp.CallToolResult {
|
|
if res == nil || (s.indexer == nil && s.multiIndexer == nil) {
|
|
return res
|
|
}
|
|
if os.Getenv("GORTEX_NO_FRESHNESS_RIDER") == "1" {
|
|
return res
|
|
}
|
|
text, ok := singleTextContent(res)
|
|
if !ok || text == "" {
|
|
return res
|
|
}
|
|
var asObj map[string]any
|
|
if json.Unmarshal([]byte(text), &asObj) != nil {
|
|
return res // GCX / TOON / array — not a JSON object
|
|
}
|
|
if _, exists := asObj["freshness"]; exists {
|
|
return res
|
|
}
|
|
|
|
var staleFiles, missingFiles []map[string]any
|
|
for _, p := range collectGraphFilePaths(asObj) {
|
|
owner, repoRel := s.indexerForRel(p)
|
|
if owner == nil || repoRel == "" {
|
|
continue
|
|
}
|
|
switch owner.TrackedFileState(repoRel) {
|
|
case indexer.FileStale:
|
|
staleFiles = append(staleFiles, s.freshFileProvenance(owner, repoRel, true))
|
|
case indexer.FileMissing:
|
|
missingFiles = append(missingFiles, s.freshFileProvenance(owner, repoRel, false))
|
|
}
|
|
}
|
|
if len(staleFiles) == 0 && len(missingFiles) == 0 {
|
|
return res
|
|
}
|
|
rider := map[string]any{}
|
|
if len(staleFiles) > 0 {
|
|
rider["stale_files"] = staleFiles
|
|
rider["stale_hint"] = "some result files changed on disk since indexing; re-read them before acting on the graph view"
|
|
}
|
|
if len(missingFiles) > 0 {
|
|
rider["missing_files"] = missingFiles
|
|
rider["missing_hint"] = "some result files are recorded in the graph but no longer on disk; they were deleted or moved since indexing"
|
|
}
|
|
asObj["freshness"] = rider
|
|
body, err := json.Marshal(asObj)
|
|
if err != nil {
|
|
return res
|
|
}
|
|
return rebuildTextResult(res, string(body))
|
|
}
|
|
|
|
// freshFileProvenance builds one stale/missing entry: the repo-relative file,
|
|
// its owning repo prefix, and — when withSHA — the repo's indexed SHA and
|
|
// working-tree-dirty flag. This per-repo provenance is the multi-repo upgrade
|
|
// over a single flat staleness banner.
|
|
func (s *Server) freshFileProvenance(owner *indexer.Indexer, repoRel string, withSHA bool) map[string]any {
|
|
entry := map[string]any{"file": repoRel}
|
|
if p := owner.RepoPrefix(); p != "" {
|
|
entry["repo"] = p
|
|
}
|
|
if withSHA {
|
|
if r, ok := graph.Store(s.graph).(graph.RepoIndexStateReader); ok {
|
|
if st, found, _ := r.GetRepoIndexState(owner.RepoPrefix()); found {
|
|
if st.IndexedSHA != "" {
|
|
entry["indexed_sha"] = shortFreshSHA(st.IndexedSHA)
|
|
}
|
|
if st.Dirty {
|
|
entry["working_tree_dirty_at_index"] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return entry
|
|
}
|
|
|
|
// collectGraphFilePaths walks a decoded list result and gathers the distinct
|
|
// graph-relative file paths referenced under the common path keys, bounded by
|
|
// maxFreshnessSweep. Absolute paths and directory values are skipped /
|
|
// harmless — they resolve to no tracked file.
|
|
func collectGraphFilePaths(v any) []string {
|
|
var out []string
|
|
seen := make(map[string]bool)
|
|
var walk func(any)
|
|
walk = func(node any) {
|
|
if len(out) >= maxFreshnessSweep {
|
|
return
|
|
}
|
|
switch t := node.(type) {
|
|
case map[string]any:
|
|
for k, val := range t {
|
|
if sv, ok := val.(string); ok {
|
|
if freshnessPathKeys[k] && sv != "" && !filepath.IsAbs(sv) && !seen[sv] {
|
|
seen[sv] = true
|
|
out = append(out, sv)
|
|
}
|
|
continue
|
|
}
|
|
walk(val)
|
|
}
|
|
case []any:
|
|
for _, e := range t {
|
|
walk(e)
|
|
}
|
|
}
|
|
}
|
|
walk(v)
|
|
return out
|
|
}
|
|
|
|
// decorateResultWithFreshness attaches the freshness rider to a JSON-object
|
|
// tool response under the "freshness" key. Non-JSON-object payloads
|
|
// (GCX / TOON / arrays) are left untouched — a best-effort hint must never
|
|
// reshape a compact wire format the caller opted into.
|
|
func decorateResultWithFreshness(res *mcp.CallToolResult, rider map[string]any) *mcp.CallToolResult {
|
|
if len(rider) == 0 {
|
|
return res
|
|
}
|
|
text, ok := singleTextContent(res)
|
|
if !ok || text == "" {
|
|
return res
|
|
}
|
|
var asObj map[string]any
|
|
if json.Unmarshal([]byte(text), &asObj) != nil {
|
|
return res
|
|
}
|
|
if _, exists := asObj["freshness"]; exists {
|
|
return res
|
|
}
|
|
asObj["freshness"] = rider
|
|
body, err := json.Marshal(asObj)
|
|
if err != nil {
|
|
return res
|
|
}
|
|
return rebuildTextResult(res, string(body))
|
|
}
|
|
|
|
// shortFreshSHA trims a git SHA to 12 chars for the rider.
|
|
func shortFreshSHA(sha string) string {
|
|
if len(sha) > 12 {
|
|
return sha[:12]
|
|
}
|
|
return sha
|
|
}
|