Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

3086 lines
132 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 —
// ~1015% 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_<key>` / `_original_count_<key>` 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=<prefix>.
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=<prefix> 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=<prefix> 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
}