package mcp import ( "context" "encoding/json" "fmt" "path/filepath" "sort" "strings" "time" "github.com/mark3labs/mcp-go/mcp" toon "github.com/toon-format/toon-go" "go.uber.org/zap" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/search/rerank" "github.com/zzet/gortex/internal/telemetry" ) // minTierParamDescription is the `min_tier` parameter description shared by // every edge-returning tool. Mentioning the tier vocabulary inline lets agents // pick an appropriate filter without consulting external docs. const minTierParamDescription = "Filter edges by minimum confidence tier. " + "Values (highest to lowest): lsp_resolved (compiler-verified), " + "lsp_dispatch (interface→impl via semantic provider), " + "ast_resolved (tree-sitter direct match), " + "ast_inferred (type heuristic), " + "text_matched (name-only). When omitted, text_matched edges are " + "auto-suppressed whenever resolver-verified evidence exists — the " + "response then carries text_matched_suppressed with the hidden count; " + "pass min_tier:\"text_matched\" to include them. If the target's file was " + "re-parsed after the last enrichment pass, the response also carries " + "suppression_caveat warning the hidden rows may be real. " + "Use lsp_resolved for high-stakes refactors where false positives are expensive." const includeSpeculativeParamDescription = "Include best-guess speculative " + "dynamic-dispatch edges (computed-member calls like obj[name](), getattr, " + "decorator registries). Default false — these are hidden low-confidence " + "fan-outs minted only when synthesize_speculative_dispatch is enabled; " + "set true to surface them (each carries candidate_count + a speculative tier)." // isCompact checks if the compact flag is set in the request. func isCompact(req mcp.CallToolRequest) bool { if v, ok := req.GetArguments()["compact"].(bool); ok { return v } return false } // isTOON reports whether the caller requested the TOON wire format // for this tool call. Selection mirrors `Server.isGCX`: // // 1. Explicit `format` arg wins. // 2. Otherwise the per-session default (driven by MCP `clientInfo`) // decides — TOON is the second-tier compact format used when a // client decodes TOON but not GCX. Today no shipping client is // known to be in this bucket; the helper exists for forward // compat as plugins evolve. // 3. Default false — JSON wins. func (s *Server) isTOON(ctx context.Context, req mcp.CallToolRequest) bool { if v, ok := req.GetArguments()["format"].(string); ok && v != "" { return v == "toon" } if s == nil { return false } return s.resolveSessionFormat(ctx) == "toon" } // toonNodeRow is a TOON-optimized flat representation of a graph node. type toonNodeRow struct { ID string `toon:"id"` Kind string `toon:"kind"` Name string `toon:"name"` FilePath string `toon:"file_path"` StartLine int `toon:"start_line"` Enclosing string `toon:"enclosing,omitempty"` EnclosingID string `toon:"enclosing_id,omitempty"` IsTest bool `toon:"is_test"` TestRole string `toon:"test_role"` TestRunner string `toon:"test_runner,omitempty"` TypeFlavor string `toon:"type_flavor,omitempty"` UIComponent string `toon:"ui_component,omitempty"` } // toonEdgeRow is a TOON-optimized flat representation of a graph edge. type toonEdgeRow struct { From string `toon:"from"` To string `toon:"to"` Kind string `toon:"kind"` Origin string `toon:"origin,omitempty"` Tier string `toon:"tier,omitempty"` Confidence float64 `toon:"confidence"` Label string `toon:"label"` } // toonCallerNoteRow is a TOON-optimized flat representation of one // caller's concurrency annotation (get_callers only). type toonCallerNoteRow struct { ID string `toon:"id"` SyncGuarded bool `toon:"sync_guarded"` SyncGuardedWhy string `toon:"sync_guarded_why,omitempty"` CrossConcurrent bool `toon:"cross_concurrent"` CrossConcurrentWhy string `toon:"cross_concurrent_why,omitempty"` } // toonSubGraphResult wraps nodes and edges for TOON tabular output. type toonSubGraphResult struct { Nodes []toonNodeRow `toon:"nodes"` Edges []toonEdgeRow `toon:"edges"` Total int `toon:"total"` Truncated bool `toon:"truncated"` Caveat *graph.ZeroEdgeCaveat `toon:"caveat,omitempty"` TextMatchedSuppressed int `toon:"text_matched_suppressed,omitempty"` SuppressionCaveat string `toon:"suppression_caveat,omitempty"` RelatedTools string `toon:"related_tools,omitempty"` CallerNotes []toonCallerNoteRow `toon:"caller_notes,omitempty"` UsageSummary *query.UsageSummary `toon:"usage_summary,omitempty"` } // toonSearchResult wraps search results for TOON tabular output. type toonSearchResult struct { Results []toonNodeRow `toon:"results"` Total int `toon:"total"` Truncated bool `toon:"truncated"` } // nodesToTOONRows converts graph nodes to flat TOON rows. func nodesToTOONRows(nodes []*graph.Node) []toonNodeRow { rows := make([]toonNodeRow, 0, len(nodes)) for _, n := range nodes { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } isTest, _ := n.Meta["is_test"].(bool) testRole, _ := n.Meta["test_role"].(string) testRunner, _ := n.Meta["test_runner"].(string) typeFlavor, _ := n.Meta["type_flavor"].(string) uiComponent, _ := n.Meta["ui_component"].(string) encID, encName := graph.EnclosingFromID(n.ID, n.Kind) rows = append(rows, toonNodeRow{ ID: n.ID, Kind: string(n.Kind), Name: n.Name, FilePath: n.FilePath, StartLine: n.StartLine, Enclosing: encName, EnclosingID: encID, IsTest: isTest, TestRole: testRole, TestRunner: testRunner, TypeFlavor: typeFlavor, UIComponent: uiComponent, }) } return rows } // callerNotesToTOONRows flattens the get_callers concurrency-annotation // map into TOON rows, sorted by node ID for deterministic output. // Returns nil for an empty map so the `caller_notes` field is omitted. func callerNotesToTOONRows(notes map[string]*graph.ConcurrencyAnnotation) []toonCallerNoteRow { if len(notes) == 0 { return nil } ids := make([]string, 0, len(notes)) for id := range notes { ids = append(ids, id) } sort.Strings(ids) rows := make([]toonCallerNoteRow, 0, len(ids)) for _, id := range ids { a := notes[id] rows = append(rows, toonCallerNoteRow{ ID: id, SyncGuarded: a.SyncGuarded, SyncGuardedWhy: a.SyncGuardedWhy, CrossConcurrent: a.CrossConcurrent, CrossConcurrentWhy: a.CrossConcurrentWhy, }) } return rows } // returnSubGraph returns a SubGraph in the requested format (JSON, compact, GCX, or TOON). // Method on Server so the format negotiation can consult per-session // client identity (claude-code → gcx, etc.). func (s *Server) returnSubGraph(ctx context.Context, req mcp.CallToolRequest, sg *query.SubGraph) (*mcp.CallToolResult, error) { // Decorate nodes with absolute paths once, up front, so every output // format below surfaces an openable path. The canonical graph nodes // are copied, never mutated. sg.Nodes = s.withAbsPaths(sg.Nodes) // Diagram formats render the subgraph directly — one place serves // every traversal tool (callers/dependencies/usages/...), so a // `gortex query ... --format mermaid|dot` gets a real diagram. switch req.GetString("format", "") { case "mermaid": return mcp.NewToolResultText(sg.ToMermaid()), nil case "dot": return mcp.NewToolResultText(sg.ToDot()), nil } if isCompact(req) { return mcp.NewToolResultText(compactSubGraph(sg)), nil } if s.isGCX(ctx, req) { tool := requestToolName(req) if tool == "" { tool = "subgraph" } return s.gcxResponseWithBudget(req)(encodeSubGraph(tool, sg)) } if s.isTOON(ctx, req) { return subGraphToTOON(sg) } return s.respondJSONOrTOON(ctx, req, sg) } // requestToolName extracts the MCP tool name from a CallToolRequest. // mcp-go surfaces the name on req.Params.Name so we can route multiple // subgraph-returning tools through the same encoder with distinct // header tags. func requestToolName(req mcp.CallToolRequest) string { return req.Params.Name } // returnTOON marshals payload as TOON and returns a text result. It // goes JSON-first so the on-wire field names match the JSON schema // every tool already advertises: toon-go honours only `toon:` tags // and rejects map[int]X / non-string keys outright, but every Gortex // payload tags its fields with `json:` (we don't double-tag with // `toon:`). Round-tripping through JSON gives us tag-driven naming // and string-key normalisation (Go's encoding/json stringifies int // keys) for free, with a single allocation we can amortise across // the tool surface. // // Falls back to JSON on encoder error so a malformed payload can // never take down the response — the caller never sees a half- // written document. func returnTOON(payload any) (*mcp.CallToolResult, error) { jsonBytes, err := json.Marshal(payload) if err != nil { return mcp.NewToolResultJSON(payload) } var generic any if err := json.Unmarshal(jsonBytes, &generic); err != nil { return mcp.NewToolResultJSON(payload) } data, err := toon.Marshal(generic) if err != nil { return mcp.NewToolResultJSON(payload) } return mcp.NewToolResultText(string(data)), nil } // respondJSONOrTOON is the bottom-of-the-handler decision shared by // every tool that advertises `format` in its schema and lets a // per-tool GCX encoder run ahead of it. It returns TOON when the // caller (or the per-session default) asks for it and JSON otherwise. // GCX is handled inline at the call site because GCX uses hand-tuned // per-tool encoders rather than reusing the JSON shape. // // Three pipeline stages run before the format encoder: // // 1. Sparse-fieldsets filter: when the caller passes // `fields: "id,line"`, list rows are projected down to those keys. // Trims response size at the row level. // 2. Graceful degradation: tools that registered a per-shape policy // (`get_file_summary`, `get_editing_context`, `find_usages`, …) // run a cascade — strip verbose meta, drop low-priority kinds, // last-resort tail-trim. Quality stays high under pressure. // 3. Generic budget: tools without a registered shape fall back to // a "trim the longest list" heuristic. Always emits inline data // with `_truncated_by_budget` metadata; never falls through to // a transport spill that the agent has to re-read from disk. // // effectiveBudget defaults to defaultMaxBytes when the caller does // not specify; pass `max_bytes: 0` to opt out of budgeting and get // the full result in one shot (transport spill if oversized). func (s *Server) respondJSONOrTOON(ctx context.Context, req mcp.CallToolRequest, payload any) (*mcp.CallToolResult, error) { payload = applyFieldsFilter(payload, parseFields(req.GetString("fields", ""))) if budget := effectiveBudget(req); budget > 0 { var trimmed bool if shape, ok := degradeShapes[req.Params.Name]; ok { payload, trimmed = applyDegradation(payload, shape, budget) } else { payload, trimmed = applyBudget(payload, budget) } if trimmed { payload = decorateTokenBudgetJSON(payload, req) } } // TOON is the right fallback whenever the caller (or the // per-session default) asked for a compact format. That covers // two cases: // // 1. Explicit `format=toon` — return TOON. // 2. Session default is gcx but this tool does not have a // hand-tuned GCX encoder (status-shape tools like graph_stats // / index_health / list_repos go through this path). Falling // back to TOON instead of JSON keeps the response compact — // ~10–15% smaller than JSON for typical payloads — without // forcing every status tool to ship a bespoke GCX encoder. // // Plain JSON is still the answer when neither toon nor gcx was // requested (unknown clients, explicit `format=json`). if s.isTOON(ctx, req) || s.isGCX(ctx, req) { return returnTOON(payload) } return mcp.NewToolResultJSON(payload) } // subGraphToTOON converts a SubGraph to a TOON-encoded text result. func subGraphToTOON(sg *query.SubGraph) (*mcp.CallToolResult, error) { var edgeRows []toonEdgeRow for _, e := range sg.Edges { label := e.ConfidenceLabel if label == "" { label = graph.ConfidenceLabelFor(e.Kind, e.Confidence) } tier := e.Tier if tier == "" { tier = graph.ResolvedBy(e.Origin) } edgeRows = append(edgeRows, toonEdgeRow{ From: e.From, To: e.To, Kind: string(e.Kind), Origin: e.Origin, Tier: tier, Confidence: e.Confidence, Label: label, }) } result := toonSubGraphResult{ Nodes: nodesToTOONRows(sg.Nodes), Edges: edgeRows, Total: sg.TotalNodes, Truncated: sg.Truncated, Caveat: sg.Caveat, TextMatchedSuppressed: sg.TextMatchedSuppressed, SuppressionCaveat: sg.SuppressionCaveat, RelatedTools: sg.RelatedTools, CallerNotes: callerNotesToTOONRows(sg.CallerNotes), UsageSummary: sg.UsageSummary, } data, err := toon.Marshal(result) if err != nil { return mcp.NewToolResultJSON(sg) } return mcp.NewToolResultText(string(data)), nil } // resolveRepoFilter resolves the optional repo/project/ref params into // a set of allowed repo prefixes, enforced against the session's // workspace boundary. // // For a workspace-bound session (the daemon socket path) the boundary // is mandatory and cannot be widened by args: a `workspace` arg may // only name the session's own workspace, and `repo`/`project`/`ref` // args are intersected with the workspace so they can only ever // narrow. With no explicit narrowing the allow-set is every repo in // the session's workspace — not "all repos". // // gitDiffScopes are the diff-selection values the review-family tools // accept in their `scope` argument; they share the argument name with the // saved-scope filter and are reserved (never saved-scope names). var gitDiffScopes = map[string]bool{"unstaged": true, "staged": true, "all": true, "compare": true} // For an unbound session (embedded stdio / `gortex server // --workspace` / legacy) it falls back to resolveRepoFilterArgs with // the active-project default applied. A nil result there still means // "no filter — all repos". func (s *Server) resolveRepoFilter(ctx context.Context, req mcp.CallToolRequest) (map[string]bool, error) { repo := strings.TrimSpace(req.GetString("repo", "")) if repo != "*" { if p := s.resolveRepoPrefix(repo); p != "" { repo = p } } project := strings.TrimSpace(req.GetString("project", "")) ref := strings.TrimSpace(req.GetString("ref", "")) workspaceArg := strings.TrimSpace(req.GetString("workspace", "")) scopeArg := strings.TrimSpace(req.GetString("scope", "")) if gitDiffScopes[scopeArg] { scopeArg = "" } var scopeRepos map[string]bool if scopeArg != "" && repo == "" && project == "" && ref == "" { sc, ok := s.lookupScope(scopeArg) if !ok { return nil, fmt.Errorf("unknown scope %q — run list_scopes to see saved scopes, or create one with save_scope", scopeArg) } scopeRepos = s.scopeRepoSet(sc) if len(scopeRepos) == 0 { return nil, fmt.Errorf("saved scope %q names no repositories", scopeArg) } } if sessWS, _, bound := s.sessionScope(ctx); bound { if workspaceArg != "" && workspaceArg != sessWS { return nil, fmt.Errorf( "workspace %q is outside the active workspace %q; cross-workspace queries are not permitted", workspaceArg, sessWS) } wsRepos := map[string]bool{} if s.multiIndexer != nil { wsRepos = s.multiIndexer.ReposInWorkspace(sessWS) } if scopeRepos != nil { intersected := intersectRepoSets(scopeRepos, wsRepos) if len(intersected) == 0 { return nil, fmt.Errorf( "saved scope %q resolves to nothing inside the active workspace %q", scopeArg, sessWS) } return intersected, nil } if repo == "*" { repo = "" } if repo == "" && project == "" && ref == "" { return wsRepos, nil } narrowed, err := s.resolveRepoFilterArgs(repo, project, ref, false) if err != nil { return nil, err } if narrowed == nil { return wsRepos, nil } intersected := intersectRepoSets(narrowed, wsRepos) if len(intersected) == 0 { return nil, fmt.Errorf( "repo/project/ref filter resolves to nothing inside the active workspace %q; cross-workspace queries are not permitted", sessWS) } return intersected, nil } if scopeRepos != nil { return scopeRepos, nil } if repo == "*" { repo = "" } return s.resolveRepoFilterArgs(repo, project, ref, true) } // resolveRepoFilterArgs folds explicit repo/project/ref args into a // single allow-set of repo prefixes. A nil map means "no filter — all // repos". When useActiveProjectDefault is true and no axis is given, // the server's active project is applied as the default scope (the // legacy single-tenant behaviour); workspace-bound callers pass false // because their boundary is supplied separately. // // An explicit unknown project is a hard error (the caller asked for X // by name, deserves to know X is wrong); a stale active-project // default degrades to "no filter" with a warning log instead, so a // single misconfigured config line does not break every MCP call. func (s *Server) resolveRepoFilterArgs(repo, project, ref string, useActiveProjectDefault bool) (map[string]bool, error) { projectFromActive := false if useActiveProjectDefault && repo == "" && project == "" && ref == "" && s.activeProject != "" { project = s.activeProject projectFromActive = true } if repo == "" && project == "" && ref == "" { return nil, nil // no filter — search all repos } // Direct repo filter — just that one prefix. if repo != "" { return map[string]bool{repo: true}, nil } // Resolve project/ref via ConfigManager. if s.configManager == nil { return nil, fmt.Errorf("configuration manager is not available") } gc := s.configManager.Global() var entries []config.RepoEntry if project != "" { repos, err := gc.ResolveRepos(project) if err != nil { if projectFromActive { // Stale active-project default. Log and degrade to no // filter (all repos) so the call still succeeds. This // mirrors ConfigManager.ActiveRepos behavior. if s.logger != nil { s.logger.Warn("active project not resolvable, falling back to all repos", zap.String("active_project", project), zap.Error(err)) } return nil, nil } return nil, err } entries = repos } else { // ref without project — collect all repos from all projects. for _, proj := range gc.Projects { entries = append(entries, proj.Repos...) } // Also include top-level repos. entries = append(entries, gc.Repos...) } // Apply ref filter if set. allowed := make(map[string]bool) for _, e := range entries { if ref != "" && e.Ref != ref { continue } allowed[config.ResolvePrefix(e)] = true } return allowed, nil } // filterNodes returns only nodes whose RepoPrefix is in the allowed set. // If allowed is nil, returns the original slice unchanged. func filterNodes(nodes []*graph.Node, allowed map[string]bool) []*graph.Node { if allowed == nil { return nodes } var out []*graph.Node for _, n := range nodes { // In single-repo mode, nodes have empty RepoPrefix — always include them. if n.RepoPrefix == "" || allowed[n.RepoPrefix] { out = append(out, n) } } return out } func filterNodesByResolvedScope(nodes []*graph.Node, resolved ResolvedScope) []*graph.Node { if !resolvedScopeHasFilter(resolved) { return nodes } opts := queryOptionsForResolvedScope(resolved) out := make([]*graph.Node, 0, len(nodes)) for _, n := range nodes { if n != nil && opts.ScopeAllows(n) { out = append(out, n) } } return out } func queryOptionsForResolvedScope(resolved ResolvedScope) query.QueryOptions { return query.QueryOptions{ WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, } } func resolvedScopeHasFilter(resolved ResolvedScope) bool { return resolved.WorkspaceID != "" || resolved.ProjectID != "" || len(resolved.RepoAllow) > 0 } func resolvedScopeAllowsNode(resolved ResolvedScope, n *graph.Node) bool { if n == nil { return false } return queryOptionsForResolvedScope(resolved).ScopeAllows(n) } func filterSubGraphByResolvedScope(sg *query.SubGraph, resolved ResolvedScope) *query.SubGraph { if sg == nil || !resolvedScopeHasFilter(resolved) { return sg } opts := queryOptionsForResolvedScope(resolved) nodeIDs := make(map[string]bool, len(sg.Nodes)) nodes := make([]*graph.Node, 0, len(sg.Nodes)) for _, n := range sg.Nodes { if n != nil && opts.ScopeAllows(n) { nodes = append(nodes, n) nodeIDs[n.ID] = true } } edges := make([]*graph.Edge, 0, len(sg.Edges)) for _, e := range sg.Edges { if e != nil && nodeIDs[e.From] && nodeIDs[e.To] { edges = append(edges, e) } } out := *sg out.Nodes = nodes out.Edges = edges out.TotalNodes = len(nodes) out.TotalEdges = len(edges) if sg.CallerNotes != nil { out.CallerNotes = make(map[string]*graph.ConcurrencyAnnotation, len(sg.CallerNotes)) for id, ann := range sg.CallerNotes { if nodeIDs[id] { out.CallerNotes[id] = ann } } if len(out.CallerNotes) == 0 { out.CallerNotes = nil } } return &out } func filterEdgesByResolvedScope(eng *query.Engine, edges []*graph.Edge, resolved ResolvedScope) []*graph.Edge { if eng == nil || !resolvedScopeHasFilter(resolved) { return edges } out := make([]*graph.Edge, 0, len(edges)) for _, e := range edges { if e == nil { continue } if resolvedScopeAllowsNode(resolved, eng.GetSymbol(e.From)) && resolvedScopeAllowsNode(resolved, eng.GetSymbol(e.To)) { out = append(out, e) } } return out } // filterNodesByKind keeps only nodes whose Kind is in the comma- // separated list. Empty / unknown kinds in the input are ignored // (treated as "no constraint of this name") so a typo is graceful // rather than silently empty. Case-insensitive. // // Used by search_symbols' `kind` argument — lets callers scope a // query to one of the domain-specific node kinds (todo, license, // team, …) without paying the cost of an unrelated BM25 prefix // match. func filterNodesByKind(nodes []*graph.Node, kindArg string) []*graph.Node { want := make(map[string]struct{}) for k := range strings.SplitSeq(kindArg, ",") { k = strings.TrimSpace(strings.ToLower(k)) if k == "" { continue } want[k] = struct{}{} } if len(want) == 0 { return nodes } out := make([]*graph.Node, 0, len(nodes)) for _, n := range nodes { if _, ok := want[strings.ToLower(string(n.Kind))]; ok { out = append(out, n) } } return out } // filterSubGraph returns a new SubGraph with only nodes/edges whose endpoints // are in the allowed repo set. If allowed is nil, returns sg unchanged. func filterSubGraph(sg *query.SubGraph, allowed map[string]bool) *query.SubGraph { if allowed == nil { return sg } nodeIDs := make(map[string]bool) var nodes []*graph.Node for _, n := range sg.Nodes { if n.RepoPrefix == "" || allowed[n.RepoPrefix] { nodes = append(nodes, n) nodeIDs[n.ID] = true } } var edges []*graph.Edge for _, e := range sg.Edges { if nodeIDs[e.From] || nodeIDs[e.To] { edges = append(edges, e) } } totalEdges := len(edges) // Counts-only payloads arrive with Edges == nil and TotalEdges // pre-populated — preserve the upstream count instead of zeroing // it. Inexact in the presence of a non-trivial filter (we'd need // the edges to know which belong to filtered-out nodes), but the // gcx output that asks for the count-only path runs with the // session's workspace scope already applied at the store, so the // filter pass is typically a no-op. if len(sg.Edges) == 0 && sg.TotalEdges > 0 { totalEdges = sg.TotalEdges } return &query.SubGraph{ Nodes: nodes, Edges: edges, TotalNodes: len(nodes), TotalEdges: totalEdges, Truncated: sg.Truncated, } } // compactNodes formats nodes as one-line-per-symbol text. // Format: "kind qualifiedName file_path:start_line" // For methods, qualifiedName includes the receiver (e.g., "Indexer.Index") // so the output can be combined with file_path to reconstruct the full node ID. func compactNodes(nodes []*graph.Node) string { var b strings.Builder for _, n := range nodes { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } fmt.Fprintf(&b, "%s %s %s:%d", n.Kind, qualifiedName(n), n.FilePath, n.StartLine) // Append the enclosing owner when the node is declared inside // one -- a method on a type, a field of a struct, a closure. if _, ename := graph.EnclosingFromID(n.ID, n.Kind); ename != "" { fmt.Fprintf(&b, " (in %s)", ename) } b.WriteByte('\n') } return b.String() } // qualifiedName returns the symbol part of a node ID (after "::"). // For methods this includes the receiver type (e.g., "Indexer.Index"), // for functions/types it's the plain name. func qualifiedName(n *graph.Node) string { if idx := strings.LastIndex(n.ID, "::"); idx >= 0 { return n.ID[idx+2:] } return n.Name } // enrichSubGraphEdges populates ConfidenceLabel, Origin, and Tier on every // edge in a SubGraph. Origin is backfilled from kind + confidence + // semantic_source meta when unset; Tier is the coarse (ast / lsp / // heuristic) provenance label derived from Origin so clients can filter // or group without recomputing the mapping. func enrichSubGraphEdges(sg *query.SubGraph) { for _, e := range sg.Edges { e.ConfidenceLabel = graph.ConfidenceLabelFor(e.Kind, e.Confidence) if e.Origin == "" { src, _ := e.Meta["semantic_source"].(string) e.Origin = graph.DefaultOriginFor(e.Kind, e.Confidence, src) } e.Tier = graph.ResolvedBy(e.Origin) if e.Meta != nil { if v, _ := e.Meta["via"].(string); v != "" { e.Via = graph.ViaLabelFor(v) } } } } // isNonDefinitionNode reports whether a node kind is NOT a file-level // definition and should be dropped from a get_file_summary view. It // excludes the file node itself, imports, and the function-body-internal // nodes (locals, params, closures, generic params, builtins) that the // file_path lookup pulls in but that the "symbols a file defines" // contract never wanted. Without this filter the summary floods with // hundreds of locals/params (the old defines-edge query excluded them by // construction; the GetFileNodes-based path does not). func isNonDefinitionNode(k graph.NodeKind) bool { switch k { case graph.KindFile, graph.KindImport, graph.KindLocal, graph.KindParam, graph.KindClosure, graph.KindGenericParam, graph.KindBuiltin: return true } return false } // stripNonDefinitionNodes returns a copy of sg with non-definition nodes // nodes removed (and edges that reference them dropped). Used by // handleGetFileSummary to keep its output focused on the symbols a // file *defines* — the file node and per-statement import nodes are // useful internals (e.g. for the file-neighbourhood walk that drives // the disk-backend pushdown) but noise in the agent-visible payload. func stripNonDefinitionNodes(sg *query.SubGraph) *query.SubGraph { if sg == nil { return nil } keep := make(map[string]bool, len(sg.Nodes)) nodes := make([]*graph.Node, 0, len(sg.Nodes)) for _, n := range sg.Nodes { if n == nil || isNonDefinitionNode(n.Kind) { continue } nodes = append(nodes, n) keep[n.ID] = true } edges := make([]*graph.Edge, 0, len(sg.Edges)) for _, e := range sg.Edges { if e == nil || !keep[e.From] || !keep[e.To] { continue } edges = append(edges, e) } totalEdges := len(edges) // Counts-only payloads arrive with Edges == nil and TotalEdges // already populated by the store. Keep that count — the file + // import nodes we're stripping pulled some edges with them so it's // a slight overcount, but the gcx callers that take this path // only render it as a header scalar, not as anything load-bearing. if len(sg.Edges) == 0 && sg.TotalEdges > 0 { totalEdges = sg.TotalEdges } return &query.SubGraph{ Nodes: nodes, Edges: edges, TotalNodes: len(nodes), TotalEdges: totalEdges, Truncated: sg.Truncated, CallerNotes: sg.CallerNotes, } } // compactSubGraph formats a SubGraph as compact text. func compactSubGraph(sg *query.SubGraph) string { var b strings.Builder for _, n := range sg.Nodes { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } fmt.Fprintf(&b, "%s %s %s:%d\n", n.Kind, qualifiedName(n), n.FilePath, n.StartLine) } if sg.Truncated { fmt.Fprintf(&b, "... truncated (%d total)\n", sg.TotalNodes) } // Append edge confidence distribution. if len(sg.Edges) > 0 { counts := map[string]int{} for _, e := range sg.Edges { label := e.ConfidenceLabel if label == "" { label = graph.ConfidenceLabelFor(e.Kind, e.Confidence) } counts[label]++ } fmt.Fprintf(&b, "edges: %d total", len(sg.Edges)) for _, label := range []string{"EXTRACTED", "INFERRED", "AMBIGUOUS"} { if c := counts[label]; c > 0 { fmt.Fprintf(&b, ", %d %s", c, label) } } b.WriteByte('\n') } // Append concurrency annotations for callers that carry one. Sorted // by node ID so the compact output is deterministic. if len(sg.CallerNotes) > 0 { ids := make([]string, 0, len(sg.CallerNotes)) for id := range sg.CallerNotes { ids = append(ids, id) } sort.Strings(ids) for _, id := range ids { a := sg.CallerNotes[id] fmt.Fprintf(&b, "concurrency %s:", id) if a.SyncGuarded { b.WriteString(" sync_guarded") } if a.CrossConcurrent { b.WriteString(" cross_concurrent") } b.WriteByte('\n') } } return b.String() } func (s *Server) registerCoreTools() { s.addTool( mcp.NewTool("index_repository", mcp.WithDescription("Index or re-index a local repository path into Gortex. Call once at session start if not already running with --watch."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to repository")), ), s.handleIndexRepository, ) s.addTool( mcp.NewTool("reindex_repository", mcp.WithDescription("Incrementally re-index a repository: re-parses only the files that changed since the last index pass (mtime, or content under Merkle mode) and evicts nodes for deleted files. Much cheaper than index_repository, which rebuilds the whole graph. Pass `paths` to scope the pass to specific files or directories; omit it to scan the whole repository. Returns what was reindexed plus node/edge counts, the stale-file count, and timing."), mcp.WithString("path", mcp.Description("Absolute path to the repository, or a tracked repo prefix. Defaults to the active repository.")), mcp.WithArray("paths", mcp.Description("Optional list of file or directory paths to scope the reindex to — absolute, or relative to the repository root. Directories are walked recursively. When omitted, the whole repository root is scanned."), mcp.WithStringItems(), ), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), ), s.handleReindexRepository, ) s.addTool( mcp.NewTool("get_symbol", mcp.WithDescription("Use instead of Read to locate a function, type, interface, or variable definition. Returns location and signature without reading the whole file."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID (e.g. pkg/server.go::HandleRequest)")), mcp.WithString("detail", mcp.Description("brief or full (default: brief)")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), ), s.handleGetSymbol, ) s.addTool( mcp.NewTool("search_symbols", mcp.WithDescription("Use instead of Grep to find symbols across the whole codebase. Supports natural language queries with camelCase-aware tokenization and BM25 ranking — 'validate token auth' finds validateToken, AuthMiddleware, parseJWT. Localizing a whole task or bug report (not one symbol)? explore returns the ranked neighborhood with source + call paths in one call. For the concrete types that implement an interface, or the methods that override a method, use find_implementations / find_overrides — a name search alone can't tell an implementor from an unrelated same-named symbol."), mcp.WithString("query", mcp.Required(), mcp.Description("Search query — symbol name, concept, or keywords. Also accepts inline field-qualified clauses: `kind:function lang:go path:internal/ repo:gortex project:web validateToken` — recognised fields are kind, flavor, lang (aliases ts/js/py/rs/…), path, repo, project; everything else is free text. A field-qualified query that matches nothing retries on the free text alone (response carries `filters_relaxed: true`).")), mcp.WithNumber("limit", mcp.Description("Max results (default: 20)")), mcp.WithString("cursor", mcp.Description("Opaque pagination cursor returned in `next_cursor` from a previous call. Pass it back to fetch the next page. Omit for the first page.")), mcp.WithBoolean("paginate", mcp.Description("When true, the server caps each page at the project default budget and returns `next_cursor` for any tail. Implies the caller will follow `next_cursor` to walk the rest. Default false (full result inline; transport spills to disk if oversized).")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed and `_truncated_by_budget` plus `_max_returned_` / `_original_count_` flags ride on the response. Omit for no cap.")), mcp.WithString("fields", mcp.Description("Comma-separated list of fields to keep on each result (e.g. \"id,name,line\"). Drops the rest to save tokens.")), mcp.WithBoolean("compact", mcp.Description("Return one-line-per-result text instead of JSON objects (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format — round-trippable, ~40% fewer tokens), or toon")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("path", mcp.Description("Restrict results to one or more sub-paths (comma-separated) -- the monorepo-service slice (e.g. \"services/billing,libs/auth\"). Anchored, slash-segment-boundary prefixes relative to the repo root: \"services/billing\" matches services/billing/x.go, not other/services/billingX. Unions with any inline path: clause and a scope's saved paths.")), mcp.WithString("kind", mcp.Description("Filter to one or more node kinds (comma-separated). Standard kinds: function, method, type, interface, variable, constant, field, file, package, import, contract. Coverage kinds: param, closure, enum_member, generic_param, module, table, column, config_key, flag, event, migration, fixture, todo, team, license, release, doc (Markdown prose section).")), mcp.WithString("flavor", mcp.Description("Filter to one or more structural flavors (comma-separated, union). Values: class, struct, enum, interface, trait, protocol, object, record, type_alias, newtype, message, service, table, view, module, signature, type_def, instance, hook, play — matched against Meta[\"type_flavor\"] case-insensitively. The special value `component` spans both keys: it matches any UI component node (React / Vue / Svelte / SwiftUI / …). ANDs with kind:.")), mcp.WithString("assist", mcp.Description("LLM assist mode: \"auto\" (default — engages on natural-language queries, skips identifier lookups), \"on\" (force engage), \"off\" (bypass), \"deep\" (on + a body-grounded verification pass that reads candidate code and HONESTLY drops irrelevant matches — slower, may return empty results when nothing genuinely matches). Requires an LLM provider configured via `llm.provider` (local / anthropic / openai / azure / ollama / claudecli / codex / copilot / cursor / opencode / gemini / bedrock / deepseek, or a registered custom provider); behaves as \"off\" when none is available.")), mcp.WithBoolean("debug", mcp.Description("When true, attach a `rerank` block to the response carrying per-candidate scores and per-signal contributions from the 11-signal rerank pipeline (bm25, semantic, fan_in, hits, fan_out, churn, community, minhash, api_signature, type_signature, recency, feedback) plus the active per-signal weight map. Off by default; enable to inspect ranking decisions or tune `.gortex.yaml::search::weights`.")), mcp.WithString("query_class", mcp.Description("Advisory hint that tunes the bm25-vs-semantic balance of the rerank: \"auto\" (default — detect from query shape), \"symbol\" (identifier / API lookup — BM25-heavy), \"concept\" (natural-language description — balanced), \"path\" (file-path query — most BM25-heavy), \"signature\" (type/function-signature fragment — BM25-leaning), \"keyword_soup\" (a degenerate boolean OR-list \u2014 suppresses LLM expansion and splits the soup into per-disjunct BM25 fetches; a `query_advice` nudge rides on the response). The class actually used is echoed back as `query_class` in the response.")), mcp.WithString("expand", mcp.Description("Query-expansion channels: \"both\" (default \u2014 LLM expansion when the assist gate engages, plus the deterministic equivalence-class table), \"equivalence\" (only the LLM-free curated synonym table + per-repo auto-mined concepts), \"llm\" (only LLM expansion), \"off\" (pure BM25, no expansion). Equivalence expansion bridges query vocabulary to the words a symbol uses (auth->login, delete->remove) and runs even with no LLM provider configured. For identifier queries (query_class symbol / path / signature) the server auto-disables expansion + vector even when expand is set \u2014 these classes match best on BM25 + exact-name alone.")), mcp.WithString("corpus", mcp.Description("Which corpus to search: \"code\" (default \u2014 code symbols only), \"docs\" (only Markdown prose-section nodes), \"content\" (only the pdf / office / text content chunks, data_class=content), \"all\" (code + all prose). With docs / content / all a prose query matches by body text.")), mcp.WithBoolean("vocab_anchored", mcp.Description("When true, the LLM query-expander's returned synonyms are post-filtered to the words that actually appear in this repo's symbol names before they feed the BM25 OR-merge -- so a hallucinated-but-plausible term can't dilute the candidate pool. Robust and model-agnostic. Degrades to unconstrained expansion when the repo's mined vocabulary is empty (e.g. a cold index). Default false; the server may set a different default via search.vocab_anchored_expansion. No effect when the LLM expansion channel is off.")), mcp.WithNumber("max_per_file", mcp.Description("Cap how many results a single source file may contribute to the diverse head of the result set (default 3). Hits beyond the cap are demoted below not-yet-capped results — never dropped — so the top of the list spans more files. Set 0 to disable diversification.")), ), s.handleSearchSymbols, ) s.addTool( mcp.NewTool("get_file_summary", mcp.WithDescription("Use instead of Read to understand a file's role: returns the symbols a file defines (functions, methods, types, fields, …) without reading source lines. The file node itself and import nodes are excluded — use find_import_path or get_dependencies for import-shape queries."), mcp.WithString("path", mcp.Required(), mcp.Description("Relative file path")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithNumber("max_tokens", mcp.Description(tokenBudgetParamDescription)), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("if_none_match", mcp.Description("ETag from a previous response — returns not_modified if content unchanged")), ), s.handleGetFileSummary, ) s.addTool( mcp.NewTool("get_dependencies", mcp.WithDescription("Returns what a symbol or file depends on — imports, calls, type references — without reading any files. Use before editing to understand incoming contracts."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), mcp.WithNumber("depth", mcp.Description("Traversal depth (default: 2)")), mcp.WithNumber("limit", mcp.Description("Max nodes (default: 50)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), s.handleGetDependencies, ) s.addTool( mcp.NewTool("get_dependents", mcp.WithDescription("Returns everything that depends on this symbol (blast radius). Call before changing a function or type to know what else must be updated."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), mcp.WithNumber("depth", mcp.Description("Traversal depth (default: 3)")), mcp.WithNumber("limit", mcp.Description("Max nodes (default: 50)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), s.handleGetDependents, ) s.addTool( mcp.NewTool("get_call_chain", mcp.WithDescription("Traces the call graph forward from a function without reading source. Use to understand what a function ultimately triggers."), mcp.WithString("id", mcp.Required(), mcp.Description("Function node ID")), mcp.WithNumber("depth", mcp.Description("Traversal depth (default: 4)")), mcp.WithNumber("limit", mcp.Description("Max nodes (default: 50)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithNumber("max_tokens", mcp.Description(tokenBudgetParamDescription)), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), s.handleGetCallChain, ) s.addTool( mcp.NewTool("get_callers", mcp.WithDescription("Returns all callers of a function without reading source. Use instead of Grep when you need to know who calls a function. For every reference — not just call sites, but type uses, field access, and imports — use find_usages."), mcp.WithString("id", mcp.Required(), mcp.Description("Function node ID")), mcp.WithNumber("depth", mcp.Description("Traversal depth (default: 2)")), mcp.WithNumber("limit", mcp.Description("Max nodes (default: 50)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), mcp.WithBoolean("exclude_tests", mcp.Description("Drop callers originating in test functions (set true when you want production callers only)")), ), s.handleGetCallers, ) s.addTool( mcp.NewTool("find_implementations", mcp.WithDescription("Finds all concrete types that implement an interface — 'what implements X', interface satisfaction, which structs satisfy this contract. Use before changing an interface to identify all types that will be affected. For method-level overrides (which methods override this one, or the parents it overrides) use find_overrides."), mcp.WithString("id", mcp.Required(), mcp.Description("Interface node ID")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), s.handleFindImplementations, ) s.addTool( mcp.NewTool("find_overrides", mcp.WithDescription("Finds all methods that override the given method (children) or the parent methods it overrides. Backed by EdgeOverrides materialised at index time and promoted to lsp_dispatch when an LSP is available."), mcp.WithString("id", mcp.Required(), mcp.Description("Method node ID")), mcp.WithString("direction", mcp.Description("'children' (default — overriders) or 'parents' (overridden methods)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), s.handleFindOverrides, ) s.addTool( mcp.NewTool("get_class_hierarchy", mcp.WithDescription("Returns the inheritance subgraph around a type, interface, or method. Walks EdgeExtends + EdgeImplements + EdgeComposes for type nodes and EdgeOverrides for method nodes — the same graph data find_implementations and find_overrides expose, but as a multi-hop tree so an agent gets the whole chain (parents → root, children → leaves) in one call. Use before refactoring an OO hierarchy or to answer 'what does this class inherit from / who subclasses it'."), mcp.WithString("id", mcp.Required(), mcp.Description("Seed node ID — a type, interface, or method")), mcp.WithString("direction", mcp.Description("'up' (parents/interfaces this extends or implements; methods this overrides), 'down' (subtypes / implementers / overriders), or 'both' (default)")), mcp.WithNumber("depth", mcp.Description("Max hops to walk in each direction (default: 5, hard cap: 64)")), mcp.WithBoolean("include_methods", mcp.Description("When true and the seed/visited node is a type or interface, also include its methods (via EdgeMemberOf) and walk the EdgeOverrides chain rooted at each method.")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), ), s.handleGetClassHierarchy, ) s.addTool( mcp.NewTool("find_usages", mcp.WithDescription("Use instead of Grep to find every reference to a symbol across the codebase. Returns precise locations with zero false positives. For just the call sites of a function use get_callers; for the concrete types that implement an interface use find_implementations."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), mcp.WithNumber("limit", mcp.Description("Max nodes (default: 50)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithNumber("max_tokens", mcp.Description(tokenBudgetParamDescription)), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), mcp.WithString("min_tier", mcp.Description(minTierParamDescription)), mcp.WithBoolean("include_speculative", mcp.Description(includeSpeculativeParamDescription)), mcp.WithBoolean("exclude_tests", mcp.Description("Drop references originating in test functions (set true to see only production usages)")), mcp.WithString("group_by", mcp.Description("Set to \"file\" to bucket the usages by the file each reference originates in -- each group carries the per-file use count and the enclosing symbol of every reference. Omit for the default flat result.")), mcp.WithString("context", mcp.Description("Filter usages by their reference context — the role the symbol plays at each site: parameter_type, return_type, field, value, type, attribute, generic_arg, call, or import (import / re-export statement lines). Every returned usage also carries its classified context. Omit for all usages.")), mcp.WithString("return_usage", mcp.Description("Filter call-site usages by how they consume the callee's return value: discarded, assigned, partially_ignored, returned, goroutine, deferred, argument, or condition. Every returned call usage also carries its classification when the extractor recorded one. Use before changing a function's return signature to see who actually uses the return. Omit for all usages.")), mcp.WithString("flavor", mcp.Description("Filter usages by the structural flavor of where they originate (comma-separated, union). A type flavor (class, struct, enum, interface, trait, …) keeps usages whose FROM site sits inside an enclosing type of that flavor — \"usages from inside a struct\". The special value `component` keeps usages from inside a UI component (React / Vue / SwiftUI / …). Each returned usage carries the resolved from_type_flavor / from_ui_component.")), ), s.handleFindUsages, ) s.addTool( mcp.NewTool("get_cluster", mcp.WithDescription("Returns the immediate neighbourhood around a node — all symbols it touches and that touch it. Useful for understanding a module's coupling before refactoring."), mcp.WithString("id", mcp.Required(), mcp.Description("Node ID")), mcp.WithNumber("radius", mcp.Description("Bidirectional hops (default: 2)")), mcp.WithNumber("limit", mcp.Description("Max nodes (default: 50)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output (saves 50-70% tokens)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")), mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")), ), s.handleGetCluster, ) s.addTool( mcp.NewTool("get_repo_outline", mcp.WithDescription("Narrative single-call overview of the indexed codebase: primary languages, top communities, load-bearing hotspots, most-imported files, and entry points. Use at session start (or when onboarding to an unfamiliar repo) instead of assembling this from graph_stats + analyze + manual inspection. Output stays under ~1k tokens."), mcp.WithString("format", mcp.Description("Output format: json (default) or toon. gcx is accepted but honoured as toon — get_repo_outline is a status-shape payload with no row-shape gain from a hand-tuned GCX encoder.")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes; truncation metadata rides on the response.")), ), s.handleGetRepoOutline, ) s.addTool( mcp.NewTool("graph_stats", mcp.WithDescription("Returns a compact summary of the indexed codebase: node/edge counts by kind and language. Call at session start to orient Claude in an unfamiliar repo."), mcp.WithString("format", mcp.Description("Output format: json (default) or toon. gcx is accepted but honoured as toon — graph_stats is a status-shape payload with no row-shape gain from a hand-tuned GCX encoder.")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes; truncation metadata rides on the response.")), ), s.handleGraphStats, ) } func (s *Server) handleIndexRepository(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { path, err := req.RequireString("path") if err != nil { return mcp.NewToolResultError("path is required"), nil } // In multi-repo mode, route through multiIndexer so nodes get the correct // RepoPrefix and byRepo stays consistent. Using the shared singleton // indexer here produces unprefixed nodes that corrupt per-repo stats. if s.multiIndexer != nil { // Accept either a tracked prefix directly or a filesystem path. // Falls back to reconciling from persisted config so users don't // have to re-track repos the daemon dropped across warmup (T0.3). prefix := s.resolveRepoPrefixOrReconcile(ctx, path) if prefix == "" { return mcp.NewToolResultError(fmt.Sprintf( "path %q is not a tracked repository; use track_repository to add it", path)), nil } result, err := s.multiIndexer.IndexRepo(prefix) if err != nil { return mcp.NewToolResultError(err.Error()), nil } s.RunAnalysis() s.recordIndexTelemetry(result.FileCount) return s.respondJSONOrTOON(ctx, req, result) } result, err := s.indexer.IndexCtx(s.progressCtx(ctx, req), path) if err != nil { return mcp.NewToolResultError(err.Error()), nil } s.RunAnalysis() s.recordIndexTelemetry(result.FileCount) return s.respondJSONOrTOON(ctx, req, result) } // recordIndexTelemetry records one completed full-index pass to opt-in usage // telemetry: a coarse file-count bucket plus a per-language counter for each // language in the graph. Gated on an enabled recorder so a disabled / absent // recorder costs nothing — in particular it does not walk the graph for the // language set. func (s *Server) recordIndexTelemetry(fileCount int) { if s.recorder == nil || !s.recorder.Enabled() { return } var langs []string if s.graph != nil { for lang := range s.graph.Stats().ByLanguage { langs = append(langs, lang) } sort.Strings(langs) } telemetry.RecordIndex(s.recorder, fileCount, langs) } // handleReindexRepository implements the reindex_repository tool: an // incremental re-index that re-parses only changed files (and evicts // deleted ones) instead of rebuilding the whole graph. The optional // `paths` argument scopes the pass to specific files / directories. func (s *Server) handleReindexRepository(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { paths := req.GetStringSlice("paths", nil) // Drop blank entries so a caller passing [""] doesn't accidentally // degrade a scoped request into a whole-repo scan. cleaned := make([]string, 0, len(paths)) for _, p := range paths { if strings.TrimSpace(p) != "" { cleaned = append(cleaned, p) } } paths = cleaned pathArg := req.GetString("path", "") var ( result *indexer.IndexResult err error ) // Multi-repo mode: route through the per-repo indexer so nodes keep // their RepoPrefix and per-repo stats stay consistent. if s.multiIndexer != nil { prefix := "" if pathArg != "" { prefix = s.resolveRepoPrefixOrReconcile(ctx, pathArg) if prefix == "" { return mcp.NewToolResultError(fmt.Sprintf( "path %q is not a tracked repository; use track_repository to add it", pathArg)), nil } } else { // No path given — fall back to the session's bound repo, // then to the sole tracked repo when there is exactly one. prefix, _ = s.sessionLocality(ctx) if prefix == "" { if tracked := s.multiIndexer.RepoPrefixes(); len(tracked) == 1 { prefix = tracked[0] } } if prefix == "" { return mcp.NewToolResultError( "reindex_repository: no repository specified and the active repository is ambiguous; pass `path`"), nil } } result, err = s.multiIndexer.IncrementalReindexRepo(prefix, paths) if err != nil { return mcp.NewToolResultError(err.Error()), nil } } else { if s.indexer == nil { return mcp.NewToolResultError("reindex_repository: no indexer available"), nil } root := pathArg if root == "" { root = s.indexer.RootPath() } if root == "" { return mcp.NewToolResultError( "reindex_repository: no repository root known; pass `path`"), nil } root, absErr := filepath.Abs(root) if absErr != nil { return mcp.NewToolResultError(absErr.Error()), nil } result, err = s.indexer.IncrementalReindexPaths(root, paths) if err != nil { return mcp.NewToolResultError(err.Error()), nil } } s.RunAnalysis() scope := "repository" if len(paths) > 0 { scope = "paths" } payload := map[string]any{ "scope": scope, "result": result, "node_count": result.NodeCount, "edge_count": result.EdgeCount, "file_count": result.FileCount, "stale_file_count": result.StaleFileCount, "full_retrack": result.FullRetrack, "duration_ms": result.DurationMs, } if len(paths) > 0 { payload["reindexed_paths"] = paths } if len(result.FailedFiles) > 0 { payload["failed_files"] = result.FailedFiles } return s.respondJSONOrTOON(ctx, req, payload) } func (s *Server) handleGetSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } // Auto re-index stale file before querying. if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { s.ensureFresh([]string{parts[0]}) } node := s.engineFor(ctx).GetSymbol(id) if node == nil { return symbolNotFoundGuidance(id), nil } resolved, errResult := s.resolveScope(ctx, req, toolIntentForName(req.Params.Name)) if errResult != nil { return errResult, nil } if !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } s.sessionFor(ctx).recordSymbol(id) // On-demand LSP: when the synchronous LSP sweep is off (the default), fault // in this symbol's precise type now — one hover on the lazy-spawned server, // cached in the graph. No-op when it already has a type or no server serves // the language. s.enrichNodeOnDemand(node) detail := req.GetString("detail", "brief") if detail == "brief" { return s.respondScopedJSONOrTOON(ctx, req, s.withAbsPath(node).Brief(), resolved) } // Full: include node + direct edges. eng := s.engineFor(ctx) out := filterEdgesByResolvedScope(eng, eng.GetOutEdges(node.ID), resolved) in := filterEdgesByResolvedScope(eng, eng.GetInEdges(node.ID), resolved) return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "node": s.withAbsPath(node), "out_edges": out, "in_edges": in, }, resolved) } func (s *Server) handleSearchSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { q, err := req.RequireString("query") if err != nil { return mcp.NewToolResultError("query is required"), nil } limit := req.GetInt("limit", 20) offset := decodeCursor(req.GetString("cursor", "")) sess := s.sessionFor(ctx) sess.recordSearch(q) // Field-qualified query syntax: lift `kind:` / `lang:` / `path:` / // `repo:` / `project:` clauses out of the query string. The // residual free text drives search, rerank, and classification; // the clauses become post-filters and scope, merged with the // explicit kind / repo / project arguments. fq := parseFieldQuery(q) q = fq.Text scopeReq := requestWithInlineScopeClauses(req, fq) // Apply server-default scope merged with caller args. `workspace` // / `project` args win per-field; empty falls through to the // server's --workspace flag. SearchSymbolsScoped over-fetches and // post-filters, so ranking is preserved while results stay inside // the boundary. With pagination we over-fetch to (offset + limit // + 10) so the post-filter slack still leaves a full page. resolved, errResult := s.resolveScope(ctx, scopeReq, IntentLocate) if errResult != nil { return errResult, nil } scopeWS, scopeProj := resolved.WorkspaceID, resolved.ProjectID // Per-phase timing for the search hot path. The struct is populated // across the engine boundary (BM25 backend call wall-clock attributes // to BM25*MS in fetchAndMergeBM25Timed; GetNodes / FindName / Fallback // land here from inside Engine.gatherBackendCandidates) and surfaced // at the end as a single debug log line. Nil-safe: callers without // debug logging pay zero overhead. timings := &query.SearchTimings{} phaseStart := time.Now() scope := query.QueryOptions{WorkspaceID: scopeWS, ProjectID: scopeProj, RepoAllow: resolved.RepoAllow, SearchTimings: timings} // Keyword-soup defense: a degenerate boolean / OR-list query // ("A OR B OR 'no access'") defeats ordinary retrieval. Detect it // up front -- explicit query_class:"keyword_soup" pins it, else the // structural detector decides. On a soup query the LLM expansion + // rerank passes are suppressed (wasted on garbage) and the soup is // split into per-disjunct BM25 fetches; a `query_advice` nudge is // attached so the agent learns to send a cleaner query. soupMode := s.searchConfig().EffectiveKeywordSoupRewrite() soupQueryClass := strings.EqualFold(strings.TrimSpace(req.GetString("query_class", "")), "keyword_soup") soupDetected, soupReason := rerank.LooksLikeKeywordSoup(q) isSoup := soupMode != config.KeywordSoupOff && (soupDetected || soupQueryClass) if isSoup && soupReason == "" { soupReason = "query reads as a boolean OR-list; search ranks best on a single concept or symbol name -- run one query per disjunct, or describe the intent in plain words" } // Identifier-shape fast path. ClassifyQuery is the structural // detector the rerank uses; QueryClassSymbol / Path / Signature // are queries where the rerank's classWeightTable already proves // the semantic channel contributes near-zero signal (0.65 / 0.45 / // 0.80 vs the baseline 1.00) — see internal/search/rerank/ // query_kind.go::classWeightTable. For these classes the handler // forces expansion off and tells the engine to skip the vector // channel entirely; the rest of the pipeline (BM25 + bundle + // rerank) is the only path that matters. An explicit // query_class arg pin on one of these three classes engages the // fast path too. A soup query never engages the fast path — // keyword_soup has its own split-disjunct treatment. // // Validation of the query_class arg happens here so the early // gating uses the same validated value the rerank below uses; // invalid input is rejected before the engine runs. queryClass := rerank.ClassifyQuery(q) qcPinned := false if qcArg := strings.TrimSpace(req.GetString("query_class", "")); qcArg != "" { parsed, ok := rerank.ParseQueryClass(qcArg) if !ok { return mcp.NewToolResultError("invalid query_class: " + qcArg + " (want auto, symbol, concept, path, signature, or keyword_soup)"), nil } if parsed != rerank.QueryClassUnknown { queryClass = parsed qcPinned = true } } identifierFastPath := !isSoup && isIdentifierClass(queryClass) if identifierFastPath { scope.SkipVectorChannel = true } // LLM assist gate: decides whether the expansion + rerank passes // run for this query. The service-enabled check is layered inside // the helpers so a stub build is a clean bypass. A soup query // forces assist off -- neither expansion nor rerank earns its cost // on a disjunct list. assist := parseAssistMode(req) // expand mode picks which query-expansion channels run -- LLM, // the deterministic equivalence table, both (default), or off. expand := parseExpandMode(req) // Identifier-shape queries skip every expansion channel — the // rerank's classWeightTable shows BM25 is near-perfect for these // classes; expansion would only add the combined-OR fan-out's // extra backend call without lifting recall on a literal-token // query. The explicit arg pin still wins for soup / concept. if identifierFastPath { expand = expandOff } // Vocabulary-anchored expansion: post-filter the LLM expander's // returned synonyms to the words this repo's symbol names actually // use, so a hallucinated-but-plausible term can't dilute the BM25 // pool. The explicit `vocab_anchored` argument wins; when omitted, // the server-wide config default applies. GetArguments is consulted // directly so an absent argument falls through to the config // default instead of GetBool's hard-coded false. vocabAnchored := s.searchConfig().VocabAnchoredExpansionDefault() if v, ok := req.GetArguments()["vocab_anchored"].(bool); ok { vocabAnchored = v } engage := shouldEngageAssist(assist, q) && s.llmService != nil && s.llmService.Enabled() if isSoup || !expand.allowsLLMExpansion() { engage = false } // Enrichment-aware first-load gate: an implicit assist request must // not trigger a cold load of the local model while a background // enrichment pass is in flight — the two would contend for // CPU/GPU/RAM. Defer assist for this request (BM25-only ranking) and // tell the caller via a rider; the explicit `ask` tool loads // regardless. Once the model is already loaded, busy() is irrelevant. var assistDeferred bool if engage { modelLoaded := s.llmService.ModelLoaded() busy := s.llmService.EnrichmentBusy() engage, assistDeferred = deferColdLoadForAssist(engage, modelLoaded, busy) if assistDeferred { s.logger.Info("deferred local model cold-load during enrichment", zap.String("query", q)) } } fetchLimit := offset + limit + 10 if engage { // Slightly widen the BM25 over-fetch when we're going to // rerank: more head candidates means a more useful reorder. fetchLimit = offset + limit + rerankCap } else if identifierFastPath { // Identifier-shape fast path: no expansion, no vector channel, // no LLM rerank — the only down-stream consumer is the // structural rerank pipeline scoring a single FTS-ranked head. // A wide head is wasted work; every extra candidate drags an // in/out edge pair through the bundle phase. Tighten to // +5 so the post-filter slack still leaves a full page. fetchLimit = offset + limit + 5 } else { // Concept / degraded-identifier query with no LLM assist: the // always-on semantic-cosine rerank still runs, so widen the BM25 // head to a full rerank pool. A natural-language intent query // ("decode bson request body") often has its target sitting past // the default +10 over-fetch; the semantic channel can only lift // a candidate it actually sees, so this pool is what turns a // BM25-rank-15 file hit into a top-5 result. Cheap: ~40 extra // FTS rows and their edge pairs, well inside the latency budget. if want := offset + limit + semanticRerankPool; want > fetchLimit { fetchLimit = want } } // Expansion terms feeding the BM25 OR-merge: LLM-derived synonyms // when assist engaged, or the soup's split disjuncts when this is // a soup query handled in "split" mode. The two are mutually // exclusive -- soup forces assist off above. // LLM-derived synonyms (when assist engaged), the soup's split // disjuncts (soup query in split mode), and the deterministic // equivalence-class siblings all compose into one expansion-term // list. fetchAndMergeBM25 dedups the merged result by node ID, so // overlapping channels never double-count a candidate. var llmTerms, soupFragments, equivTerms []string if engage { llmTerms = expandSearchTerms(ctx, s, q, vocabAnchored) } if isSoup && soupMode == config.KeywordSoupSplit { soupFragments = rerank.SplitSoupFragments(q) } if expand.allowsEquivalenceExpansion() { equivTerms = s.expandEquivalenceClasses(q) } expandedTerms := mergeExpansionTerms(soupFragments, llmTerms, equivTerms) // Build the rerank context BEFORE the BM25 fetch so the engine's // bundle path can seed its edge caches as the BM25 calls land. // The handler-side applyRerankBoostsTimed reuses this same rctx, // so the merged candidate set's edges are already cached when // prepare() runs against the post-filter slice. Without this // pre-fetch construction the engine's bundle would build a // throwaway cache on each BM25 call and the handler's later // rerank would still fetch every candidate's edges itself. rctx := s.buildRerankContext(ctx, q) scope.RerankContext = rctx // Corpus selection: `code` (default) keeps only code symbols, // `docs` keeps only prose-section (KindDoc) nodes, `all` keeps // both. Parsed BEFORE the fetch so the docs corpus can drive its // own retrieval channel (below) rather than relying purely on a // post-filter over the single code-shaped fetch. corpus, corpusErr := parseCorpus(req) if corpusErr != nil { return mcp.NewToolResultError(corpusErr.Error()), nil } var nodes []*graph.Node var primaryCount int if len(expandedTerms) > 0 { nodes, primaryCount = fetchAndMergeBM25Timed(s.engineFor(ctx), q, expandedTerms, fetchLimit, scope, timings) } else { bm25Start := time.Now() nodes = s.engineFor(ctx).SearchSymbolsScoped(q, fetchLimit, scope) timings.BM25PrimaryMS += time.Since(bm25Start).Milliseconds() primaryCount = len(nodes) } // Docs retrieval channel: when the corpus admits prose, the single // code-shaped fetch above is not enough -- a relevant doc section // can rank just past fetchLimit behind code symbols that share the // query's tokens and never enter the candidate pool at all, so the // corpus post-filter has nothing to keep. Issue a parallel, // wider-limit fetch and merge in its KindDoc hits before the // corpus filter runs, so prose competes on its own terms. The // merge dedupes by node ID; ranking among the merged set is settled // by the rerank pass downstream. if corpus.includesDocs() { nodes = s.mergeDocChannel(ctx, q, nodes, fetchLimit, scope, timings) } // Content retrieval channel: content (data_class=content) sections live // in the dedicated content index, not the symbol search, so the fetches // above can't surface them — pull them from the ContentSearcher and // merge before the corpus filter runs. if corpus.includesContent() { nodes = s.mergeContentChannel(ctx, q, nodes, fetchLimit) } candsAfterGather := len(nodes) mergedCount := len(nodes) // pre-filter; comparable to primaryCount allowed := resolved.RepoAllow // kind filter so callers can scope to a single new node kind // (todo, license, team, module, …). Comma-separated list — // case-insensitive — applied post-search so BM25 ranking is // preserved within the kept set. The explicit `kind` argument // wins; an in-query `kind:` clause fills in when it is absent. kindArg := strings.TrimSpace(req.GetString("kind", "")) if kindArg == "" { kindArg = fq.Kind } flavorArg := strings.TrimSpace(req.GetString("flavor", "")) if flavorArg == "" { flavorArg = fq.Flavor } // codegraph-compat shim: a kind: value that is actually a structural // flavor (class/struct/enum/component) routes to the flavor filter so // `kind:class` doesn't silently return an empty result set. if kindArg != "" { var movedFlavors string kindArg, movedFlavors = reclassifyKindFlavor(kindArg) if movedFlavors != "" { if flavorArg == "" { flavorArg = movedFlavors } else { flavorArg += "," + movedFlavors } } } pathFilter := s.resolvePathFilter(req, fq) // applyAllPostFilters runs the full post-search filter sequence // (repo / kind / lang+path clauses / sub-path scope / corpus) over // a candidate slice in the order the primary path uses it. Lifted // into a closure so the zero-result decomposition fallback applies // identical filtering to its re-fetched candidates — a fallback // that skipped any of these would surface results the caller's // scope was supposed to exclude. applyAllPostFilters := func(cands []*graph.Node) []*graph.Node { cands = filterNodes(cands, allowed) if kindArg != "" { cands = filterNodesByKind(cands, kindArg) } // lang: / path: / repo: clauses from the field-qualified syntax. cands = applyFieldFilters(cands, fq) // flavor: clause / flavor param / reclassified kind: value. if flavorArg != "" { cands = applyFlavorFilter(cands, flavorArg) } // Sub-path scoping: anchored, slash-segment prefix narrowing // below the repository grain. Distinct from the loose `path:` // substring match in applyFieldFilters -- this is a // directory-boundary anchored prefix test. if len(pathFilter) > 0 { cands = applyPathFilter(cands, pathFilter) } cands = filterNodesByCorpus(cands, corpus) return cands } nodes = applyAllPostFilters(nodes) // Fuzzy fallback: a field-qualified query that filtered down to // nothing retries on the free text alone (still inside the // caller's repo / project scope), so an over-narrow or typo'd // clause degrades to a useful result set instead of an empty one. filtersRelaxed := false if len(nodes) == 0 && q != "" && (kindArg != "" || flavorArg != "" || fq.hasFieldFilters()) { relaxed := filterNodes(s.engineFor(ctx).SearchSymbolsScoped(q, fetchLimit, scope), allowed) if len(relaxed) > 0 { nodes = relaxed filtersRelaxed = true } } // Leaf-decomposition fallback: a compound / dotted / CamelCase // query that found nothing — even after the field-clause relax // above — is decomposed into its leaf symbol-name tokens // ("UserService.FindUser" -> user / service / find) which are // re-fetched through the same BM25 OR-merge path the expansion // uses. Gated on the query carrying a separator so a prose miss // (which has no leaves to split into) never pays the extra fetch. // All post-filters are re-applied so the rescued candidates honour // the caller's repo / kind / lang / path / corpus scope. decomposed := false if len(nodes) == 0 && queryHasDecomposableSeparator(q) { if leaves := decomposeQueryToLeaves(q); len(leaves) > 0 { rescued, _ := fetchAndMergeBM25Timed(s.engineFor(ctx), "", leaves, fetchLimit, scope, timings) rescued = applyAllPostFilters(rescued) if len(rescued) > 0 { nodes = rescued decomposed = true } } } // LLM rerank runs AFTER kind/repo filters so the model only sees // the candidate pool the caller will actually receive, and BEFORE // the combo/frecency boost so per-session signals can still // override a stale rerank. var verifyDbg verifyDebug var verifyRan bool if engage { nodes = rerankWithLLM(ctx, s, q, nodes) // `deep` mode adds a body-grounded verification pass that // reads candidate code and HONESTLY drops the ones whose // body isn't actually about the query. An empty kept set is // preserved — it's the load-bearing "nothing genuinely matches" // signal that distinguishes deep mode from plain rerank. if assist == assistDeep { nodes, verifyDbg, verifyRan = verifyWithLLM(ctx, s, q, nodes) } } // Force-inject the implicit-feedback channel: symbols the agent has // reached for on this query before but that BM25 did not surface // this time. Injected before the rerank so the combo / feedback // signals rank them in context; post-filtered so they honour the // caller's repo / kind / lang / path / corpus scope. nodes = s.forceInjectLearnedCandidates(ctx, q, nodes, applyAllPostFilters) // Rerank: run the I13 11-signal pipeline over the candidate set // with the session-aware Context wired in. Structural signals // (BM25 rank, fan-in / fan-out, MinHash similarity, signature // match, recency, community) discriminate within the backend's // BM25 order; session signals (locality, combo, frecency, // feedback, churn) layer on top once the agent has spent time // in the codebase. Cold queries with no session data fall back // to a structural-only pass. // // rctx was built above (before the BM25 fetch) so the engine's // bundle path could seed its edge caches into the same rctx the // handler-side rerank will read from. // queryClass was classified + validated at the top of the handler // so the identifier-shape fast path could read it. Re-apply the // soup override here — soup detection happens after classification // and reports keyword_soup regardless of what the structural // detector thought the query looked like. if isSoup { queryClass = rerank.QueryClassKeywordSoup } if rctx != nil { rctx.QueryClass = queryClass // Continuous α lever: when the class was auto-detected — not // pinned by the caller and not a keyword-soup (which keeps its // discrete split-disjunct treatment) — score the bm25↔semantic // balance on a continuous query-shape axis instead of snapping // to a discrete class bucket. if !qcPinned && !isSoup { rctx.Alpha = rerank.AlphaForContinuous(q) } // Prose-tuned reranking: a docs-only query scores prose-section // nodes that have no call graph, signature, or definition // keyword, so suppress the code-structural signals and lift the // text + semantic channels. corpusAll still mixes code, so the // prose profile engages only for the docs-only corpus where // every candidate is a prose section. if corpus == corpusDocs || corpus == corpusContent { rctx.ProseMode = true } } candsAfterFilter := len(nodes) // Capture the post-filter candidate ID set so we can ask the rctx // what fraction of these candidates' edges were already cached by // the bundle pre-seed (vs needing prepare's own batched fetch). // Hit-rate is reported on the debug log as cache_hit_rate. if rctx != nil { preIDs := make([]string, 0, len(nodes)) for _, n := range nodes { if n != nil { preIDs = append(preIDs, n.ID) } } timings.CacheHitRate = rctx.EdgeCacheHitRate(preIDs) } var rerankBreakdown []*rerank.Candidate var rerankPrepare, rerankSignals time.Duration nodes, rerankPrepare, rerankSignals = applyRerankBoostsTimed(s, nodes, q, rctx, &rerankBreakdown) // Post-rerank exact-cosine refinement. The merged rerank above // scores the semantic channel by RRF rank and discards the raw // cosine the vector store computed; this stage recovers it by // embedding the query once and re-ordering the ranked head against // the candidates' stored vectors. Skipped for identifier-shape // queries (the vector channel never ran) and strictly a no-op when // the vector channel is otherwise inactive, so it can never regress // a text-only search. rerankBreakdown is the candidate slice in the // final rerank order; refining it and rebuilding nodes keeps the // two aligned for the diversification pass below. if !scope.SkipVectorChannel && s.searchConfig().CosineRerankEnabled() && len(rerankBreakdown) > 1 { refined := s.engineFor(ctx).RefineByCosine(q, rerankBreakdown, 0) rerankBreakdown = refined refinedNodes := make([]*graph.Node, 0, len(refined)) for _, c := range refined { if c != nil && c.Node != nil { refinedNodes = append(refinedNodes, c.Node) } } if len(refinedNodes) == len(nodes) { nodes = refinedNodes } } // Per-file diversification: keep one file's many symbols from // monopolising the head of the result set. Runs after the rerank // so demotion acts on final scores; nothing is dropped. diversifyStart := time.Now() nodes, rerankBreakdown = diversifyByFile(nodes, rerankBreakdown, req.GetInt("max_per_file", defaultMaxPerFile)) diversifyMS := time.Since(diversifyStart).Milliseconds() // Flush the prior search's implicit skip-above negatives before this // search overwrites the attribution state: results that ranked above // the deepest one the agent consumed but were themselves passed over // lose a little of their learned per-keyword boost. if sess != nil && q != "" { if nq, skipped := sess.drainSkippedNegatives(); nq != "" && len(skipped) > 0 { s.combo.RecordNegative(nq, skipped) } } // Remember the returned IDs for attribution on later consume calls. // Cap at top limit so unseen "overflow" results don't get credited. recordLastSearchFromNodes(sess, q, nodes, limit) total := len(nodes) // Slice the (offset, limit) window. nextCursor is empty when the // last row in `nodes` is included. end := offset + limit if end > total { end = total } if offset > total { offset = total } page := nodes[offset:end] // Decorate the page with absolute file paths so every output format // below surfaces an openable path alongside the repo-relative one. page = s.withAbsPaths(page) nextCursor := "" if end < total { nextCursor = encodeCursor(end) } if isCompact(req) { return decorateResultWithScope(mcp.NewToolResultText(compactNodes(page)), resolved), nil } if s.isGCX(ctx, req) { res, err := s.gcxResponseWithBudget(req)(encodeSearchSymbols(page, total, len(page))) return withScopeResult(res, err, resolved) } if s.isTOON(ctx, req) { result := toonSearchResult{ Results: nodesToTOONRows(page), Total: total, Truncated: end < total, } data, err := toon.Marshal(result) if err == nil { return decorateResultWithScope(mcp.NewToolResultText(string(data)), resolved), nil } } var results []map[string]any for _, n := range page { results = append(results, n.Brief()) } resp := map[string]any{ "results": results, "total": total, "truncated": end < total, "query_class": queryClass.String(), } if nextCursor != "" { resp["next_cursor"] = nextCursor } // A repo-narrowed zero is indistinguishable from "not indexed" in // clients that never render _meta. Say it in the body — and since the // result is empty anyway, pay one extra BM25 fetch to report whether // widening would actually help. if total == 0 && len(resolved.RepoAllow) > 0 { wide := scope wide.RepoAllow = nil wideNodes, _ := fetchAndMergeBM25Timed(s.engineFor(ctx), q, expandedTerms, offset+limit, wide, timings) resp["scope_note"] = scopeZeroNote(resolved, len(wideNodes)) } if filtersRelaxed { resp["filters_relaxed"] = true } if decomposed { resp["decomposed"] = true } // query_advice nudges the agent toward a cleaner query when the // input was detected as keyword-soup. In "split" mode the search // still ran (over the split disjuncts); the advice is purely // instructional and rides alongside the results. if isSoup && soupReason != "" { advice := map[string]any{"reason": soupReason} if len(soupFragments) > 0 { advice["split_into"] = soupFragments } resp["query_advice"] = advice } // When LLM assist engaged, expose a small debug surface so callers // (and the agent itself) can see what the model contributed. // Suppressed when engage was false to keep the common-path response // shape unchanged. if engage { assistDebug := map[string]any{ "engaged": true, "primary_count": primaryCount, // BM25 hits on original query alone, pre-filter "merged_count": mergedCount, // BM25 hits after merging expansion terms, pre-filter "final_count": total, // post-filter, post-rerank — matches the top-level total } if len(llmTerms) > 0 { assistDebug["terms"] = llmTerms } if len(equivTerms) > 0 { assistDebug["equivalence_terms"] = equivTerms } if verifyRan { assistDebug["verify_considered"] = verifyDbg.Considered assistDebug["verify_kept_ids"] = verifyDbg.Kept assistDebug["verify_kept"] = len(verifyDbg.Kept) assistDebug["verify_dropped"] = len(verifyDbg.Considered) - len(verifyDbg.Kept) } resp["assist"] = assistDebug } // When the assist cold-load was deferred (enrichment in flight), // tell the caller the results are BM25-only and LLM-assisted ranking // will be available shortly — so a thin result set isn't read as a // hard miss. if assistDeferred { resp["assist_deferred"] = map[string]any{ "reason": "enrichment_in_progress", "detail": "local model cold-load deferred while background enrichment is running; results are BM25-only. Retry shortly for LLM-assisted ranking, or use the `ask` tool to load the model now.", } } // Surface the 11-signal rerank breakdown when the caller asked // for it via debug=true. Page-sliced to match the rows in // `results`. Keeps the common-path response shape unchanged. if req.GetBool("debug", false) && len(rerankBreakdown) > 0 { pageBreakdown := rerankBreakdown if offset < len(pageBreakdown) { pageBreakdown = pageBreakdown[offset:] } else { pageBreakdown = nil } if len(pageBreakdown) > limit { pageBreakdown = pageBreakdown[:limit] } resp["rerank"] = encodeRerankBreakdown(pageBreakdown, s.engineFor(ctx).Rerank()) } // Per-phase Debug log line — single zap.Debug call carrying every // timing field for this search_symbols invocation. The bench harness // greps for the "search_symbols phases" message at --log-level // debug; production runs at info level pay nothing. Tracked phases: // BM25 primary / expansion calls (wall-clock around the engine), // the inner GetNodesByIDs / FindNodesByName / Fallback hops (from // the engine), rerank prepare (batched edge fetch) and signals // (in-process scoring), diversify, and the candidate counts at // gather → filter → final. if s.logger != nil { totalMS := time.Since(phaseStart).Milliseconds() // "BM25 backend" cost = the BM25 wall-clock minus the inner // phases the engine also accumulated under that call. Negative // values are clamped to 0 (clock granularity / contention). // BundleMS is subtracted too — it's a fold of the FTS + nodes // + edge fetches that, on the legacy path, would have shown up // in TextBackend / GetNodes / (no field for edges) separately. bm25Backend := timings.BM25PrimaryMS + timings.BM25ExpansionMS - timings.GetNodesMS - timings.FindNameMS - timings.FallbackMS - timings.BundleMS if bm25Backend < 0 { bm25Backend = 0 } s.logger.Debug("search_symbols phases", zap.String("query", q), zap.Int("expansion_terms", len(expandedTerms)), zap.Int64("bm25_primary_ms", timings.BM25PrimaryMS), zap.Int64("bm25_expansion_ms", timings.BM25ExpansionMS), zap.Int64("bm25_backend_ms", bm25Backend), zap.Int64("text_backend_ms", timings.TextBackendMS), zap.Int64("embed_ms", timings.EmbedMS), zap.Int64("vector_search_ms", timings.VectorSearchMS), zap.Int64("engine_rerank_ms", timings.EngineRerankMS), zap.Int64("bundle_ms", timings.BundleMS), zap.Float64("cache_hit_rate", timings.CacheHitRate), zap.Int64("get_nodes_ms", timings.GetNodesMS), zap.Int64("find_name_ms", timings.FindNameMS), zap.Int64("fallback_ms", timings.FallbackMS), zap.Duration("rerank_prepare_ms", rerankPrepare), zap.Duration("rerank_signals_ms", rerankSignals), zap.Int64("diversify_ms", diversifyMS), zap.Int64("total_ms", totalMS), zap.Int("cands_after_gather", candsAfterGather), zap.Int("cands_after_filter", candsAfterFilter), zap.Int("cands_final", len(nodes)), ) } return s.respondScopedJSONOrTOON(ctx, req, resp, resolved) } // encodeRerankBreakdown converts a candidate slice into a JSON-ready // breakdown — one row per candidate carrying its final score and the // per-signal contributions in the canonical order. func encodeRerankBreakdown(cands []*rerank.Candidate, pipeline *rerank.Pipeline) []map[string]any { if len(cands) == 0 { return nil } weights := map[string]float64{} if pipeline != nil { weights = pipeline.Weights() } out := make([]map[string]any, 0, len(cands)) for _, c := range cands { row := map[string]any{ "id": c.Node.ID, "score": roundTo(c.Score, 4), } if c.TextRank >= 0 { row["text_rank"] = c.TextRank } if c.VectorRank >= 0 { row["vector_rank"] = c.VectorRank } if len(c.Signals) > 0 { sigs := make(map[string]float64, len(c.Signals)) for _, name := range rerank.AllSignalNames() { if v, ok := c.Signals[name]; ok { sigs[name] = roundTo(v, 4) } } row["signals"] = sigs } if len(weights) > 0 { row["weights"] = weights } out = append(out, row) } return out } func roundTo(v float64, places int) float64 { if v == 0 { return 0 } pow := 1.0 for range places { pow *= 10 } return float64(int64(v*pow+0.5)) / pow } func (s *Server) handleGetFileSummary(ctx context.Context, req mcp.CallToolRequest) (res *mcp.CallToolResult, retErr error) { // Defensive panic recovery — get_file_summary has been observed // to crash the MCP transport in multi-repo mode (file-content // validation gap). Surface the panic as a tool error so the // session survives. defer func() { if r := recover(); r != nil { s.logger.Error("get_file_summary panic recovered", zap.String("path", req.GetString("path", "")), zap.Any("panic", r)) res = mcp.NewToolResultError(fmt.Sprintf("get_file_summary internal error: %v", r)) retErr = nil } }() fp, err := req.RequireString("path") if err != nil { return mcp.NewToolResultError("path is required"), nil } // Normalise to the graph's stored path form so a repo-relative path // (internal/x.go) doesn't miss the repo-prefixed nodes in multi-repo // mode — the cause of spurious file_not_indexed misses. fp = s.graphRelPath(fp) // Auto re-index stale file before querying. s.ensureFresh([]string{fp}) // gcx is the high-volume agent format and only emits total_edges // in its meta header — never per-edge rows. Route gcx-only calls // through the count-only path so the disk backends skip // materialising every adjacent edge across cgo (a 4 000-row // round-trip on a 500-symbol file becomes two scalar aggregates). // compact + json paths still take the full SubGraph because // compact summarises edges per confidence label and json ships // every edge in the body. gcxOnly := s.isGCX(ctx, req) && !isCompact(req) var sg *query.SubGraph if gcxOnly { sg = s.engineFor(ctx).GetFileSymbolsCounts(fp) } else { sg = s.engineFor(ctx).GetFileSymbols(fp) } if len(sg.Nodes) == 0 { return fileNotIndexedGuidance(fp), nil } // Apply repo/project/ref filter. allowed, filterErr := s.resolveRepoFilter(ctx, req) if filterErr != nil { return mcp.NewToolResultError(filterErr.Error()), nil } sg = filterSubGraph(sg, allowed) if len(sg.Nodes) == 0 { return fileNotIndexedGuidance(fp), nil } // get_file_summary's contract is "what symbols does this file // define" — the file node itself and import nodes ride on // GetFileSubGraph because they're useful for other walkers, but // the encoder layer wants the symbols-only view. The compact // path already filtered both kinds inline; the cleaner home is // here so every output format (compact, gcx, json, toon) sees the // same shape. sg = stripNonDefinitionNodes(sg) if len(sg.Nodes) == 0 { return fileNotIndexedGuidance(fp), nil } // ETag conditional fetch — checked before any savings accounting so // a not_modified turnaround books nothing (a polling client would // otherwise mint fake savings on every poll). Use the structural // SubGraph hash — json.Marshal'ing the whole SubGraph + Meta on // every call was the dominant cost on large files (~2 ms / call on // a 500-symbol file). etag := etagSubGraph(sg) if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { return notModifiedResult(etag), nil } // Server-side accounting only — a file summary stands in for // reading the whole file. The payload sample tracks the format // actually returned (the compact text for compact/gcx, the // marshaled result for json/toon) so `returned` reflects what was // shipped. summaryLang := "" for _, n := range sg.Nodes { if n != nil && n.Language != "" { summaryLang = n.Language break } } if isCompact(req) { payload := compactSubGraph(sg) s.recordFileBaselineSavings(ctx, "get_file_summary", fp, summaryLang, payload) return mcp.NewToolResultText(payload), nil } if s.isGCX(ctx, req) { s.recordFileBaselineSavings(ctx, "get_file_summary", fp, summaryLang, compactSubGraph(sg)) return s.gcxResponseWithBudget(req)(encodeFileSummary(sg, etag)) } // Wrap with etag in response. result := map[string]any{ "nodes": sg.Nodes, "edges": sg.Edges, "total_nodes": len(sg.Nodes), "total_edges": len(sg.Edges), "truncated": sg.Truncated, "etag": etag, } s.attachFileDependents(result, fp) if payload, merr := json.Marshal(result); merr == nil { s.recordFileBaselineSavings(ctx, "get_file_summary", fp, summaryLang, string(payload)) } if s.isTOON(ctx, req) { return returnTOON(result) } return s.respondJSONOrTOON(ctx, req, result) } func (s *Server) handleGetDependencies(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } eng := s.engineFor(ctx) if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } opts := query.QueryOptions{ Depth: req.GetInt("depth", 2), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, } sg := eng.GetDependencies(id, opts) sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGetDependents(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } eng := s.engineFor(ctx) if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } opts := query.QueryOptions{ Depth: req.GetInt("depth", 3), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, } sg := eng.GetDependents(id, opts) sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGetCallChain(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } eng := s.engineFor(ctx) if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } opts := query.QueryOptions{ Depth: req.GetInt("depth", 4), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, } s.hydrateProxyTargets(ctx, id) sg := eng.GetCallChain(id, opts) sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) enrichSubGraphEdges(sg) annotateProxyFreshness(sg) return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGetCallers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { target, err := req.RequireString("id") if err != nil { return mcp.NewToolResultError("id is required"), nil } id, candidates := s.resolveSymbolTarget(ctx, target) if len(candidates) > 0 { return s.symbolDisambiguationResult(ctx, req, "get_callers", target, candidates) } minTier := req.GetString("min_tier", "") resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } eng := s.engineFor(ctx) if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } opts := query.QueryOptions{ Depth: req.GetInt("depth", 2), Limit: req.GetInt("limit", 50), Detail: "brief", MinTier: minTier, WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, ExcludeTests: req.GetBool("exclude_tests", false), } // Lazy enrichment: confirm this symbol's callers on demand before // answering, so a graph indexed without the eager LSP sweep still returns // compiler-grade callers. No-op when already confirmed or eager ran. s.confirmSymbolRefsOnDemand(eng.GetSymbol(id)) s.hydrateProxyTargets(ctx, id) sg := eng.GetCallers(id, opts) sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) if minTier == "" { // Same adaptive default as find_usages: hide the name-only // fan-out once resolver-verified callers exist. sg.SuppressRedundantTextMatches() s.attachSuppressionCaveat(sg, id) } enrichSubGraphEdges(sg) annotateCallerConcurrency(eng.Reader(), sg, id) if len(sg.Edges) == 0 { sg.Caveat = graph.CaveatForZeroEdge(s.graph, id) } // Epistemic lower bound: a caller walk over in-edges cannot see callers // that reach this symbol through interface dispatch the resolver left // unbound. Flag the floor + name the interface so the agent can widen it. if bs := graph.CallerBoundaries(s.graph, []string{id}, 0); len(bs) > 0 { sg.Boundaries = bs sg.LowerBound = graph.LowerBoundCaveat(bs) // The reach is a floor because dispatch is dynamic — scan the seed's // body for the exact runtime-dispatch sites so the agent gets // {site, form, key, candidates} instead of a read-spiral. if db := s.dynamicBoundariesForSymbol(s.graph.GetNode(id)); len(db) > 0 { sg.DynamicBoundaries = db } } annotateProxyFreshness(sg) return s.returnScopedSubGraph(ctx, req, sg, resolved) } // annotateCallerConcurrency attaches a ConcurrencyAnnotation to every // caller node in a get_callers result that carries at least one // concurrency flag. The seed node itself is skipped — it is the // queried symbol, not one of its callers. File / import nodes are // skipped because no concurrency fact applies to them. A node with no // flag set is left out of the map, so an absent entry reads as // "neither sync_guarded nor cross_concurrent". func annotateCallerConcurrency(r graph.Reader, sg *query.SubGraph, seedID string) { if r == nil || sg == nil { return } for _, n := range sg.Nodes { if n == nil || n.ID == seedID { continue } if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } ann := graph.ClassifyConcurrency(r, n.ID) if !ann.Any() { continue } if sg.CallerNotes == nil { sg.CallerNotes = make(map[string]*graph.ConcurrencyAnnotation) } annCopy := ann sg.CallerNotes[n.ID] = &annCopy } } func (s *Server) handleFindOverrides(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } direction := req.GetString("direction", "children") minTier := req.GetString("min_tier", "") resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } var nodes []*graph.Node switch direction { case "parents", "overridden": nodes = s.engineFor(ctx).FindOverridden(id) default: nodes = s.engineFor(ctx).FindOverridesMinTier(id, minTier) } // Confine results to the session's workspace — these engine // methods don't take QueryOptions, so the boundary is enforced // here. nodes = s.scopedNodeSlice(ctx, nodes) nodes = filterNodesByResolvedScope(nodes, resolved) nodes = s.withAbsPaths(nodes) if s.isGCX(ctx, req) { sg := &query.SubGraph{Nodes: nodes, TotalNodes: len(nodes)} return s.returnScopedSubGraph(ctx, req, sg, resolved) } if s.isTOON(ctx, req) { result := struct { Overrides []toonNodeRow `toon:"overrides"` Total int `toon:"total"` }{ Overrides: nodesToTOONRows(nodes), Total: len(nodes), } if data, err := toon.Marshal(result); err == nil { return decorateResultWithScope(mcp.NewToolResultText(string(data)), resolved), nil } } results := make([]map[string]any, 0, len(nodes)) for _, n := range nodes { results = append(results, n.Brief()) } return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "overrides": results, "total": len(results), "direction": direction, }, resolved) } func (s *Server) handleFindImplementations(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } impls := s.engineFor(ctx).FindImplementationsMinTier(id, minTier) // Confine results to the session's workspace — FindImplementations // doesn't take QueryOptions, so the boundary is enforced here. impls = s.scopedNodeSlice(ctx, impls) impls = filterNodesByResolvedScope(impls, resolved) impls = s.withAbsPaths(impls) if s.isGCX(ctx, req) { sg := &query.SubGraph{Nodes: impls, TotalNodes: len(impls)} return s.returnScopedSubGraph(ctx, req, sg, resolved) } if s.isTOON(ctx, req) { result := struct { Implementations []toonNodeRow `toon:"implementations"` Total int `toon:"total"` }{ Implementations: nodesToTOONRows(impls), Total: len(impls), } if data, err := toon.Marshal(result); err == nil { return decorateResultWithScope(mcp.NewToolResultText(string(data)), resolved), nil } } var results []map[string]any for _, n := range impls { results = append(results, n.Brief()) } return s.respondScopedJSONOrTOON(ctx, req, map[string]any{ "implementations": results, "total": len(results), }, resolved) } func (s *Server) handleGetClassHierarchy(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } direction := query.HierarchyDirection(req.GetString("direction", string(query.HierarchyBoth))) switch direction { case query.HierarchyUp, query.HierarchyDown, query.HierarchyBoth: // ok default: return mcp.NewToolResultError("direction must be one of: up, down, both"), nil } depth := req.GetInt("depth", 5) includeMethods := req.GetBool("include_methods", false) minTier := req.GetString("min_tier", "") resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } eng := s.engineFor(ctx) if node := eng.GetSymbol(id); node != nil && !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } opts := query.QueryOptions{ WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, MinTier: minTier, } sg := eng.ClassHierarchy(id, direction, depth, includeMethods, opts) sg = filterSubGraphByResolvedScope(sg, resolved) enrichSubGraphEdges(sg) return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleFindUsages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } minTier := req.GetString("min_tier", "") // find_usages on a tuck symbol returns hits only from tuck. // Server-level --workspace + caller `workspace` arg compose the // same way as on search_symbols. resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } eng := s.engineFor(ctx) // Barrel re-export resolve-through: a public import path that names a // forwarded binding (`src/middleware.ts::persist`) may have no node of its // own (an unresolved / over-cap / wildcard barrel) — map it to the // canonical declaration so find_usages answers with the real usages instead // of not_found. Gated on the id not already being a node, so a real // symbol's result is never altered. node := eng.GetSymbol(id) if node == nil { if canon := reExportBindingCanonical(s.graph, id, 0); canon != "" { id = canon node = eng.GetSymbol(id) } } if node != nil && !resolvedScopeAllowsNode(resolved, node) { return symbolNotFoundGuidance(id), nil } // Lazy enrichment: confirm this symbol's incoming references on demand // before answering, so a graph indexed without the eager LSP sweep still // converges to compiler-grade usages. No-op when already confirmed or eager // ran. s.confirmSymbolRefsOnDemand(node) opts := query.QueryOptions{ WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, ExcludeTests: req.GetBool("exclude_tests", false), } sg := eng.FindUsagesScoped(id, opts) // Barrel re-export binding: a query on the public façade node // (`src/middleware.ts::persist`) returns its own direct refs — consumer // imports that bind to it — plus the canonical declaration's usages, // deduped. So the façade an agent actually imports answers with the full // usage set instead of only the forwarding site. if isReExportNode(node) { if canon := reExportNodeCanonical(s.graph, id, 0); canon != "" && canon != id { mergeUsageSubGraph(sg, eng.FindUsagesScoped(canon, opts)) } } sg = filterSubGraphByResolvedScope(sg, resolved) sg.FilterByMinTier(minTier) sg.FilterSpeculative(req.GetBool("include_speculative", false)) if minTier == "" { // Adaptive default: with resolver-verified usages present, the // text_matched fan-out is noise — suppress it and report the count // via text_matched_suppressed. min_tier:"text_matched" restores it. sg.SuppressRedundantTextMatches() s.attachSuppressionCaveat(sg, id) } enrichSubGraphEdges(sg) // Classify each usage's reference context (parameter_type / return_type // / field / value / type / attribute / call) and optionally filter to // one context — `find_usages context:"parameter_type"`. annotateAndFilterUsageContext(sg, strings.ToLower(strings.TrimSpace(req.GetString("context", "")))) // Surface the extractor-stamped return-usage classification on each // call usage and optionally filter to one consumption shape — // `find_usages return_usage:"discarded"`. annotateAndFilterReturnUsage(sg, strings.ToLower(strings.TrimSpace(req.GetString("return_usage", "")))) // flavor: keeps only usages whose FROM site resolves to a matching // structural flavor (the enclosing owner type for a type flavor; the // FROM node's own ui_component for `component`). Orphan nodes left // with no incident edge are pruned. s.filterUsagesByFlavor(sg, id, strings.TrimSpace(req.GetString("flavor", ""))) if len(sg.Edges) == 0 && sg.TierFiltered == nil { // A tier_filtered emptiness is not "no usages" — FilterByMinTier // already recorded why. Only reach for the extraction-gap / unused // classification when the emptiness was NOT caused by min_tier. sg.Caveat = graph.CaveatForZeroEdge(s.graph, id) } // Completeness rollup (n_refs / n_files / n_test_refs) so an agent can // tell at a glance whether the usage list already covers tests instead // of re-grepping *_test.go files. Rides every wire format below; nil // for an empty result (the Caveat above covers that case). sg.UsageSummary = usageSummaryOf(sg) // Proactive discovery cue: when the target is dispatch-heavy, point at // find_implementations (once per session). Additive — the only response // content the diet adds. s.attachRelatedToolsCue(ctx, sg, id) // group_by:"file" buckets the usages by the file each reference // originates in -- an opt-in shape for callers that want a // per-file rollup. The flat SubGraph stays the default so // existing consumers are unaffected. if gb := strings.ToLower(strings.TrimSpace(req.GetString("group_by", ""))); gb == "file" { return s.respondScopedJSONOrTOON(ctx, req, groupUsagesByFile(sg), resolved) } if s.isGCX(ctx, req) { res, err := s.gcxResponseWithBudget(req)(encodeFindUsages(sg, s.graph)) return withScopeResult(res, err, resolved) } // Plain JSON gets curated usage rows that promote the resolved // from_type_flavor / from_ui_component to top-level node fields, so // they survive the meta-stripping degrade step. TOON / compact / // diagram formats go through the shared subgraph renderer. format := req.GetString("format", "") if !s.isTOON(ctx, req) && !isCompact(req) && format != "mermaid" && format != "dot" { sg.Nodes = s.withAbsPaths(sg.Nodes) return s.respondScopedJSONOrTOON(ctx, req, newUsageResponse(sg, s.graph), resolved) } return s.returnScopedSubGraph(ctx, req, sg, resolved) } // usageNode wraps a find_usages node with the resolved from_* fields // promoted to top-level JSON (not nested under meta, so they survive // the meta-stripping degrade step). It is an mcp-package projection — // it never adds a field to the graph wire contract. type usageNode struct { *graph.Node FromTypeFlavor string `json:"from_type_flavor,omitempty"` FromUIComponent string `json:"from_ui_component,omitempty"` } // usageResponse is the curated find_usages JSON payload. It embeds the // SubGraph (so caveat / totals / edges marshal unchanged) and shadows // its Nodes with the from_*-enriched projection. type usageResponse struct { *query.SubGraph Nodes []usageNode `json:"nodes"` } // attachSuppressionCaveat adds a human-readable rider to sg when the adaptive // default hid text_matched edges AND the target's file is in the // re-parsed-but-not-re-enriched window (see suppressionMayBeStale) — so a // hidden-but-real usage is diagnosable instead of silently dropped. No-op when // nothing was suppressed or the file is not stale; the existing // text_matched_suppressed count carries the number in every response either way. func (s *Server) attachSuppressionCaveat(sg *query.SubGraph, id string) { if sg == nil || sg.TextMatchedSuppressed == 0 { return } if !s.suppressionMayBeStale(id) { return } sg.SuppressionCaveat = fmt.Sprintf( "%d candidate usage(s) hidden pending re-verification; "+ "pass min_tier:\"text_matched\" to see them", sg.TextMatchedSuppressed) } // suppressionMayBeStale reports whether id's file was re-parsed on the live // watch path without re-running semantic enrichment — the window in which the // resolver-verified edges that triggered text_matched suppression may sit // below the enrichment tier, so the suppressed name-only usages could be the // real ones. Reads graph.MetaReparsePendingEnrichment, which the indexer // stamps on the file's KindFile node. Conservative: false whenever the marker // is absent (no rider rather than a false alarm on a converged graph). func (s *Server) suppressionMayBeStale(id string) bool { n := s.graph.GetNode(id) if n == nil || n.FilePath == "" { return false } for _, fn := range s.graph.GetFileNodes(n.FilePath) { if fn.Kind != graph.KindFile { continue } v, ok := fn.Meta[graph.MetaReparsePendingEnrichment] b, _ := v.(bool) return ok && b } return false } // newUsageResponse resolves the from_* fields for each node (pure read // — never mutates the graph) and wraps the SubGraph for JSON output. func newUsageResponse(sg *query.SubGraph, g graph.Store) *usageResponse { wrapped := make([]usageNode, 0, len(sg.Nodes)) for _, n := range sg.Nodes { tf, uc := usageFromFlavor(g, n.ID, n) wrapped = append(wrapped, usageNode{Node: n, FromTypeFlavor: tf, FromUIComponent: uc}) } return &usageResponse{SubGraph: sg, Nodes: wrapped} } // annotateAndFilterUsageContext stamps each usage edge with its // reference context (RefContextOf, using the origin node's kind) and, when // contextFilter is non-empty, drops every usage whose context does not // match — the engine behind `find_usages context:"parameter_type"`. func annotateAndFilterUsageContext(sg *query.SubGraph, contextFilter string) { if sg == nil { return } nodeByID := make(map[string]*graph.Node, len(sg.Nodes)) for _, n := range sg.Nodes { nodeByID[n.ID] = n } for _, e := range sg.Edges { var fromKind graph.NodeKind if fn := nodeByID[e.From]; fn != nil { fromKind = fn.Kind } e.Context = graph.RefContextOf(e, fromKind) } if contextFilter == "" { return } kept := sg.Edges[:0] for _, e := range sg.Edges { if e.Context == contextFilter { kept = append(kept, e) } } sg.Edges = kept } // annotateAndFilterReturnUsage copies the extractor-stamped // return-usage label (Meta[MetaReturnUsage]) onto each usage edge's // JSON-visible ReturnUsage field and, when usageFilter is non-empty, // drops every usage whose label does not match — the engine behind // `find_usages return_usage:"discarded"`. Edges without a label (non- // call edges, unclassifiable sites) never match a filter. func annotateAndFilterReturnUsage(sg *query.SubGraph, usageFilter string) { if sg == nil { return } for _, e := range sg.Edges { e.ReturnUsage = graph.ReturnUsageOf(e) } if usageFilter == "" { return } kept := sg.Edges[:0] for _, e := range sg.Edges { if e.ReturnUsage == usageFilter { kept = append(kept, e) } } sg.Edges = kept } // usageFromFlavor resolves the structural flavor and UI-component // framework attributable to a usage edge's FROM site, for the // find_usages flavor filter and the from_* surfacing. The component // marker is read off the FROM node itself (React function components / // Composables are KindFunction); the type flavor is read off the FROM // node's enclosing owner type, because callers are functions / methods // that never carry type_flavor themselves. Nil-safe: a FROM node absent // from both the supplied lookup and the graph yields empty strings. func usageFromFlavor(g graph.Store, fromID string, fromNode *graph.Node) (typeFlavor, uiComponent string) { if fromNode == nil && g != nil { fromNode = g.GetNode(fromID) } if fromNode == nil { return "", "" } if fromNode.Meta != nil { if uc, _ := fromNode.Meta["ui_component"].(string); uc != "" { uiComponent = uc } } if ownerID, _ := graph.EnclosingFromID(fromID, fromNode.Kind); ownerID != "" && g != nil { if owner := g.GetNode(ownerID); owner != nil && owner.Meta != nil { if tf, _ := owner.Meta["type_flavor"].(string); tf != "" { typeFlavor = tf } } } return typeFlavor, uiComponent } // filterUsagesByFlavor drops usage edges whose FROM site does not // resolve to a matching structural flavor, then prunes nodes left with // no incident edge (the queried target is always kept). An empty flavor // argument is a no-op. func (s *Server) filterUsagesByFlavor(sg *query.SubGraph, targetID, flavorArg string) { flavors := splitFlavors(flavorArg) if sg == nil || len(flavors) == 0 { return } nodeByID := make(map[string]*graph.Node, len(sg.Nodes)) for _, n := range sg.Nodes { nodeByID[n.ID] = n } kept := sg.Edges[:0] for _, e := range sg.Edges { tf, uc := usageFromFlavor(s.graph, e.From, nodeByID[e.From]) if flavorMatchesResolved(tf, uc, flavors) { kept = append(kept, e) } } sg.Edges = kept // Prune orphan nodes: keep the queried target plus every node still // incident to a surviving edge. referenced := map[string]struct{}{targetID: {}} for _, e := range sg.Edges { referenced[e.From] = struct{}{} referenced[e.To] = struct{}{} } keptNodes := sg.Nodes[:0] for _, n := range sg.Nodes { if _, ok := referenced[n.ID]; ok { keptNodes = append(keptNodes, n) } } sg.Nodes = keptNodes } // usageFileGroup is one file's worth of references from a // group_by:"file" find_usages response. type usageFileGroup struct { File string `json:"file"` Count int `json:"count"` Uses []usageGroupItem `json:"uses"` } // usageGroupItem is one reference inside a usageFileGroup -- the // line it sits on plus the enclosing symbol. type usageGroupItem struct { Line int `json:"line"` EdgeKind string `json:"edge_kind"` Context string `json:"context,omitempty"` ReturnUsage string `json:"return_usage,omitempty"` SymbolID string `json:"symbol_id,omitempty"` SymbolName string `json:"symbol_name,omitempty"` } // groupUsagesByFile buckets a find_usages SubGraph by the file each // reference originates in. The `from` endpoint of every edge is the // usage site; its file path is the bucket key and the from-node's // name/ID is the enclosing symbol. Files are sorted by descending // use count, then by path for a stable order. func groupUsagesByFile(sg *query.SubGraph) map[string]any { nodeByID := make(map[string]*graph.Node, len(sg.Nodes)) for _, n := range sg.Nodes { nodeByID[n.ID] = n } groups := map[string]*usageFileGroup{} for _, e := range sg.Edges { from := nodeByID[e.From] file := e.FilePath if file == "" && from != nil { file = from.FilePath } if file == "" { file = "(unknown)" } g := groups[file] if g == nil { g = &usageFileGroup{File: file} groups[file] = g } item := usageGroupItem{Line: e.Line, EdgeKind: string(e.Kind), Context: e.Context, ReturnUsage: e.ReturnUsage} if from != nil { item.SymbolID = from.ID item.SymbolName = from.Name } g.Uses = append(g.Uses, item) g.Count++ } out := make([]*usageFileGroup, 0, len(groups)) for _, g := range groups { out = append(out, g) } sort.Slice(out, func(i, j int) bool { if out[i].Count != out[j].Count { return out[i].Count > out[j].Count } return out[i].File < out[j].File }) return map[string]any{ "grouped_by": "file", "file_count": len(out), "total_uses": len(sg.Edges), "groups": out, "truncated": sg.Truncated, } } // usageSummaryOf computes the compact completeness rollup attached to a // find_usages response: the total reference count, the number of // distinct files those references live in, and how many originate in // test files. It reuses the exact per-node test classification // (nodeIsTest — the from_is_test column) and file resolution as the // per-usage rows, so the rollup never disagrees with the edges it // summarizes. Returns nil for an empty result — the zero-edge Caveat // already explains that case, and an all-zero summary would be noise. func usageSummaryOf(sg *query.SubGraph) *query.UsageSummary { if sg == nil || len(sg.Edges) == 0 { return nil } nodeByID := make(map[string]*graph.Node, len(sg.Nodes)) for _, n := range sg.Nodes { nodeByID[n.ID] = n } files := make(map[string]struct{}, len(sg.Edges)) testRefs := 0 for _, e := range sg.Edges { from := nodeByID[e.From] file := e.FilePath if file == "" && from != nil { file = from.FilePath } if file == "" { file = "(unknown)" } files[file] = struct{}{} if nodeIsTest(from) { testRefs++ } } return &query.UsageSummary{ NRefs: len(sg.Edges), NFiles: len(files), NTestRefs: testRefs, } } func (s *Server) handleGetCluster(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := req.RequireString("id") if err != nil { return mcp.NewToolResultError("id is required"), nil } resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } opts := query.QueryOptions{ Depth: req.GetInt("radius", 2), Limit: req.GetInt("limit", 50), Detail: "brief", WorkspaceID: resolved.WorkspaceID, ProjectID: resolved.ProjectID, RepoAllow: resolved.RepoAllow, } sg := s.engineFor(ctx).GetCluster(id, opts) enrichSubGraphEdges(sg) return s.returnScopedSubGraph(ctx, req, sg, resolved) } func (s *Server) handleGraphStats(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return s.respondJSONOrTOON(ctx, req, s.buildGraphStatsPayload(ctx)) } // buildGraphStatsPayload returns the same data the `graph_stats` tool // emits. Shared with the `gortex://stats` resource so both surfaces // stay byte-for-byte equal. func (s *Server) buildGraphStatsPayload(ctx context.Context) map[string]any { stats := s.engineFor(ctx).Stats() result := map[string]any{ "total_nodes": stats.TotalNodes, "total_edges": stats.TotalEdges, "by_kind": stats.ByKind, "by_language": stats.ByLanguage, } // Provenance churn: how many times an in-graph edge's identity // changed because its Origin was upgraded or reverted. Surfaced // as the tamper-evidence signal — a non-zero value means edge // provenance moved since the graph was built. result["edge_identity_revisions"] = s.readerFor(ctx).EdgeIdentityRevisions() if s.multiIndexer != nil && s.multiIndexer.IsMultiRepo() { // BUG_FIX_CONTEXT: an unbounded per-repo dump here made the MCP unusable on large // monorepos. The gortex://stats resource is advertised "read at session start to // orient", so an agent reads it on connect — and a full GraphStats for every one of // the hundreds of tracked sub-repos a monorepo decomposes into overflowed the agent's // context window before any user turn (small repos: IsMultiRepo()==false → no dump → // fine). Cap to the top-N repos by node count + a truncation marker; per-repo detail // for one repo stays available via graph_stats repo=. result["per_repo"] = cappedRepoStats(s.readerFor(ctx).RepoStats(), graphStatsPerRepoCap) } result["token_savings"] = s.tokenStatsFor(ctx).snapshot() if cs := s.cumulativeSavingsSnapshot(); cs != nil { result["cumulative_savings"] = cs } if s.semanticMgr != nil && s.semanticMgr.Enabled() { result["semantic"] = map[string]any{ "enabled": true, "providers": s.semanticMgr.Stats(), } } if ns := s.notificationsStatus(); ns != nil { result["notifications"] = ns } // Merkle-keyed RWR walk cache performance — surfaces whether the // incremental centrality cache is earning its keep (hit rate) and // how many distinct walks it retains. if hits, misses, size, capacity, enabled := s.pprCache.stats(); enabled && (hits+misses) > 0 { ppr := map[string]any{ "hits": hits, "misses": misses, "size": size, "capacity": capacity, } if total := hits + misses; total > 0 { ppr["hit_rate"] = round4(float64(hits) / float64(total)) } result["ppr_cache"] = ppr } return result } // graphStatsPerRepoCap bounds how many per-repo GraphStats entries the // gortex://stats resource / graph_stats tool inlines. On a large monorepo // gortex tracks hundreds of sub-repos; dumping a full GraphStats per repo // into a resource that is read "at session start" overflows an agent's // context window — the bug that made the MCP unusable on big monorepos. const graphStatsPerRepoCap = 25 // cappedRepoStats returns the per_repo rollup verbatim when the repo count is // within the cap, otherwise the top-`limit` repos by TotalNodes plus a // `_truncated` marker pointing at graph_stats repo= for the rest. // Keeps the stats payload bounded regardless of how many repos are tracked. func cappedRepoStats(stats map[string]graph.GraphStats, limit int) map[string]any { out := make(map[string]any, len(stats)+1) if len(stats) <= limit { for k, v := range stats { out[k] = v } return out } type kv struct { name string st graph.GraphStats } arr := make([]kv, 0, len(stats)) for k, v := range stats { arr = append(arr, kv{name: k, st: v}) } sort.Slice(arr, func(i, j int) bool { return arr[i].st.TotalNodes > arr[j].st.TotalNodes }) for i := 0; i < limit; i++ { out[arr[i].name] = arr[i].st } out["_truncated"] = map[string]any{ "shown": limit, "total_repos": len(stats), "note": fmt.Sprintf("per_repo capped to the top %d of %d tracked repos by node count "+ "(context-frugal on monorepos); call graph_stats with repo= for a specific repo.", limit, len(stats)), } return out } // notificationsStatus reports each push-notification channel's live // subscriber count and last-published payload. nil when no broadcaster // is wired (single-shot CLI modes). Consumed by graph_stats / // gortex://stats so a debugging client can see who is subscribed and // what the broadcasters last sent without standing up its own // subscriber. func (s *Server) notificationsStatus() map[string]any { out := map[string]any{} if s.diagBroadcaster != nil { out["diagnostics"] = map[string]any{ "subscribers": s.diagBroadcaster.subscriberCount(), } } if s.readinessBroadcaster != nil { row := map[string]any{ "subscribers": s.readinessBroadcaster.subscriberCount(), } if snap := s.readinessBroadcaster.snapshot(); snap != nil { row["last_state"] = snap } out["workspace_readiness"] = row } if s.healthBroadcaster != nil { row := map[string]any{ "subscribers": s.healthBroadcaster.subscriberCount(), } if snap := s.healthBroadcaster.snapshot(); snap != nil { row["last_snapshot"] = snap } out["daemon_health"] = row } if s.staleRefsBroadcaster != nil { out["stale_refs"] = map[string]any{ "subscribers": s.staleRefsBroadcaster.subscriberCount(), } } if len(out) == 0 { return nil } return out }