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
191 lines
6.5 KiB
Go
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)
|
|
}
|