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

223 lines
8.1 KiB
Go

package mcp
import (
"context"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/search/trigram"
)
// enrichedTextMatch is a trigram literal-search hit decorated with the
// graph symbol that encloses the matching line. symbol_id /
// symbol_name are empty for a match in a file-level region with no
// enclosing function / method / type.
type enrichedTextMatch struct {
Path string `json:"path"`
Line int `json:"line"`
Text string `json:"text"`
SymbolID string `json:"symbol_id,omitempty"`
SymbolName string `json:"symbol_name,omitempty"`
}
// handleSearchText runs a trigram-accelerated literal code search
// across the indexed repository -- the alt grep backbone. A trigram
// index narrows the file set, then each candidate is scanned to
// confirm the match, so a repo-wide substring search costs roughly
// the size of the matching files rather than the whole tree.
//
// Each hit is enriched with the enclosing graph symbol so an agent
// can see *which function / method* a literal match landed in
// without a follow-up get_symbol call.
func (s *Server) handleSearchText(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
query := req.GetString("query", "")
if query == "" {
return mcp.NewToolResultError("search_text: query is required"), nil
}
if s.indexer == nil && s.multiIndexer == nil {
return mcp.NewToolResultError("search_text: no indexer available"), nil
}
resolved, errResult := s.resolveScope(ctx, req, IntentLocate)
if errResult != nil {
return errResult, nil
}
limit := req.GetInt("limit", 100)
if limit < 1 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
// Multi-repo mode: the daemon owns a MultiIndexer and the per-repo
// Indexer pointer (s.indexer) is unset or empty-rooted. Fan out
// across every tracked repo's trigram searcher and stamp repo
// prefixes on the match paths so downstream tooling sees the same
// shape graph nodes use. Single-indexer callers (one-shot CLI,
// tests) fall through to the legacy path.
//
// regexp mode runs the same trigram-accelerated backbone through a
// compiled regular expression instead of a literal substring; a
// bad pattern surfaces as a tool error rather than zero hits, and
// the results flow through the identical enclosing-symbol
// enrichment so callers get the same shape either way.
useRegexp := req.GetBool("regexp", false)
pathFilter := s.resolvePathFilter(req, fieldQuery{})
scopedMultiGrep := s.multiIndexer != nil && (resolved.RepoAllow != nil || len(pathFilter) > 0)
var matches []trigram.Match
needsFinalLimit := false
if useRegexp {
var err error
if scopedMultiGrep {
matches, err = s.multiIndexer.GrepRegexpForRepos(query, "", resolved.RepoAllow, limit)
needsFinalLimit = true
} else if s.multiIndexer != nil {
matches, err = s.multiIndexer.GrepRegexp(query, "", limit)
} else {
matches, err = s.indexer.GrepRegexp(query, "", limit)
}
if err != nil {
return mcp.NewToolResultError("search_text: invalid regexp: " + err.Error()), nil
}
} else if scopedMultiGrep {
matches = s.multiIndexer.GrepTextForRepos(query, resolved.RepoAllow, limit)
needsFinalLimit = true
} else if s.multiIndexer != nil {
matches = s.multiIndexer.GrepText(query, limit)
} else {
matches = s.indexer.GrepText(query, limit)
}
// Sub-path scoping: a `path` argument or a `scope:`-named saved
// scope's paths narrow the literal hits to a monorepo service
// slice. In multi-repo mode MultiIndexer.GrepText stamps a repo
// prefix onto every match path, so the repo-relative filter is
// expanded with the tracked repo prefixes before the anchored test.
if len(pathFilter) > 0 {
var repoPrefixes []string
if s.multiIndexer != nil {
repoPrefixes = s.multiIndexer.RepoPrefixes()
}
matches = filterTextMatchesByPath(matches, pathFilter, repoPrefixes)
}
matches = s.filterTextMatchesByResolvedScope(matches, resolved)
if needsFinalLimit {
matches = limitTextMatches(matches, limit)
}
enriched := s.enrichTextMatches(matches)
resp := map[string]any{
"query": query,
"matches": enriched,
"count": len(enriched),
}
// Body-visible disclosure for a repo-narrowed zero (the _meta scope
// fields are invisible in CLI output and most clients). No recheck
// here — the note still names the scope and the widen escape hatch.
if len(enriched) == 0 && len(resolved.RepoAllow) > 0 {
resp["scope_note"] = scopeZeroNote(resolved, -1)
}
return s.respondScopedJSONOrTOON(ctx, req, resp, resolved)
}
// filterTextMatchesByPath keeps only the trigram matches whose file
// path sits under one of the anchored sub-path prefixes. repoPrefixes
// carries the tracked repo prefixes (empty in single-repo mode) so a
// repo-relative filter still matches the repo-prefixed paths that
// MultiIndexer.GrepText stamps onto matches in multi-repo mode.
func filterTextMatchesByPath(matches []trigram.Match, paths, repoPrefixes []string) []trigram.Match {
norm := normalizePathPrefixes(paths)
if len(norm) == 0 {
return matches
}
prefixes := expandPathPrefixesWithRepos(norm, repoPrefixes)
out := make([]trigram.Match, 0, len(matches))
for _, m := range matches {
if pathMatchesAnyPrefix(m.Path, prefixes) {
out = append(out, m)
}
}
return out
}
func limitTextMatches(matches []trigram.Match, limit int) []trigram.Match {
if limit > 0 && len(matches) > limit {
return matches[:limit]
}
return matches
}
func (s *Server) filterTextMatchesByResolvedScope(matches []trigram.Match, resolved ResolvedScope) []trigram.Match {
if resolved.WorkspaceID == "" && resolved.ProjectID == "" && len(resolved.RepoAllow) == 0 {
return matches
}
opts := query.QueryOptions{
WorkspaceID: resolved.WorkspaceID,
ProjectID: resolved.ProjectID,
RepoAllow: resolved.RepoAllow,
}
out := make([]trigram.Match, 0, len(matches))
for _, m := range matches {
repo, rest, ok := strings.Cut(m.Path, "/")
// Repo allow-set fast path: a match whose stamped repo prefix
// is outside the allow-set is dropped outright. Only applies
// when the first path segment actually names a tracked repo —
// on an unstamped (single-indexer / unprefixed) path the first
// segment is an ordinary directory and must not be mistaken
// for a repo prefix. The node-attribution check below stays
// authoritative either way.
knownRepo := ok && repo != "" && s.multiIndexer != nil && s.multiIndexer.GetMetadata(repo) != nil
if len(resolved.RepoAllow) > 0 && knownRepo && !resolved.RepoAllow[repo] {
continue
}
// Fail CLOSED under active narrowing: keep a match only when it
// can be positively attributed to an in-scope graph node. A match
// whose path resolves to no node (graph unavailable, or a file the
// graph never turned into a node) cannot be proven in-scope, so
// dropping it is the safe choice — keeping it was a latent
// cross-scope leak.
if s.graph == nil {
continue
}
n := s.graph.GetNode(m.Path)
if n == nil && knownRepo {
// GrepTextForRepos stamps the registry prefix onto every
// match path, but a repo indexed in single-repo mode minted
// UNPREFIXED node IDs (RepoMetadata.Unprefixed) — the
// stamped path misses the graph key. Retry with the prefix
// stripped so attribution works in a lone-repo daemon.
if meta := s.multiIndexer.GetMetadata(repo); meta != nil && meta.Unprefixed {
n = s.graph.GetNode(rest)
}
}
if n == nil || !opts.ScopeAllows(n) {
continue
}
out = append(out, m)
}
return out
}
// enrichTextMatches decorates every trigram match with its enclosing
// graph symbol. It builds one per-file symbol index for the set of
// matched files, then resolves each match's line through it.
func (s *Server) enrichTextMatches(matches []trigram.Match) []enrichedTextMatch {
out := make([]enrichedTextMatch, 0, len(matches))
paths := make(map[string]struct{}, len(matches))
for _, m := range matches {
paths[m.Path] = struct{}{}
}
idx := s.buildFileSymbolIndexForPaths(paths)
for _, m := range matches {
em := enrichedTextMatch{Path: m.Path, Line: m.Line, Text: m.Text}
if fi := idx[m.Path]; fi != nil {
em.SymbolID, em.SymbolName = fi.find(m.Line)
}
out = append(out, em)
}
return out
}