项目文件夹

文件
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 行
132 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package mcp
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"github.com/mark3labs/mcp-go/mcp"
toon "github.com/toon-format/toon-go"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/search/rerank"
"github.com/zzet/gortex/internal/telemetry"
)
// minTierParamDescription is the `min_tier` parameter description shared by
// every edge-returning tool. Mentioning the tier vocabulary inline lets agents
// pick an appropriate filter without consulting external docs.
const minTierParamDescription = "Filter edges by minimum confidence tier. " +
"Values (highest to lowest): lsp_resolved (compiler-verified), " +
"lsp_dispatch (interface→impl via semantic provider), " +
"ast_resolved (tree-sitter direct match), " +
"ast_inferred (type heuristic), " +
"text_matched (name-only). When omitted, text_matched edges are " +
"auto-suppressed whenever resolver-verified evidence exists — the " +
"response then carries text_matched_suppressed with the hidden count; " +
"pass min_tier:\"text_matched\" to include them. If the target's file was " +
"re-parsed after the last enrichment pass, the response also carries " +
"suppression_caveat warning the hidden rows may be real. " +
"Use lsp_resolved for high-stakes refactors where false positives are expensive."
const includeSpeculativeParamDescription = "Include best-guess speculative " +
"dynamic-dispatch edges (computed-member calls like obj[name](), getattr, " +
"decorator registries). Default false — these are hidden low-confidence " +
"fan-outs minted only when synthesize_speculative_dispatch is enabled; " +
"set true to surface them (each carries candidate_count + a speculative tier)."
// isCompact checks if the compact flag is set in the request.
func isCompact(req mcp.CallToolRequest) bool {
if v, ok := req.GetArguments()["compact"].(bool); ok {
return v
}
return false
}
// isTOON reports whether the caller requested the TOON wire format
// for this tool call. Selection mirrors `Server.isGCX`:
//
// 1. Explicit `format` arg wins.
// 2. Otherwise the per-session default (driven by MCP `clientInfo`)
// decides — TOON is the second-tier compact format used when a
// client decodes TOON but not GCX. Today no shipping client is
// known to be in this bucket; the helper exists for forward
// compat as plugins evolve.
// 3. Default false — JSON wins.
func (s *Server) isTOON(ctx context.Context, req mcp.CallToolRequest) bool {
if v, ok := req.GetArguments()["format"].(string); ok && v != "" {
return v == "toon"
}
if s == nil {
return false
}
return s.resolveSessionFormat(ctx) == "toon"
}
// toonNodeRow is a TOON-optimized flat representation of a graph node.
type toonNodeRow struct {
ID string `toon:"id"`
Kind string `toon:"kind"`
Name string `toon:"name"`
FilePath string `toon:"file_path"`
StartLine int `toon:"start_line"`
Enclosing string `toon:"enclosing,omitempty"`
EnclosingID string `toon:"enclosing_id,omitempty"`
IsTest bool `toon:"is_test"`
TestRole string `toon:"test_role"`
TestRunner string `toon:"test_runner,omitempty"`
TypeFlavor string `toon:"type_flavor,omitempty"`
UIComponent string `toon:"ui_component,omitempty"`
}
// toonEdgeRow is a TOON-optimized flat representation of a graph edge.
type toonEdgeRow struct {
From string `toon:"from"`
To string `toon:"to"`
Kind string `toon:"kind"`
Origin string `toon:"origin,omitempty"`
Tier string `toon:"tier,omitempty"`
Confidence float64 `toon:"confidence"`
Label string `toon:"label"`
}
// toonCallerNoteRow is a TOON-optimized flat representation of one
// caller's concurrency annotation (get_callers only).
type toonCallerNoteRow struct {
ID string `toon:"id"`
SyncGuarded bool `toon:"sync_guarded"`
SyncGuardedWhy string `toon:"sync_guarded_why,omitempty"`
CrossConcurrent bool `toon:"cross_concurrent"`
CrossConcurrentWhy string `toon:"cross_concurrent_why,omitempty"`
}
// toonSubGraphResult wraps nodes and edges for TOON tabular output.
type toonSubGraphResult struct {
Nodes []toonNodeRow `toon:"nodes"`
Edges []toonEdgeRow `toon:"edges"`
Total int `toon:"total"`
Truncated bool `toon:"truncated"`
Caveat *graph.ZeroEdgeCaveat `toon:"caveat,omitempty"`
TextMatchedSuppressed int `toon:"text_matched_suppressed,omitempty"`
SuppressionCaveat string `toon:"suppression_caveat,omitempty"`
RelatedTools string `toon:"related_tools,omitempty"`
CallerNotes []toonCallerNoteRow `toon:"caller_notes,omitempty"`
UsageSummary *query.UsageSummary `toon:"usage_summary,omitempty"`
}
// toonSearchResult wraps search results for TOON tabular output.
type toonSearchResult struct {
Results []toonNodeRow `toon:"results"`
Total int `toon:"total"`
Truncated bool `toon:"truncated"`
}
// nodesToTOONRows converts graph nodes to flat TOON rows.
func nodesToTOONRows(nodes []*graph.Node) []toonNodeRow {
rows := make([]toonNodeRow, 0, len(nodes))
for _, n := range nodes {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
isTest, _ := n.Meta["is_test"].(bool)
testRole, _ := n.Meta["test_role"].(string)
testRunner, _ := n.Meta["test_runner"].(string)
typeFlavor, _ := n.Meta["type_flavor"].(string)
uiComponent, _ := n.Meta["ui_component"].(string)
encID, encName := graph.EnclosingFromID(n.ID, n.Kind)
rows = append(rows, toonNodeRow{
ID: n.ID,
Kind: string(n.Kind),
Name: n.Name,
FilePath: n.FilePath,
StartLine: n.StartLine,
Enclosing: encName,
EnclosingID: encID,
IsTest: isTest,
TestRole: testRole,
TestRunner: testRunner,
TypeFlavor: typeFlavor,
UIComponent: uiComponent,
})
}
return rows
}
// callerNotesToTOONRows flattens the get_callers concurrency-annotation
// map into TOON rows, sorted by node ID for deterministic output.
// Returns nil for an empty map so the `caller_notes` field is omitted.
func callerNotesToTOONRows(notes map[string]*graph.ConcurrencyAnnotation) []toonCallerNoteRow {
if len(notes) == 0 {
return nil
}
ids := make([]string, 0, len(notes))
for id := range notes {
ids = append(ids, id)
}
sort.Strings(ids)
rows := make([]toonCallerNoteRow, 0, len(ids))
for _, id := range ids {
a := notes[id]
rows = append(rows, toonCallerNoteRow{
ID: id,
SyncGuarded: a.SyncGuarded,
SyncGuardedWhy: a.SyncGuardedWhy,
CrossConcurrent: a.CrossConcurrent,
CrossConcurrentWhy: a.CrossConcurrentWhy,
})
}
return rows
}
// returnSubGraph returns a SubGraph in the requested format (JSON, compact, GCX, or TOON).
// Method on Server so the format negotiation can consult per-session
// client identity (claude-code → gcx, etc.).
func (s *Server) returnSubGraph(ctx context.Context, req mcp.CallToolRequest, sg *query.SubGraph) (*mcp.CallToolResult, error) {
// Decorate nodes with absolute paths once, up front, so every output
// format below surfaces an openable path. The canonical graph nodes
// are copied, never mutated.
sg.Nodes = s.withAbsPaths(sg.Nodes)
// Diagram formats render the subgraph directly — one place serves
// every traversal tool (callers/dependencies/usages/...), so a
// `gortex query ... --format mermaid|dot` gets a real diagram.
switch req.GetString("format", "") {
case "mermaid":
return mcp.NewToolResultText(sg.ToMermaid()), nil
case "dot":
return mcp.NewToolResultText(sg.ToDot()), nil
}
if isCompact(req) {
return mcp.NewToolResultText(compactSubGraph(sg)), nil
}
if s.isGCX(ctx, req) {
tool := requestToolName(req)
if tool == "" {
tool = "subgraph"
}
return s.gcxResponseWithBudget(req)(encodeSubGraph(tool, sg))
}
if s.isTOON(ctx, req) {
return subGraphToTOON(sg)
}
return s.respondJSONOrTOON(ctx, req, sg)
}
// requestToolName extracts the MCP tool name from a CallToolRequest.
// mcp-go surfaces the name on req.Params.Name so we can route multiple
// subgraph-returning tools through the same encoder with distinct
// header tags.
func requestToolName(req mcp.CallToolRequest) string {
return req.Params.Name
}
// returnTOON marshals payload as TOON and returns a text result. It
// goes JSON-first so the on-wire field names match the JSON schema
// every tool already advertises: toon-go honours only `toon:` tags
// and rejects map[int]X / non-string keys outright, but every Gortex
// payload tags its fields with `json:` (we don't double-tag with
// `toon:`). Round-tripping through JSON gives us tag-driven naming
// and string-key normalisation (Go's encoding/json stringifies int
// keys) for free, with a single allocation we can amortise across
// the tool surface.
//
// Falls back to JSON on encoder error so a malformed payload can
// never take down the response — the caller never sees a half-
// written document.
func returnTOON(payload any) (*mcp.CallToolResult, error) {
jsonBytes, err := json.Marshal(payload)
if err != nil {
return mcp.NewToolResultJSON(payload)
}
var generic any
if err := json.Unmarshal(jsonBytes, &generic); err != nil {
return mcp.NewToolResultJSON(payload)
}
data, err := toon.Marshal(generic)
if err != nil {
return mcp.NewToolResultJSON(payload)
}
return mcp.NewToolResultText(string(data)), nil
}
// respondJSONOrTOON is the bottom-of-the-handler decision shared by
// every tool that advertises `format` in its schema and lets a
// per-tool GCX encoder run ahead of it. It returns TOON when the
// caller (or the per-session default) asks for it and JSON otherwise.
// GCX is handled inline at the call site because GCX uses hand-tuned
// per-tool encoders rather than reusing the JSON shape.
//
// Three pipeline stages run before the format encoder:
//
// 1. Sparse-fieldsets filter: when the caller passes
// `fields: "id,line"`, list rows are projected down to those keys.
// Trims response size at the row level.
// 2. Graceful degradation: tools that registered a per-shape policy
// (`get_file_summary`, `get_editing_context`, `find_usages`, …)
// run a cascade — strip verbose meta, drop low-priority kinds,
// last-resort tail-trim. Quality stays high under pressure.
// 3. Generic budget: tools without a registered shape fall back to
// a "trim the longest list" heuristic. Always emits inline data
// with `_truncated_by_budget` metadata; never falls through to
// a transport spill that the agent has to re-read from disk.
//
// effectiveBudget defaults to defaultMaxBytes when the caller does
// not specify; pass `max_bytes: 0` to opt out of budgeting and get
// the full result in one shot (transport spill if oversized).
func (s *Server) respondJSONOrTOON(ctx context.Context, req mcp.CallToolRequest, payload any) (*mcp.CallToolResult, error) {
payload = applyFieldsFilter(payload, parseFields(req.GetString("fields", "")))
if budget := effectiveBudget(req); budget > 0 {
var trimmed bool
if shape, ok := degradeShapes[req.Params.Name]; ok {
payload, trimmed = applyDegradation(payload, shape, budget)
} else {
payload, trimmed = applyBudget(payload, budget)
}
if trimmed {
payload = decorateTokenBudgetJSON(payload, req)
}
}
// TOON is the right fallback whenever the caller (or the
// per-session default) asked for a compact format. That covers
// two cases:
//
// 1. Explicit `format=toon` — return TOON.
// 2. Session default is gcx but this tool does not have a
// hand-tuned GCX encoder (status-shape tools like graph_stats
// / index_health / list_repos go through this path). Falling
// back to TOON instead of JSON keeps the response compact —
// ~10–15% smaller than JSON for typical payloads — without
// forcing every status tool to ship a bespoke GCX encoder.
//
// Plain JSON is still the answer when neither toon nor gcx was
// requested (unknown clients, explicit `format=json`).
if s.isTOON(ctx, req) || s.isGCX(ctx, req) {
return returnTOON(payload)
}
return mcp.NewToolResultJSON(payload)
}
// subGraphToTOON converts a SubGraph to a TOON-encoded text result.
func subGraphToTOON(sg *query.SubGraph) (*mcp.CallToolResult, error) {
var edgeRows []toonEdgeRow
for _, e := range sg.Edges {
label := e.ConfidenceLabel
if label == "" {
label = graph.ConfidenceLabelFor(e.Kind, e.Confidence)
}
tier := e.Tier
if tier == "" {
tier = graph.ResolvedBy(e.Origin)
}
edgeRows = append(edgeRows, toonEdgeRow{
From: e.From,
To: e.To,
Kind: string(e.Kind),
Origin: e.Origin,
Tier: tier,
Confidence: e.Confidence,
Label: label,
})
}
result := toonSubGraphResult{
Nodes: nodesToTOONRows(sg.Nodes),
Edges: edgeRows,
Total: sg.TotalNodes,
Truncated: sg.Truncated,
Caveat: sg.Caveat,
TextMatchedSuppressed: sg.TextMatchedSuppressed,
SuppressionCaveat: sg.SuppressionCaveat,
RelatedTools: sg.RelatedTools,
CallerNotes: callerNotesToTOONRows(sg.CallerNotes),
UsageSummary: sg.UsageSummary,
}
data, err := toon.Marshal(result)
if err != nil {
return mcp.NewToolResultJSON(sg)
}
return mcp.NewToolResultText(string(data)), nil
}
// resolveRepoFilter resolves the optional repo/project/ref params into
// a set of allowed repo prefixes, enforced against the session's
// workspace boundary.
//
// For a workspace-bound session (the daemon socket path) the boundary
// is mandatory and cannot be widened by args: a `workspace` arg may
// only name the session's own workspace, and `repo`/`project`/`ref`
// args are intersected with the workspace so they can only ever
// narrow. With no explicit narrowing the allow-set is every repo in
// the session's workspace — not "all repos".
//
// gitDiffScopes are the diff-selection values the review-family tools
// accept in their `scope` argument; they share the argument name with the
// saved-scope filter and are reserved (never saved-scope names).
var gitDiffScopes = map[string]bool{"unstaged": true, "staged": true, "all": true, "compare": true}
// For an unbound session (embedded stdio / `gortex server
// --workspace` / legacy) it falls back to resolveRepoFilterArgs with
// the active-project default applied. A nil result there still means
// "no filter — all repos".
func (s *Server) resolveRepoFilter(ctx context.Context, req mcp.CallToolRequest) (map[string]bool, error) {
repo := strings.TrimSpace(req.GetString("repo", ""))
if repo != "*" {
if p := s.resolveRepoPrefix(repo); p != "" {
repo = p
}
}
project := strings.TrimSpace(req.GetString("project", ""))
ref := strings.TrimSpace(req.GetString("ref", ""))
workspaceArg := strings.TrimSpace(req.GetString("workspace", ""))
scopeArg := strings.TrimSpace(req.GetString("scope", ""))
if gitDiffScopes[scopeArg] {
scopeArg = ""
}
var scopeRepos map[string]bool
if scopeArg != "" && repo == "" && project == "" && ref == "" {
sc, ok := s.lookupScope(scopeArg)
if !ok {
return nil, fmt.Errorf("unknown scope %q — run list_scopes to see saved scopes, or create one with save_scope", scopeArg)
}
scopeRepos = s.scopeRepoSet(sc)
if len(scopeRepos) == 0 {
return nil, fmt.Errorf("saved scope %q names no repositories", scopeArg)
}
}
if sessWS, _, bound := s.sessionScope(ctx); bound {
if workspaceArg != "" && workspaceArg != sessWS {
return nil, fmt.Errorf(
"workspace %q is outside the active workspace %q; cross-workspace queries are not permitted",
workspaceArg, sessWS)
}
wsRepos := map[string]bool{}
if s.multiIndexer != nil {
wsRepos = s.multiIndexer.ReposInWorkspace(sessWS)
}
if scopeRepos != nil {
intersected := intersectRepoSets(scopeRepos, wsRepos)
if len(intersected) == 0 {
return nil, fmt.Errorf(
"saved scope %q resolves to nothing inside the active workspace %q",
scopeArg, sessWS)
}
return intersected, nil
}
if repo == "*" {
repo = ""
}
if repo == "" && project == "" && ref == "" {
return wsRepos, nil
}
narrowed, err := s.resolveRepoFilterArgs(repo, project, ref, false)
if err != nil {
return nil, err
}
if narrowed == nil {
return wsRepos, nil
}
intersected := intersectRepoSets(narrowed, wsRepos)
if len(intersected) == 0 {
return nil, fmt.Errorf(
"repo/project/ref filter resolves to nothing inside the active workspace %q; cross-workspace queries are not permitted",
sessWS)
}
return intersected, nil
}
if scopeRepos != nil {
return scopeRepos, nil
}
if repo == "*" {
repo = ""
}
return s.resolveRepoFilterArgs(repo, project, ref, true)
}
// resolveRepoFilterArgs folds explicit repo/project/ref args into a
// single allow-set of repo prefixes. A nil map means "no filter — all
// repos". When useActiveProjectDefault is true and no axis is given,
// the server's active project is applied as the default scope (the
// legacy single-tenant behaviour); workspace-bound callers pass false
// because their boundary is supplied separately.
//
// An explicit unknown project is a hard error (the caller asked for X
// by name, deserves to know X is wrong); a stale active-project
// default degrades to "no filter" with a warning log instead, so a
// single misconfigured config line does not break every MCP call.
func (s *Server) resolveRepoFilterArgs(repo, project, ref string, useActiveProjectDefault bool) (map[string]bool, error) {
projectFromActive := false
if useActiveProjectDefault && repo == "" && project == "" && ref == "" && s.activeProject != "" {
project = s.activeProject
projectFromActive = true
}
if repo == "" && project == "" && ref == "" {
return nil, nil // no filter — search all repos
}
// Direct repo filter — just that one prefix.
if repo != "" {
return map[string]bool{repo: true}, nil
}
// Resolve project/ref via ConfigManager.
if s.configManager == nil {
return nil, fmt.Errorf("configuration manager is not available")
}
gc := s.configManager.Global()
var entries []config.RepoEntry
if project != "" {
repos, err := gc.ResolveRepos(project)
if err != nil {
if projectFromActive {
// Stale active-project default. Log and degrade to no
// filter (all repos) so the call still succeeds. This
// mirrors ConfigManager.ActiveRepos behavior.
if s.logger != nil {
s.logger.Warn("active project not resolvable, falling back to all repos",
zap.String("active_project", project),
zap.Error(err))
}
return nil, nil
}
return nil, err
}
entries = repos
} else {
// ref without project — collect all repos from all projects.
for _, proj := range gc.Projects {
entries = append(entries, proj.Repos...)
}
// Also include top-level repos.
entries = append(entries, gc.Repos...)
}
// Apply ref filter if set.
allowed := make(map[string]bool)
for _, e := range entries {
if ref != "" && e.Ref != ref {
continue
}
allowed[config.ResolvePrefix(e)] = true
}
return allowed, nil
}
// filterNodes returns only nodes whose RepoPrefix is in the allowed set.
// If allowed is nil, returns the original slice unchanged.
func filterNodes(nodes []*graph.Node, allowed map[string]bool) []*graph.Node {
if allowed == nil {
return nodes
}
var out []*graph.Node
for _, n := range nodes {
// In single-repo mode, nodes have empty RepoPrefix — always include them.
if n.RepoPrefix == "" || allowed[n.RepoPrefix] {
out = append(out, n)
}
}
return out
}
func filterNodesByResolvedScope(nodes []*graph.Node, resolved ResolvedScope) []*graph.Node {
if !resolvedScopeHasFilter(resolved) {
return nodes
}
opts := queryOptionsForResolvedScope(resolved)
out := make([]*graph.Node, 0, len(nodes))
for _, n := range nodes {
if n != nil && opts.ScopeAllows(n) {
out = append(out, n)
}
}
return out
}
func queryOptionsForResolvedScope(resolved ResolvedScope) query.QueryOptions {
return query.QueryOptions{
WorkspaceID: resolved.WorkspaceID,
ProjectID: resolved.ProjectID,
RepoAllow: resolved.RepoAllow,
}
}
func resolvedScopeHasFilter(resolved ResolvedScope) bool {
return resolved.WorkspaceID != "" || resolved.ProjectID != "" || len(resolved.RepoAllow) > 0
}
func resolvedScopeAllowsNode(resolved ResolvedScope, n *graph.Node) bool {
if n == nil {
return false
}
return queryOptionsForResolvedScope(resolved).ScopeAllows(n)
}
func filterSubGraphByResolvedScope(sg *query.SubGraph, resolved ResolvedScope) *query.SubGraph {
if sg == nil || !resolvedScopeHasFilter(resolved) {
return sg
}
opts := queryOptionsForResolvedScope(resolved)
nodeIDs := make(map[string]bool, len(sg.Nodes))
nodes := make([]*graph.Node, 0, len(sg.Nodes))
for _, n := range sg.Nodes {
if n != nil && opts.ScopeAllows(n) {
nodes = append(nodes, n)
nodeIDs[n.ID] = true
}
}
edges := make([]*graph.Edge, 0, len(sg.Edges))
for _, e := range sg.Edges {
if e != nil && nodeIDs[e.From] && nodeIDs[e.To] {
edges = append(edges, e)
}
}
out := *sg
out.Nodes = nodes
out.Edges = edges
out.TotalNodes = len(nodes)
out.TotalEdges = len(edges)
if sg.CallerNotes != nil {
out.CallerNotes = make(map[string]*graph.ConcurrencyAnnotation, len(sg.CallerNotes))
for id, ann := range sg.CallerNotes {
if nodeIDs[id] {
out.CallerNotes[id] = ann
}
}
if len(out.CallerNotes) == 0 {
out.CallerNotes = nil
}
}
return &out
}
func filterEdgesByResolvedScope(eng *query.Engine, edges []*graph.Edge, resolved ResolvedScope) []*graph.Edge {
if eng == nil || !resolvedScopeHasFilter(resolved) {
return edges
}
out := make([]*graph.Edge, 0, len(edges))
for _, e := range edges {
if e == nil {
continue
}
if resolvedScopeAllowsNode(resolved, eng.GetSymbol(e.From)) &&
resolvedScopeAllowsNode(resolved, eng.GetSymbol(e.To)) {
out = append(out, e)
}
}
return out
}
// filterNodesByKind keeps only nodes whose Kind is in the comma-
// separated list. Empty / unknown kinds in the input are ignored
// (treated as "no constraint of this name") so a typo is graceful
// rather than silently empty. Case-insensitive.
//
// Used by search_symbols' `kind` argument — lets callers scope a
// query to one of the domain-specific node kinds (todo, license,
// team, …) without paying the cost of an unrelated BM25 prefix
// match.
func filterNodesByKind(nodes []*graph.Node, kindArg string) []*graph.Node {
want := make(map[string]struct{})
for k := range strings.SplitSeq(kindArg, ",") {
k = strings.TrimSpace(strings.ToLower(k))
if k == "" {
continue
}
want[k] = struct{}{}
}
if len(want) == 0 {
return nodes
}
out := make([]*graph.Node, 0, len(nodes))
for _, n := range nodes {
if _, ok := want[strings.ToLower(string(n.Kind))]; ok {
out = append(out, n)
}
}
return out
}
// filterSubGraph returns a new SubGraph with only nodes/edges whose endpoints
// are in the allowed repo set. If allowed is nil, returns sg unchanged.
func filterSubGraph(sg *query.SubGraph, allowed map[string]bool) *query.SubGraph {
if allowed == nil {
return sg
}
nodeIDs := make(map[string]bool)
var nodes []*graph.Node
for _, n := range sg.Nodes {
if n.RepoPrefix == "" || allowed[n.RepoPrefix] {
nodes = append(nodes, n)
nodeIDs[n.ID] = true
}
}
var edges []*graph.Edge
for _, e := range sg.Edges {
if nodeIDs[e.From] || nodeIDs[e.To] {
edges = append(edges, e)
}
}
totalEdges := len(edges)
// Counts-only payloads arrive with Edges == nil and TotalEdges
// pre-populated — preserve the upstream count instead of zeroing
// it. Inexact in the presence of a non-trivial filter (we'd need
// the edges to know which belong to filtered-out nodes), but the
// gcx output that asks for the count-only path runs with the
// session's workspace scope already applied at the store, so the
// filter pass is typically a no-op.
if len(sg.Edges) == 0 && sg.TotalEdges > 0 {
totalEdges = sg.TotalEdges
}
return &query.SubGraph{
Nodes: nodes,
Edges: edges,
TotalNodes: len(nodes),
TotalEdges: totalEdges,
Truncated: sg.Truncated,
}
}
// compactNodes formats nodes as one-line-per-symbol text.
// Format: "kind qualifiedName file_path:start_line"
// For methods, qualifiedName includes the receiver (e.g., "Indexer.Index")
// so the output can be combined with file_path to reconstruct the full node ID.
func compactNodes(nodes []*graph.Node) string {
var b strings.Builder
for _, n := range nodes {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
fmt.Fprintf(&b, "%s %s %s:%d", n.Kind, qualifiedName(n), n.FilePath, n.StartLine)
// Append the enclosing owner when the node is declared inside
// one -- a method on a type, a field of a struct, a closure.
if _, ename := graph.EnclosingFromID(n.ID, n.Kind); ename != "" {
fmt.Fprintf(&b, " (in %s)", ename)
}
b.WriteByte('\n')
}
return b.String()
}
// qualifiedName returns the symbol part of a node ID (after "::").
// For methods this includes the receiver type (e.g., "Indexer.Index"),
// for functions/types it's the plain name.
func qualifiedName(n *graph.Node) string {
if idx := strings.LastIndex(n.ID, "::"); idx >= 0 {
return n.ID[idx+2:]
}
return n.Name
}
// enrichSubGraphEdges populates ConfidenceLabel, Origin, and Tier on every
// edge in a SubGraph. Origin is backfilled from kind + confidence +
// semantic_source meta when unset; Tier is the coarse (ast / lsp /
// heuristic) provenance label derived from Origin so clients can filter
// or group without recomputing the mapping.
func enrichSubGraphEdges(sg *query.SubGraph) {
for _, e := range sg.Edges {
e.ConfidenceLabel = graph.ConfidenceLabelFor(e.Kind, e.Confidence)
if e.Origin == "" {
src, _ := e.Meta["semantic_source"].(string)
e.Origin = graph.DefaultOriginFor(e.Kind, e.Confidence, src)
}
e.Tier = graph.ResolvedBy(e.Origin)
if e.Meta != nil {
if v, _ := e.Meta["via"].(string); v != "" {
e.Via = graph.ViaLabelFor(v)
}
}
}
}
// isNonDefinitionNode reports whether a node kind is NOT a file-level
// definition and should be dropped from a get_file_summary view. It
// excludes the file node itself, imports, and the function-body-internal
// nodes (locals, params, closures, generic params, builtins) that the
// file_path lookup pulls in but that the "symbols a file defines"
// contract never wanted. Without this filter the summary floods with
// hundreds of locals/params (the old defines-edge query excluded them by
// construction; the GetFileNodes-based path does not).
func isNonDefinitionNode(k graph.NodeKind) bool {
switch k {
case graph.KindFile, graph.KindImport, graph.KindLocal,
graph.KindParam, graph.KindClosure, graph.KindGenericParam,
graph.KindBuiltin:
return true
}
return false
}
// stripNonDefinitionNodes returns a copy of sg with non-definition nodes
// nodes removed (and edges that reference them dropped). Used by
// handleGetFileSummary to keep its output focused on the symbols a
// file *defines* — the file node and per-statement import nodes are
// useful internals (e.g. for the file-neighbourhood walk that drives
// the disk-backend pushdown) but noise in the agent-visible payload.
func stripNonDefinitionNodes(sg *query.SubGraph) *query.SubGraph {
if sg == nil {
return nil
}
keep := make(map[string]bool, len(sg.Nodes))
nodes := make([]*graph.Node, 0, len(sg.Nodes))
for _, n := range sg.Nodes {
if n == nil || isNonDefinitionNode(n.Kind) {
continue
}
nodes = append(nodes, n)
keep[n.ID] = true
}
edges := make([]*graph.Edge, 0, len(sg.Edges))
for _, e := range sg.Edges {
if e == nil || !keep[e.From] || !keep[e.To] {
continue
}
edges = append(edges, e)
}
totalEdges := len(edges)
// Counts-only payloads arrive with Edges == nil and TotalEdges
// already populated by the store. Keep that count — the file +
// import nodes we're stripping pulled some edges with them so it's
// a slight overcount, but the gcx callers that take this path
// only render it as a header scalar, not as anything load-bearing.
if len(sg.Edges) == 0 && sg.TotalEdges > 0 {
totalEdges = sg.TotalEdges
}
return &query.SubGraph{
Nodes: nodes,
Edges: edges,
TotalNodes: len(nodes),
TotalEdges: totalEdges,
Truncated: sg.Truncated,
CallerNotes: sg.CallerNotes,
}
}
// compactSubGraph formats a SubGraph as compact text.
func compactSubGraph(sg *query.SubGraph) string {
var b strings.Builder
for _, n := range sg.Nodes {
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
fmt.Fprintf(&b, "%s %s %s:%d\n", n.Kind, qualifiedName(n), n.FilePath, n.StartLine)
}
if sg.Truncated {
fmt.Fprintf(&b, "... truncated (%d total)\n", sg.TotalNodes)
}
// Append edge confidence distribution.
if len(sg.Edges) > 0 {
counts := map[string]int{}
for _, e := range sg.Edges {
label := e.ConfidenceLabel
if label == "" {
label = graph.ConfidenceLabelFor(e.Kind, e.Confidence)
}
counts[label]++
}
fmt.Fprintf(&b, "edges: %d total", len(sg.Edges))
for _, label := range []string{"EXTRACTED", "INFERRED", "AMBIGUOUS"} {
if c := counts[label]; c > 0 {
fmt.Fprintf(&b, ", %d %s", c, label)
}
}
b.WriteByte('\n')
}
// Append concurrency annotations for callers that carry one. Sorted
// by node ID so the compact output is deterministic.
if len(sg.CallerNotes) > 0 {
ids := make([]string, 0, len(sg.CallerNotes))
for id := range sg.CallerNotes {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
a := sg.CallerNotes[id]
fmt.Fprintf(&b, "concurrency %s:", id)
if a.SyncGuarded {
b.WriteString(" sync_guarded")
}
if a.CrossConcurrent {
b.WriteString(" cross_concurrent")
}
b.WriteByte('\n')
}
}
return b.String()
}
func (s *Server) registerCoreTools() {
s.addTool(
mcp.NewTool("index_repository",
mcp.WithDescription("Index or re-index a local repository path into Gortex. Call once at session start if not already running with --watch."),
mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path to repository")),
),
s.handleIndexRepository,
)
s.addTool(
mcp.NewTool("reindex_repository",
mcp.WithDescription("Incrementally re-index a repository: re-parses only the files that changed since the last index pass (mtime, or content under Merkle mode) and evicts nodes for deleted files. Much cheaper than index_repository, which rebuilds the whole graph. Pass `paths` to scope the pass to specific files or directories; omit it to scan the whole repository. Returns what was reindexed plus node/edge counts, the stale-file count, and timing."),
mcp.WithString("path", mcp.Description("Absolute path to the repository, or a tracked repo prefix. Defaults to the active repository.")),
mcp.WithArray("paths",
mcp.Description("Optional list of file or directory paths to scope the reindex to — absolute, or relative to the repository root. Directories are walked recursively. When omitted, the whole repository root is scanned."),
mcp.WithStringItems(),
),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")),
),
s.handleReindexRepository,
)
s.addTool(
mcp.NewTool("get_symbol",
mcp.WithDescription("Use instead of Read to locate a function, type, interface, or variable definition. Returns location and signature without reading the whole file."),
mcp.WithString("id", mcp.Required(), mcp.Description("Node ID (e.g. pkg/server.go::HandleRequest)")),
mcp.WithString("detail", mcp.Description("brief or full (default: brief)")),
mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")),
mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")),
mcp.WithString("ref", mcp.Description("Filter results to repositories with a specific reference tag")),
mcp.WithString("workspace", mcp.Description("Restrict results to the active workspace slug; daemon sessions may only name their own workspace.")),
mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories. Ignored when an explicit repo / project / ref is also given.")),
),
s.handleGetSymbol,
)
s.addTool(
mcp.NewTool("search_symbols",
mcp.WithDescription("Use instead of Grep to find symbols across the whole codebase. Supports natural language queries with camelCase-aware tokenization and BM25 ranking — 'validate token auth' finds validateToken, AuthMiddleware, parseJWT. Localizing a whole task or bug report (not one symbol)? explore returns the ranked neighborhood with source + call paths in one call. For the concrete types that implement an interface, or the methods that override a method, use find_implementations / find_overrides — a name search alone can't tell an implementor from an unrelated same-named symbol."),
mcp.WithString("query", mcp.Required(), mcp.Description("Search query — symbol name, concept, or keywords. Also accepts inline field-qualified clauses: `kind:function lang:go path:internal/ repo:gortex project:web validateToken` — recognised fields are kind, flavor, lang (aliases ts/js/py/rs/…), path, repo, project; everything else is free text. A field-qualified query that matches nothing retries on the free text alone (response carries `filters_relaxed: true`).")),
mcp.WithNumber("limit", mcp.Description("Max results (default: 20)")),
mcp.WithString("cursor", mcp.Description("Opaque pagination cursor returned in `next_cursor` from a previous call. Pass it back to fetch the next page. Omit for the first page.")),
mcp.WithBoolean("paginate", mcp.Description("When true, the server caps each page at the project default budget and returns `next_cursor` for any tail. Implies the caller will follow `next_cursor` to walk the rest. Default false (full result inline; transport spills to disk if oversized).")),
mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed and `_truncated_by_budget` plus `_max_returned_<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
}