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

191 lines
6.5 KiB
Go

package mcp
import (
"context"
"path"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
querypkg "github.com/zzet/gortex/internal/query"
)
// registerFindFilesTool wires `find_files` — the dedicated search-by-
// file-NAME tool. File (KindFile) nodes are deliberately excluded from
// the BM25 symbol index (Indexer.shouldIndexForSearch), so neither
// search_symbols nor search_text can find a file by its name. This
// tool closes that gap: it enumerates the KindFile bucket directly and
// ranks by how well the query matches the basename (exact > prefix >
// substring), falling back to a path-substring match, with optional
// glob and fuzzy-subsequence matching.
func (s *Server) registerFindFilesTool() {
s.addTool(
mcp.NewTool("find_files",
mcp.WithDescription("Find source files by NAME — the file-name counterpart of search_symbols. "+
"Pass `query` for a basename/path match (ranked exact > prefix > substring), `glob` for a "+
"path glob (e.g. \"internal/**/*_test.go\", \"*.proto\"), or both. Use this instead of search_symbols "+
"kind:file (which cannot return file nodes). Returns repo-relative paths with the repo prefix and "+
"language, ready to hand to get_file_summary / read_file. Pass format:\"gcx\" for the compact wire format."),
mcp.WithString("query", mcp.Description("Filename or path substring to match (case-insensitive). Ranked basename-exact > prefix > substring > path-substring.")),
mcp.WithString("glob", mcp.Description("Path glob over the repo-relative path. `*` stays within a segment, `**` crosses segments, a bare basename pattern like `*_test.go` matches basenames. ANDed with `query` when both are given.")),
mcp.WithBoolean("fuzzy", mcp.Description("Also accept fuzzy subsequence matches of `query` against the basename (e.g. \"tcgo\" matches \"tools_coding.go\"). Lowest-ranked. Default false.")),
mcp.WithString("path", mcp.Description("Restrict to one or more anchored sub-path prefixes (comma-separated), repo-root-relative — the monorepo-service slice.")),
mcp.WithString("repo", mcp.Description("Restrict to a single repository prefix.")),
mcp.WithString("project", mcp.Description("Restrict to repositories in a specific project.")),
mcp.WithString("workspace", mcp.Description("Restrict 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) -- its repositories and paths narrow the matches. Ignored for repositories when an explicit repo / project / ref is also given.")),
mcp.WithNumber("limit", mcp.Description("Max files to return (default 50, capped at 500).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (compact wire format), or toon.")),
),
s.handleFindFiles,
)
}
// fileHit is one ranked find_files result.
type fileHit struct {
Path string `json:"path"`
Repo string `json:"repo,omitempty"`
Language string `json:"language,omitempty"`
ID string `json:"id"`
score int
depth int
}
func (s *Server) handleFindFiles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if s.graph == nil {
return mcp.NewToolResultError("find_files: no graph available"), nil
}
query := strings.TrimSpace(req.GetString("query", ""))
glob := strings.TrimSpace(req.GetString("glob", ""))
if query == "" && glob == "" {
return mcp.NewToolResultError("find_files: pass `query` (a filename/path substring) and/or `glob` (a path glob)"), nil
}
fuzzy := req.GetBool("fuzzy", false)
resolved, errResult := s.resolveScope(ctx, req, IntentLocate)
if errResult != nil {
return errResult, nil
}
limit := req.GetInt("limit", 50)
if limit < 1 {
limit = 50
}
if limit > 500 {
limit = 500
}
pathFilter := s.resolvePathFilter(req, fieldQuery{})
scopeOpts := querypkg.QueryOptions{
WorkspaceID: resolved.WorkspaceID,
ProjectID: resolved.ProjectID,
RepoAllow: resolved.RepoAllow,
}
hits := make([]fileHit, 0, 64)
for n := range s.graph.NodesByKind(graph.KindFile) {
if n == nil {
continue
}
if !s.nodeInSessionScope(ctx, n) {
continue
}
if !scopeOpts.ScopeAllows(n) {
continue
}
rel := repoRelativePath(n)
if len(pathFilter) > 0 && !pathMatchesAnyPrefix(rel, pathFilter) {
continue
}
base := path.Base(rel)
score := 0
if glob != "" {
if !matchFidelityGlob(glob, rel) {
continue
}
score += 5
}
if query != "" {
sc, ok := scoreFilenameMatch(query, base, rel, fuzzy)
if !ok {
continue
}
score += sc
}
hits = append(hits, fileHit{
Path: rel,
Repo: n.RepoPrefix,
Language: n.Language,
ID: n.ID,
score: score,
depth: strings.Count(rel, "/"),
})
}
// Highest score first; tie-break toward shallower paths, then
// lexical — so a top-level match outranks a deeply-nested one and
// identical inputs produce identical output across restarts.
sort.Slice(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
if hits[i].depth != hits[j].depth {
return hits[i].depth < hits[j].depth
}
return hits[i].Path < hits[j].Path
})
total := len(hits)
if len(hits) > limit {
hits = hits[:limit]
}
return s.respondScopedJSONOrTOON(ctx, req, map[string]any{
"query": query,
"glob": glob,
"files": hits,
"count": len(hits),
"truncated": total > len(hits),
}, resolved)
}
// scoreFilenameMatch ranks how well query matches a file's basename or
// repo-relative path. The score tiers (basename-exact > basename-prefix
// > basename-substring > path-substring > fuzzy) keep the most specific
// match at the top. Returns (0, false) when nothing matches.
func scoreFilenameMatch(query, base, rel string, fuzzy bool) (int, bool) {
q := strings.ToLower(query)
b := strings.ToLower(base)
r := strings.ToLower(rel)
switch {
case b == q:
return 100, true
case strings.HasPrefix(b, q):
return 70, true
case strings.Contains(b, q):
return 50, true
case strings.Contains(r, q):
return 30, true
}
if fuzzy && isSubsequence(q, b) {
return 10, true
}
return 0, false
}
// isSubsequence reports whether every rune of needle appears in haystack
// in order (a cheap fzf-style filename match). Both are expected to be
// lowercased by the caller.
func isSubsequence(needle, haystack string) bool {
if needle == "" {
return true
}
i := 0
for j := 0; j < len(haystack) && i < len(needle); j++ {
if haystack[j] == needle[i] {
i++
}
}
return i == len(needle)
}