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

1294 lines
46 KiB
Go

package mcp
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/agents"
"github.com/zzet/gortex/internal/elide"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/tokens"
)
// errPathUnresolved is returned when a relative path cannot be anchored to any
// indexed repo. Callers should surface this as a clear error rather than letting
// os.Open resolve it against the daemon process CWD, which is unrelated to any
// repo and silently produces wrong results.
var errPathUnresolved = errors.New("path is not absolute and no indexed repo could anchor it")
// errPathEscape is returned when a relative or repo-prefixed path resolves
// outside the anchor repo's root via `..` traversal. Absolute paths bypass
// this check by design — agents that hand over an absolute path are
// responsible for the location.
var errPathEscape = errors.New("relative path escapes the indexed repo root")
// resolveFilePath turns a user-supplied path into the absolute filesystem
// path the write should target. Accepts:
// - absolute paths (still confined: refused if outside every repo root)
// - repo-prefixed paths (e.g. "gortex/internal/foo.go" in multi-repo mode)
// - paths relative to the single indexer's root (single-repo mode only)
//
// Returns the absolute path, the repo-relative form for session
// bookkeeping, and an error sentinel describing WHY the input was
// rejected when one of the rejection paths fires:
//
// - errPathUnresolved — empty input, missing indexer, or a multi-repo
// bare-relative path that doesn't match any registered prefix
// (no implicit "primary repo", so the daemon refuses to fall
// through to its process CWD).
// - errPathEscape — any input (relative, repo-prefixed, or absolute)
// whose resolved location lands outside every indexed repo root,
// whether via `..` segments, an out-of-root absolute path, or a
// symlink that points out. Catches `../../etc/passwd` and
// `/etc/passwd` style attempts at the boundary instead of letting
// them silently land on system files.
//
// Both relative and absolute inputs are confined to the indexed
// repository roots: an absolute path that points outside every root is
// refused, not honoured. See the confinement guard at the top of the body.
func (s *Server) resolveFilePath(rawPath string) (absPath, relPath string, err error) {
// Post-resolution confinement (the SECURITY.md "confined to indexed
// repository roots" invariant): whatever path the body resolves below —
// relative or absolute — is refused if its real, symlink-resolved
// location falls outside every indexed repository root. This is the
// central choke point; no caller, read or write, may bypass it. It is a
// no-op for control clients that have no known roots (the same posture
// guardSymlinkWithinRepo takes on the read path).
defer func() {
if err == nil {
if guardErr := s.guardSymlinkWithinRepo(absPath); guardErr != nil {
absPath, relPath, err = "", "", guardErr
}
}
}()
if rawPath == "" {
return "", "", fmt.Errorf("%w: path is empty", errPathUnresolved)
}
if filepath.IsAbs(rawPath) {
abs := filepath.Clean(rawPath)
return abs, s.repoRelative(abs), nil
}
if s.multiIndexer != nil {
// Multi-repo mode requires a repo-prefixed path. Bare-relative
// paths are ambiguous; refuse rather than fall through to the
// daemon process CWD. With exactly one tracked repo there is no
// ambiguity — single-repo mode indexes unprefixed paths, so a
// bare-relative path anchors to the lone repo's root.
prefix := matchedRepoPrefix(s.multiIndexer, rawPath)
if prefix == "" {
if root, ok := s.multiIndexer.RepoRoot(""); ok {
abs := filepath.Clean(filepath.Join(root, rawPath))
if !pathContainedIn(abs, root) {
return "", "", fmt.Errorf("%w: %q resolves to %q, outside repo root %q", errPathEscape, rawPath, abs, root)
}
abs = worktreeRootedPath(abs, root, s.multiIndexer)
return abs, rawPath, nil
}
// No matched prefix and no lone repo: an unprefixed path is
// normally ambiguous across tracked repos. But when it names
// an existing file under exactly one repo there is no
// ambiguity — anchor it there, so an agent can edit
// gortex/internal/x.go as internal/x.go without first
// learning the prefix. A brand-new file (matches == 0) still
// requires an explicit prefix; a path present in several
// repos (matches > 1) is reported as such.
if abs, rel, matches := anchorUnprefixedExisting(s.multiIndexer, rawPath); matches == 1 {
return abs, rel, nil
} else if matches > 1 {
return "", "", fmt.Errorf("%w: path %q names a file in multiple tracked repos; prefix it with one of: %s/",
errPathUnresolved, rawPath, strings.Join(s.multiIndexer.RepoPrefixes(), "/, "))
}
prefixes := s.multiIndexer.RepoPrefixes()
return "", "", fmt.Errorf("%w: path %q does not start with a known repo prefix; expected one of: %s/, or an absolute path",
errPathUnresolved, rawPath, strings.Join(prefixes, "/, "))
}
root, ok := s.multiIndexer.RepoRoot(prefix)
if !ok {
return "", "", fmt.Errorf("%w: repo prefix %q has no root path", errPathUnresolved, prefix)
}
// Collision guard for the lone unprefixed repo: its indexed
// paths are raw relative paths, so one whose first segment
// equals the repo's own prefix (repo "api" containing
// api/handlers.go) would be hijacked by the prefix-strip join.
// Prefer the raw join when that file actually exists.
if loneRoot, lok := s.multiIndexer.RepoRoot(""); lok && loneRoot == root {
raw := filepath.Clean(filepath.Join(loneRoot, rawPath))
if pathContainedIn(raw, loneRoot) {
if _, err := os.Stat(raw); err == nil {
return worktreeRootedPath(raw, loneRoot, s.multiIndexer), rawPath, nil
}
}
}
abs := filepath.Clean(filepath.Join(root, strings.TrimPrefix(rawPath, prefix+"/")))
if !pathContainedIn(abs, root) {
return "", "", fmt.Errorf("%w: %q resolves to %q, outside repo root %q", errPathEscape, rawPath, abs, root)
}
// Re-root the write into the linked worktree the file belongs
// to when the resolved root is a main checkout that shares its
// index identity with one. relPath stays the repo-prefixed form
// for session bookkeeping — the prefix names the same repo
// regardless of which worktree the bytes land in.
abs = worktreeRootedPath(abs, root, s.multiIndexer)
return abs, rawPath, nil
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
abs := filepath.Clean(filepath.Join(root, rawPath))
if !pathContainedIn(abs, root) {
return "", "", fmt.Errorf("%w: %q resolves to %q, outside repo root %q", errPathEscape, rawPath, abs, root)
}
return abs, rawPath, nil
}
}
return "", "", fmt.Errorf("%w: no indexer is attached and path %q is not absolute", errPathUnresolved, rawPath)
}
// matchedRepoPrefix returns the longest repo prefix that prefixes rawPath
// (with a "/" separator) in the multi-indexer. Used to decide which repo
// anchors a repo-prefixed path before joining with its root.
func matchedRepoPrefix(mi multiRepoLookup, rawPath string) string {
if mi == nil || rawPath == "" {
return ""
}
best := ""
for _, prefix := range mi.RepoPrefixes() {
if prefix == "" {
continue
}
// Longest match wins so a nested repo (prefix "a/b") is chosen
// over its parent (prefix "a") for a path under the child,
// independent of RepoPrefixes() iteration order.
if strings.HasPrefix(rawPath, prefix+"/") && len(prefix) > len(best) {
best = prefix
}
}
return best
}
// multiRepoLookup is the subset of *MultiIndexer that resolveFilePath
// needs. Pulled out as an interface for testability and to keep the
// resolver decoupled from the broader MultiIndexer surface.
type multiRepoLookup interface {
RepoPrefixes() []string
RepoRoot(prefix string) (string, bool)
// LinkedWorktreeRoots returns the on-disk roots of every tracked
// linked git worktree that shares a .git common directory with the
// checkout at the given path.
LinkedWorktreeRoots(mainRepoPath string) []string
}
// worktreeRootedPath re-roots an edit target into the linked git
// worktree the file actually belongs to. All worktrees of one repo
// reuse a single index identity, so a repo-relative path resolved
// against one checkout's root (root → abs) can name a file that
// physically lives in a sibling worktree. When the resolved root is a
// main checkout, abs does not exist there, and exactly one tracked
// linked worktree of that repo *does* contain the same repo-relative
// file, the write is re-rooted into that worktree — so editing a file
// in a linked worktree modifies the worktree's copy, not the main
// repo's.
//
// The function is deliberately conservative:
// - It never moves a path that already exists at abs — the resolved
// root genuinely owns the file.
// - It never moves a path when the resolved root is itself a linked
// worktree — abs is already inside the right checkout.
// - It never moves a brand-new file (one that exists in no checkout):
// a fresh write_file lands under the prefix the caller named.
// - It re-roots only when the match is unambiguous (exactly one
// worktree contains the file); two candidates leave abs untouched.
func worktreeRootedPath(abs, root string, mi multiRepoLookup) string {
if abs == "" || root == "" || mi == nil {
return abs
}
// Already inside a linked worktree — nothing to re-root.
if indexer.ResolveWorktree(root).IsWorktree {
return abs
}
// The file is physically present where it resolved — the resolved
// root owns it.
if _, err := os.Stat(abs); err == nil {
return abs
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
return abs
}
match := ""
for _, wt := range mi.LinkedWorktreeRoots(root) {
candidate := filepath.Clean(filepath.Join(wt, rel))
if _, err := os.Stat(candidate); err != nil {
continue
}
if match != "" && match != candidate {
// Ambiguous — more than one worktree carries this file.
// Leave the path at its originally-resolved location.
return abs
}
match = candidate
}
if match != "" {
return match
}
return abs
}
// anchorUnprefixedExisting tries to anchor a bare repo-relative path —
// one that carries no repo prefix — to the single tracked repo that
// actually contains it. It joins rawPath against each repo root and
// counts the repos where the resulting file exists on disk. A unique
// match (matches == 1) is unambiguous and safe for the caller to honour:
// absPath is the worktree-rooted absolute path and relPath the
// repo-prefixed form used for session bookkeeping. matches == 0 means no
// tracked repo has the file (e.g. a brand-new write target) and matches
// > 1 means the path names a file in several repos; in both cases absPath
// and relPath are unset and the caller decides how to report it. The
// existence gate is what keeps this unambiguous — it never invents a
// location for a path that does not already resolve to exactly one file.
func anchorUnprefixedExisting(mi multiRepoLookup, rawPath string) (absPath, relPath string, matches int) {
if mi == nil || rawPath == "" || filepath.IsAbs(rawPath) {
return "", "", 0
}
for _, prefix := range mi.RepoPrefixes() {
if prefix == "" {
continue
}
root, ok := mi.RepoRoot(prefix)
if !ok || root == "" {
continue
}
cand := filepath.Clean(filepath.Join(root, rawPath))
if !pathContainedIn(cand, root) {
continue
}
if _, err := os.Stat(cand); err != nil {
continue
}
matches++
absPath = worktreeRootedPath(cand, root, mi)
relPath = prefix + "/" + rawPath
}
if matches != 1 {
return "", "", matches
}
return absPath, relPath, matches
}
// pathContainedIn reports whether abs sits at or beneath root, after
// cleaning both. The check is purely lexical — it doesn't dereference
// symlinks — but combined with filepath.Clean it catches the standard
// `..` traversal class. A defense-in-depth pass for symlink-target
// containment is left to the OS-level rename, which fails when the
// destination resolves to a different filesystem object.
func pathContainedIn(abs, root string) bool {
if abs == "" || root == "" {
return false
}
abs = filepath.Clean(abs)
root = filepath.Clean(root)
if abs == root {
return true
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return false
}
if rel == "." {
return true
}
if strings.HasPrefix(rel, "..") {
return false
}
return true
}
// guardSymlinkWithinRepo refuses to serve a file whose REAL (symlink-resolved)
// location is outside every tracked repository root. resolveFilePath /
// resolveNodePath already block lexical `../` traversal, but a symlink INSIDE
// the repo can still point at an arbitrary file on disk (repo/link ->
// /etc/passwd) that os.ReadFile would happily follow. This closes that hole by
// resolving the real path and requiring it to stay within an indexed root.
// Roots are symlink-resolved too, so a repo under a symlinked prefix (macOS
// /tmp -> /private/tmp, a temp-dir test root) still matches. A broken / missing
// target, or a control client with no known roots, is left to the normal read
// path — there is nothing to leak.
func (s *Server) guardSymlinkWithinRepo(absPath string) error {
roots := s.guardRepoRoots()
if len(roots) == 0 {
return nil // no known roots (control client / unindexed) — nothing to enforce
}
real, err := filepath.EvalSymlinks(absPath)
if err != nil {
// Not-yet-created file (or broken symlink): EvalSymlinks can't resolve
// the leaf, so resolve the nearest existing ancestor and re-append the
// trailing components. This keeps a brand-new file under a symlinked
// root (e.g. macOS /var -> /private/var, or a symlinked checkout) inside
// the root, while an out-of-repo target still resolves outside and is
// refused below.
real = resolveNearestExistingAncestor(absPath)
}
for _, root := range roots {
if pathContainedIn(real, root) {
return nil
}
}
return fmt.Errorf("%w: %q resolves to %q, outside every indexed repository root", errPathEscape, absPath, real)
}
// resolveNearestExistingAncestor symlink-resolves the longest existing prefix
// of absPath and rejoins the non-existent trailing components, so the
// confinement check on a not-yet-created file compares like-for-like against
// the (symlink-resolved) repository roots. Falls back to the lexical clean of
// absPath when nothing along the chain resolves.
func resolveNearestExistingAncestor(absPath string) string {
cur := filepath.Clean(absPath)
var suffix string
for {
if real, err := filepath.EvalSymlinks(cur); err == nil {
if suffix == "" {
return real
}
return filepath.Join(real, suffix)
}
parent := filepath.Dir(cur)
if parent == cur {
return filepath.Clean(absPath) // reached the filesystem root unresolved
}
suffix = filepath.Join(filepath.Base(cur), suffix)
cur = parent
}
}
// guardRepoRoots returns the symlink-resolved root paths of every tracked repo
// (multi-repo) plus the lone indexer (single-repo), for guardSymlinkWithinRepo.
func (s *Server) guardRepoRoots() []string {
var roots []string
add := func(root string) {
if root == "" {
return
}
if real, err := filepath.EvalSymlinks(root); err == nil {
root = real
}
roots = append(roots, filepath.Clean(root))
}
if s.multiIndexer != nil {
for _, prefix := range s.multiIndexer.RepoPrefixes() {
if root, ok := s.multiIndexer.RepoRoot(prefix); ok {
add(root)
}
}
}
if s.indexer != nil {
add(s.indexer.RootPath())
}
return roots
}
// resolveNodePath returns the absolute filesystem path for a graph node.
// Uses node.RepoPrefix to find the owning repo's root in multi-repo mode;
// falls back to the lone indexer's RootPath in single-repo mode. Returns an
// error (not a relative path) when no repo root is available, to keep callers
// from passing a bare-relative path to os.Open and resolving against the
// daemon process CWD.
func (s *Server) resolveNodePath(node *graph.Node) (string, error) {
if node == nil {
return "", errors.New("nil node")
}
if node.FilePath == "" {
return "", fmt.Errorf("node %q has no file path", node.ID)
}
if filepath.IsAbs(node.FilePath) {
return filepath.Clean(node.FilePath), nil
}
if s.multiIndexer != nil {
if root, ok := s.multiIndexer.RepoRoot(node.RepoPrefix); ok {
// applyRepoPrefix stamps `<repoPrefix>/` onto node.FilePath
// at index time, so a node's FilePath looks like
// `gortex/internal/mcp/tools_fileops.go`. RepoRoot returns
// the on-disk path that ALREADY corresponds to the repo
// Joining as-is duplicates the prefix segment when the repo's basename
// matches the prefix — strip the leading `<prefix>/` from
// the file path before joining so the result is the real
// on-disk file regardless of basename collision.
rel := node.FilePath
if node.RepoPrefix != "" {
rel = strings.TrimPrefix(rel, node.RepoPrefix+"/")
}
abs := filepath.Clean(filepath.Join(root, rel))
// Re-root onto the linked worktree the file belongs to —
// same reasoning as resolveFilePath: worktrees of one repo
// share an index identity, so a node's resolved path can
// land on a sibling checkout.
return worktreeRootedPath(abs, root, s.multiIndexer), nil
}
return "", fmt.Errorf("could not resolve repo root for node %q (repo_prefix=%q)", node.ID, node.RepoPrefix)
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
return filepath.Clean(filepath.Join(root, node.FilePath)), nil
}
}
return "", fmt.Errorf("%w: node=%q file=%q", errPathUnresolved, node.ID, node.FilePath)
}
// withAbsPath returns a shallow copy of n with AbsoluteFilePath populated
// from the indexer roots. The canonical graph node is never mutated, so
// this is safe to call from concurrent request handlers; AbsoluteFilePath
// is left empty when the path cannot be resolved (callers still carry the
// repo-relative file_path).
func (s *Server) withAbsPath(n *graph.Node) *graph.Node {
if n == nil {
return nil
}
cp := *n
if abs, err := s.resolveNodePath(n); err == nil {
cp.AbsoluteFilePath = abs
}
return &cp
}
// withAbsPaths maps withAbsPath over a slice, returning a fresh slice of
// copies. The input slice and its nodes are left untouched.
func (s *Server) withAbsPaths(nodes []*graph.Node) []*graph.Node {
if nodes == nil {
return nil
}
out := make([]*graph.Node, len(nodes))
for i, n := range nodes {
out[i] = s.withAbsPath(n)
}
return out
}
// resolveGraphPath returns the absolute filesystem path for a repo-prefixed
// graph path (e.g. "gortex/internal/foo.go"). Mirrors resolveNodePath but
// works on raw path strings — used for edges, search results, and other
// references that don't carry a Node pointer. Returns errPathUnresolved
// rather than letting os.Open resolve against the daemon process CWD.
func (s *Server) resolveGraphPath(graphPath string) (string, error) {
if graphPath == "" {
return "", errors.New("empty path")
}
if filepath.IsAbs(graphPath) {
return filepath.Clean(graphPath), nil
}
if s.multiIndexer != nil {
if abs := s.multiIndexer.ResolveFilePath(graphPath); abs != "" {
abs = filepath.Clean(abs)
// Re-root onto the linked worktree the file belongs to.
// ResolveFilePath joins against the matched prefix's root;
// recover that root so worktreeRootedPath can decide.
if prefix := matchedRepoPrefix(s.multiIndexer, graphPath); prefix != "" {
if root, ok := s.multiIndexer.RepoRoot(prefix); ok {
abs = worktreeRootedPath(abs, root, s.multiIndexer)
}
}
return abs, nil
}
return "", fmt.Errorf("could not resolve repo root for path %q", graphPath)
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
return filepath.Clean(filepath.Join(root, graphPath)), nil
}
}
return "", fmt.Errorf("%w: path=%q", errPathUnresolved, graphPath)
}
// graphRelPath normalises a caller-supplied file path to the
// repo-relative form the graph stores its nodes under — repo-prefixed in
// multi-repo mode (internal/x.go -> gortex/internal/x.go), unchanged in
// single-repo mode, and converted from an absolute path when one is
// given. It is the read-side companion to resolveFilePath's relPath:
// both run the same anchoring, but graphRelPath never errors — a path
// that cannot be anchored (e.g. a not-yet-created file) is returned
// as-is so a graph lookup keyed on it degrades to exactly the raw-path
// behaviour callers had before, never worse. Use it before GetFileSymbols
// / FileEditingContext so a repo-relative path doesn't silently miss the
// prefixed nodes in multi-repo mode.
func (s *Server) graphRelPath(fp string) string {
if _, rel, err := s.resolveFilePath(fp); err == nil && rel != "" {
return rel
}
return fp
}
// fileAttributionNode synthesizes a node carrying just the repo prefix
// and language of a file — enough for tokenStats.record to attribute an
// observation to the right per-repo / per-language bucket when no real
// symbol node is in hand.
func (s *Server) fileAttributionNode(relPath, language string) *graph.Node {
prefix := ""
if s.multiIndexer != nil {
prefix = matchedRepoPrefix(s.multiIndexer, relPath)
// Lone-repo attribution applies only to repo-relative paths —
// an absolute path that matched no prefix points outside every
// tracked repo and must stay unattributed rather than polluting
// the lone repo's buckets.
if prefix == "" && !filepath.IsAbs(relPath) {
if prefixes := s.multiIndexer.RepoPrefixes(); len(prefixes) == 1 {
prefix = prefixes[0]
}
}
}
return &graph.Node{RepoPrefix: prefix, Language: language, FilePath: relPath}
}
// savingsAttributionNode fills the per-repo attribution for symbol
// nodes minted in single-repo mode (RepoPrefix="") so symbol-tool
// events land in the same per-repo bucket the read-family tools use.
// The graph node itself is never mutated.
func (s *Server) savingsAttributionNode(node *graph.Node) *graph.Node {
if node == nil || node.RepoPrefix != "" || s.multiIndexer == nil {
return node
}
prefixes := s.multiIndexer.RepoPrefixes()
if len(prefixes) != 1 {
return node
}
cp := *node
cp.RepoPrefix = prefixes[0]
return &cp
}
// recordFileBaselineSavings books a savings observation for a tool whose
// response stands in for reading a whole file. payload is the response
// content actually produced — used both for the returned-token count and
// as the chars-per-token calibration sample — and the baseline is the
// on-disk byte size of the file the agent would otherwise have read.
// Best-effort accounting: files that don't resolve or stat book nothing.
func (s *Server) recordFileBaselineSavings(ctx context.Context, tool, relPath, language, payload string) {
abs, err := s.resolveGraphPath(relPath)
if err != nil {
return
}
info, err := os.Stat(abs)
if err != nil || info.IsDir() {
return
}
returned := tokens.CachedCountInt64(payload)
fullFile := int64(tokens.EstimateFromSample(int(info.Size()), payload))
s.tokenStatsFor(ctx).record(s.fileAttributionNode(relPath, language), tool, returned, fullFile)
}
// repoRelative converts an absolute path to a repo-prefixed or root-relative
// string if it falls under any indexed repo, otherwise returns the absolute
// path unchanged.
func (s *Server) repoRelative(absPath string) string {
if s.multiIndexer != nil {
if prefix := s.multiIndexer.RepoForFile(absPath); prefix != "" {
if idx := s.multiIndexer.GetIndexer(prefix); idx != nil {
if rel, err := filepath.Rel(idx.RootPath(), absPath); err == nil {
return filepath.ToSlash(filepath.Join(prefix, rel))
}
}
return prefix
}
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
if rel, err := filepath.Rel(root, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return filepath.ToSlash(rel)
}
}
}
return absPath
}
// reindexFile refreshes the graph for a single file after a write. Best-effort:
// non-source files or files outside any indexed repo are silently skipped.
func (s *Server) reindexFile(absPath string) bool {
if s.multiIndexer != nil {
if prefix := s.multiIndexer.RepoForFile(absPath); prefix != "" {
if idx := s.multiIndexer.GetIndexer(prefix); idx != nil {
if err := idx.IndexFile(absPath); err == nil {
return true
}
}
}
}
if s.indexer != nil {
if root := s.indexer.RootPath(); root != "" {
if rel, err := filepath.Rel(root, absPath); err == nil && !strings.HasPrefix(rel, "..") {
if err := s.indexer.IndexFile(absPath); err == nil {
return true
}
}
}
}
return false
}
// fileSyntaxHealth reads back the parse-error stamp the indexer places on a
// file node during (re)indexing, and returns a syntax_health block to attach
// to an edit response — but ONLY when the file now has parse errors, so a
// clean edit stays quiet. This lets an agent notice immediately that an edit
// left the file syntactically broken, before it trusts graph queries against
// it. Returns nil when the file parsed cleanly or its health is unknown.
func (s *Server) fileSyntaxHealth(relPath, absPath string) map[string]any {
if s.graph == nil {
return nil
}
graphPath := s.resolveOverlayGraphPath(relPath, absPath)
for _, n := range s.graph.GetFileNodes(graphPath) {
if n == nil || n.Kind != graph.KindFile || n.Meta == nil {
continue
}
broken, _ := n.Meta["has_parse_errors"].(bool)
if !broken {
continue
}
count := 0
switch v := n.Meta["parse_errors"].(type) {
case int:
count = v
case int64:
count = int(v)
case float64:
count = int(v)
}
return map[string]any{
"healthy": false,
"parse_errors": count,
"warning": fmt.Sprintf(
"file has %d parse error(s) after this edit — it may be syntactically broken; review before relying on graph queries for it.",
count),
}
}
return nil
}
func (s *Server) handleEditFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
rawPath, err := req.RequireString("path")
if err != nil {
return mcp.NewToolResultError("path is required"), nil
}
oldString, err := req.RequireString("old_string")
if err != nil {
return mcp.NewToolResultError("old_string is required"), nil
}
newString, err := req.RequireString("new_string")
if err != nil {
return mcp.NewToolResultError("new_string is required"), nil
}
if oldString == newString {
return mcp.NewToolResultError("old_string and new_string are identical"), nil
}
replaceAll := req.GetBool("replace_all", false)
dryRun := req.GetBool("dry_run", false)
baseSHA := normalizeExpectedSHA(req.GetString("base_sha", ""))
absPath, relPath, resolveErr := s.resolveFilePath(rawPath)
if resolveErr != nil {
return mcp.NewToolResultError(resolveErr.Error()), nil
}
content, err := os.ReadFile(absPath)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("could not read file: %v", err)), nil
}
// Drift guard: when the caller observed the file at base_sha,
// refuse to apply on top of a divergent on-disk SHA. Match the
// overlay-push error shape so callers can re-read and retry.
if baseSHA != "" && gitBlobSHA(content) != baseSHA {
return mcp.NewToolResultError(errBaseSHADrift), nil
}
fileStr := string(content)
matches := findEOLMatches(fileStr, oldString)
count := matches.count
if expected := req.GetInt("expected_occurrences", 0); expected > 0 && count != expected {
return mcp.NewToolResultError(fmt.Sprintf(
"expected_occurrences=%d but old_string matches %d location(s) — refusing the edit so a wrong-cardinality replacement can't slip through. Adjust the fragment or the expected count.",
expected, count)), nil
}
if count == 0 {
return mcp.NewToolResultError(
"old_string not found in file. Use get_file_summary or get_editing_context to inspect the current content."), nil
}
if count > 1 && !replaceAll {
hint := matchSpansHint(fileStr, matches.spans)
return mcp.NewToolResultError(fmt.Sprintf(
"old_string matches %d locations%s. Provide a larger fragment for uniqueness or pass replace_all=true.", count, hint)), nil
}
var newContent string
var replacements int
switch {
case matches.normalized:
// The CRLF<->LF fallback matched: splice the real byte spans and
// write new_string with each region's own line terminators so the
// edit never introduces mixed endings.
limit := 1
replacements = 1
if replaceAll {
limit = -1
replacements = count
}
newContent = spliceSpansEOL(fileStr, matches.spans, newString, limit)
if newContent == fileStr {
return mcp.NewToolResultError(
"old_string and new_string are identical after line-ending normalization"), nil
}
case replaceAll:
newContent = strings.ReplaceAll(fileStr, oldString, newString)
replacements = count
default:
newContent = strings.Replace(fileStr, oldString, newString, 1)
replacements = 1
}
newContentBytes := []byte(newContent)
newSHA := gitBlobSHA(newContentBytes)
allowParseErrors := req.GetBool("allow_parse_errors", false)
var gate parseGateResult
if parseGateEnabled() {
gate = checkParseGate(relPath, content, newContentBytes)
if gate.Blocked && !allowParseErrors && !dryRun {
return mcp.NewToolResultError(parseGateError(relPath, gate)), nil
}
}
if dryRun {
// Dry-run: validate everything but skip the write + reindex.
// Returns the same shape so callers can branch on dry_run for a
// preview before committing.
preview := map[string]any{
"path": relPath,
"status": "would_apply",
"dry_run": true,
"replacements": replacements,
"bytes_written": len(newContentBytes),
"reindexed": false,
"diff": unifiedDiff(relPath, fileStr, newContent),
"new_sha": newSHA,
}
if matches.normalized {
preview["eol_normalized"] = true
}
if info := parseGateInfo(gate, allowParseErrors); info != nil {
preview["parse_gate"] = info
}
return s.respondJSONOrTOON(ctx, req, preview)
}
perm := os.FileMode(0o644)
if info, err := os.Stat(absPath); err == nil {
perm = info.Mode().Perm()
}
if err := agents.AtomicWriteFile(absPath, newContentBytes, perm); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("could not write file: %v", err)), nil
}
sess := s.sessionFor(ctx)
sess.recordModified(relPath)
reindexed := s.reindexFile(absPath)
resp := map[string]any{
"path": relPath,
"status": "applied",
"replacements": replacements,
"bytes_written": len(newContentBytes),
"reindexed": reindexed,
"new_sha": newSHA,
}
if matches.normalized {
resp["eol_normalized"] = true
}
if info := parseGateInfo(gate, allowParseErrors); info != nil {
resp["parse_gate"] = info
}
if health := s.fileSyntaxHealth(relPath, absPath); health != nil {
resp["syntax_health"] = health
}
return s.respondJSONOrTOON(ctx, req, resp)
}
func (s *Server) handleWriteFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
rawPath, err := req.RequireString("path")
if err != nil {
return mcp.NewToolResultError("path is required"), nil
}
content, err := req.RequireString("content")
if err != nil {
return mcp.NewToolResultError("content is required"), nil
}
dryRun := req.GetBool("dry_run", false)
baseSHA := normalizeExpectedSHA(req.GetString("base_sha", ""))
absPath, relPath, resolveErr := s.resolveFilePath(rawPath)
if resolveErr != nil {
return mcp.NewToolResultError(resolveErr.Error()), nil
}
status := "created"
perm := os.FileMode(0o644)
fileExists := false
if info, err := os.Stat(absPath); err == nil {
if info.IsDir() {
return mcp.NewToolResultError(fmt.Sprintf("path %q is a directory", rawPath)), nil
}
status = "overwritten"
perm = info.Mode().Perm()
fileExists = true
}
// Drift guard: when the caller observed the file at base_sha,
// refuse to overwrite a divergent on-disk file. If the caller
// passed base_sha for a file that no longer exists, that is
// also drift (the file they read is gone).
if baseSHA != "" {
if !fileExists {
return mcp.NewToolResultError(errBaseSHADrift), nil
}
existing, readErr := os.ReadFile(absPath)
if readErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("could not read file for drift check: %v", readErr)), nil
}
if gitBlobSHA(existing) != baseSHA {
return mcp.NewToolResultError(errBaseSHADrift), nil
}
}
contentBytes := []byte(content)
newSHA := gitBlobSHA(contentBytes)
allowParseErrors := req.GetBool("allow_parse_errors", false)
var gate parseGateResult
if parseGateEnabled() {
var priorContent []byte
if fileExists {
priorContent, _ = os.ReadFile(absPath)
}
gate = checkParseGate(relPath, priorContent, contentBytes)
if gate.Blocked && !allowParseErrors && !dryRun {
return mcp.NewToolResultError(parseGateError(relPath, gate)), nil
}
}
if dryRun {
// Dry-run: skip the write + reindex but report what would happen,
// including a unified-diff preview of the change.
dryStatus := "would_create"
oldContent := ""
if status == "overwritten" {
dryStatus = "would_overwrite"
if existing, e := os.ReadFile(absPath); e == nil {
oldContent = string(existing)
}
}
preview := map[string]any{
"path": relPath,
"status": dryStatus,
"dry_run": true,
"bytes_written": len(contentBytes),
"reindexed": false,
"diff": unifiedDiff(relPath, oldContent, content),
"new_sha": newSHA,
}
if info := parseGateInfo(gate, allowParseErrors); info != nil {
preview["parse_gate"] = info
}
return s.respondJSONOrTOON(ctx, req, preview)
}
if err := agents.AtomicWriteFile(absPath, contentBytes, perm); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("could not write file: %v", err)), nil
}
sess := s.sessionFor(ctx)
sess.recordModified(relPath)
reindexed := s.reindexFile(absPath)
resp := map[string]any{
"path": relPath,
"status": status,
"bytes_written": len(contentBytes),
"reindexed": reindexed,
"new_sha": newSHA,
}
if info := parseGateInfo(gate, allowParseErrors); info != nil {
resp["parse_gate"] = info
}
if health := s.fileSyntaxHealth(relPath, absPath); health != nil {
resp["syntax_health"] = health
}
return s.respondJSONOrTOON(ctx, req, resp)
}
// matchLocationsHint returns a brief " (first match lines X, Y, Z)" hint
// listing up to three line numbers where oldString matches in fileStr
// (EOL-tolerant). Empty when there are zero matches. Helps an agent choose
// a more unique fragment without re-reading the file.
func matchLocationsHint(fileStr, oldString string) string {
return matchSpansHint(fileStr, findEOLMatches(fileStr, oldString).spans)
}
// handleReadFile returns the full content of a file as a string,
// optionally rewritten through the tree-sitter elider when
// compress_bodies=true. Path resolution shares the same rules as
// edit_file / write_file (absolute, repo-prefixed, or
// single-repo-root-relative); the file does not need to be indexed.
// fileDependents returns the distinct source files that import the given file —
// the files a change to it would ripple into. Only import edges into the file
// node count, so the header reflects real file-to-file dependencies.
func (s *Server) fileDependents(fileID string) []string {
if s.graph == nil || fileID == "" {
return nil
}
seen := map[string]bool{}
var deps []string
for _, e := range s.graph.GetInEdges(fileID) {
if e == nil || e.Kind != graph.EdgeImports {
continue
}
f := s.fileOfNode(e.From)
if f == "" || f == fileID || seen[f] {
continue
}
seen[f] = true
deps = append(deps, f)
}
sort.Strings(deps)
return deps
}
// fileOfNode returns the source file a node belongs to: a file node is its own
// file, any other node reports its FilePath; falls back to the id.
func (s *Server) fileOfNode(id string) string {
if n := s.graph.GetNode(id); n != nil {
if n.Kind == graph.KindFile {
return n.ID
}
if n.FilePath != "" {
return n.FilePath
}
}
return id
}
// fileDependentsNote renders a one-line dependents header: how many files import
// this file, and the first few of them.
func fileDependentsNote(deps []string) string {
if len(deps) == 0 {
return ""
}
shown := deps
suffix := ""
if len(shown) > 5 {
shown = shown[:5]
suffix = fmt.Sprintf(" (+%d more)", len(deps)-5)
}
noun := "files import"
if len(deps) == 1 {
noun = "file imports"
}
return fmt.Sprintf("↑ %d %s this file: %s%s", len(deps), noun, strings.Join(shown, ", "), suffix)
}
// attachFileDependents records the dependents list + one-line header on a file
// tool's result map, when the file has any importers.
func (s *Server) attachFileDependents(result map[string]any, fileID string) {
if deps := s.fileDependents(fileID); len(deps) > 0 {
result["dependents"] = deps
result["dependents_header"] = fileDependentsNote(deps)
}
}
// windowFileLines slices content to the 1-based line window [offset,
// offset+limit). offset <= 0 starts at line 1; limit <= 0 reads to EOF.
// Returns the windowed bytes, whether a window was applied, and the
// 1-based inclusive start/end lines plus the file's total line count. A
// trailing newline's phantom final line is excluded from the count and the
// window, so total_lines matches what an editor shows. An offset past EOF
// yields empty content (not an error).
func windowFileLines(content []byte, offset, limit int) (out []byte, applied bool, start, end, total int) {
if offset <= 0 && limit <= 0 {
return content, false, 0, 0, 0
}
lines := strings.Split(string(content), "\n")
total = len(lines)
if total > 0 && lines[total-1] == "" {
// Trailing newline produced a phantom empty final element — don't
// count it as a line.
total--
}
start = offset
if start < 1 {
start = 1
}
if start > total {
return []byte{}, true, start, start - 1, total
}
end = total
if limit > 0 {
if e := start + limit - 1; e < end {
end = e
}
}
return []byte(strings.Join(lines[start-1:end], "\n")), true, start, end, total
}
func (s *Server) handleReadFile(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
rawPath, err := req.RequireString("path")
if err != nil {
return mcp.NewToolResultError("path is required"), nil
}
absPath, relPath, resolveErr := s.resolveFilePath(rawPath)
if resolveErr != nil {
return mcp.NewToolResultError(resolveErr.Error()), nil
}
if guardErr := s.guardSymlinkWithinRepo(absPath); guardErr != nil {
return mcp.NewToolResultError(guardErr.Error()), nil
}
info, statErr := os.Stat(absPath)
if statErr != nil {
return mcp.NewToolResultError(fmt.Sprintf("could not stat file: %v", statErr)), nil
}
if info.IsDir() {
return mcp.NewToolResultError(fmt.Sprintf("path %q is a directory", rawPath)), nil
}
// Honour the editor-buffer overlay if one is active for this path. A
// drifted overlay is already rejected upstream by the overlay view
// guard; what reaches here is a live buffer, which we flag as such so
// the caller knows the bytes are an unsaved editor view, not disk.
var content []byte
servedFromOverlay := false
if buf, ok := s.overlayContentFor(ctx, absPath); ok {
content = []byte(buf)
servedFromOverlay = true
} else {
b, rerr := os.ReadFile(absPath)
if rerr != nil {
return mcp.NewToolResultError(fmt.Sprintf("could not read file: %v", rerr)), nil
}
content = b
}
originalBytes := len(content)
// Line-window: when offset/limit are given, return only that slice of
// the file's lines. This is the bounded-read path for large files — the
// salience/compress transforms below reshape a whole body, whereas a
// window is a plain "lines M..N" cut the caller asked for explicitly.
windowed := false
var winStart, winEnd, winTotal int
if offset := req.GetInt("offset", 0); offset > 0 || req.GetInt("limit", 0) > 0 {
content, windowed, winStart, winEnd, winTotal = windowFileLines(content, offset, req.GetInt("limit", 0))
}
isBinary := looksBinary(content)
bodiesElided := false
var keptSymbols []string
language := s.detectLanguageForPath(ctx, absPath, relPath)
// Tool-call observer: credit the recent search for the symbols in
// the file the agent is reading.
s.creditFileConsumption(ctx, relPath)
// File symbols power both the `keep` predicate and frecency credit.
sg := s.engineFor(ctx).GetFileSymbols(relPath)
if req.GetBool("compress_bodies", false) && language != "" && elide.IsSupported(language) {
var symbols []*graph.Node
if sg != nil {
symbols = sg.Nodes
}
keepPred, resolved := resolveKeepPredicate(req.GetString("keep", ""), symbols)
decide := fidelityDecideForPath(parseFidelityGlobs(req.GetString("fidelity_globs", "")), relPath)
if out, eerr := elide.CompressWith(content, language, elide.Options{Keep: keepPred, Decide: decide}); eerr == nil && len(out) != len(content) {
content = out
bodiesElided = true
keptSymbols = resolved
}
}
// Salience truncation: collapse leaf-statement runs (and head-cut
// non-code files) when the file is still over the max_lines budget.
salienceTruncated := false
if maxLines := req.GetInt("max_lines", 0); maxLines > 0 {
if out, truncated, _ := elide.SalienceTruncate(content, language, maxLines); truncated {
content = out
salienceTruncated = true
}
}
// Record the access for frecency credit on any node defined in
// this file. read_file is a heavy access (full file), so we
// credit every defined symbol — keeps the "agent is working in
// this area" signal aligned with how the agent burned its
// budget.
s.sessionFor(ctx).recordFile(relPath)
if sg != nil {
for _, n := range sg.Nodes {
if n == nil || n.Kind == graph.KindFile {
continue
}
s.frecency.Record(n.ID)
}
}
// Withhold secret-shaped values from config / data-leaf files unless the
// caller explicitly opts out. Keys stay readable; only secret-shaped values
// are replaced.
secretsRedacted := false
if !isBinary {
if red, did := s.maybeRedactConfigLeaf(language, relPath, req.GetBool("allow_secrets", false), string(content)); did {
content = []byte(red)
secretsRedacted = true
}
}
result := map[string]any{
"path": relPath,
"language": language,
"bytes": len(content),
"original_bytes": originalBytes,
"content": string(content),
}
if secretsRedacted {
result["secrets_redacted"] = true
}
if servedFromOverlay {
result["served_from"] = "overlay"
}
if bodiesElided {
result["bodies_elided"] = true
if len(keptSymbols) > 0 {
result["kept_symbols"] = keptSymbols
}
}
if salienceTruncated {
result["salience_truncated"] = true
}
if windowed {
result["window"] = map[string]any{
"start_line": winStart,
"end_line": winEnd,
"total_lines": winTotal,
}
}
// Omission notes: tell the model what the payload deliberately
// leaves out or reshapes, so it does not reason about absent code.
omissions := pathOmissions(relPath)
if servedFromOverlay {
omissions = append(omissions, omission("overlay",
"served from an active editor-buffer overlay, not the file currently on disk"))
}
if isBinary {
omissions = append(omissions, omission("binary",
"file is binary — the content field holds raw bytes, not source text"))
}
if bodiesElided {
omissions = append(omissions, omission("compressed",
"function and method bodies replaced with elided stubs; signatures and structure kept"))
}
if salienceTruncated {
omissions = append(omissions, omission("truncated",
"oversized source reduced toward its control-flow skeleton; runs of leaf statements collapsed"))
}
if windowed {
omissions = append(omissions, omission("windowed",
fmt.Sprintf("returned lines %d-%d of %d; the rest of the file was not included (offset/limit window)", winStart, winEnd, winTotal)))
}
if secretsRedacted {
omissions = append(omissions, omission("secrets_withheld",
"secret-shaped values in this config file were withheld; pass allow_secrets:true to read them"))
}
if len(omissions) > 0 {
result["omissions"] = omissions
}
etag := computeETag(result)
if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag {
return notModifiedResult(etag), nil
}
result["etag"] = etag
// Server-side accounting only — read_file is the heaviest source
// fetch and must show up in the savings ledger even when nothing
// was saved (an uncompressed read returns the whole file, so
// returned == baseline and only the call is counted). Recorded
// after the conditional-fetch return so a not_modified turnaround
// books nothing and skips the tokenization entirely.
if !isBinary {
contentStr := string(content)
returned := tokens.CachedCountInt64(contentStr)
fullFile := returned
if bodiesElided || salienceTruncated || windowed {
fullFile = int64(tokens.EstimateFromSample(originalBytes, contentStr))
}
s.tokenStatsFor(ctx).record(s.fileAttributionNode(relPath, language), "read_file", returned, fullFile)
}
s.attachFileDependents(result, relPath)
if s.isTOON(ctx, req) {
return returnTOON(result)
}
return s.respondJSONOrTOON(ctx, req, result)
}
// detectLanguageForPath resolves the language code for a file. Prefers
// the indexed file node's Node.Language (canonical: same code the
// extractor stamped at index time). Falls back to the parser
// Registry's extension-based detection so unindexed files (or files
// outside any tracked repo) still get a language tag.
func (s *Server) detectLanguageForPath(ctx context.Context, absPath, relPath string) string {
// Try the indexed file node first.
if sg := s.engineFor(ctx).GetFileSymbols(relPath); sg != nil {
for _, n := range sg.Nodes {
if n != nil && n.Kind == graph.KindFile && n.Language != "" {
return n.Language
}
}
}
// Fall back to the parser registry from whichever indexer owns
// the file. In multi-repo mode, every indexer holds the same
// registry instance; in single-repo mode we ask the lone indexer.
//
// A bounded prefix read lets the registry's content probe place
// an ambiguous extension (.h, .m) or an unknown-extension script.
var head []byte
if f, err := os.Open(absPath); err == nil {
buf := make([]byte, 512)
if n, _ := f.Read(buf); n > 0 {
head = buf[:n]
}
_ = f.Close()
}
if s.multiIndexer != nil {
for _, prefix := range s.multiIndexer.RepoPrefixes() {
if idx := s.multiIndexer.GetIndexer(prefix); idx != nil {
if reg := idx.Registry(); reg != nil {
if lang, ok := reg.DetectLanguageContent(absPath, head); ok {
return lang
}
}
}
}
}
if s.indexer != nil {
if reg := s.indexer.Registry(); reg != nil {
if lang, ok := reg.DetectLanguageContent(absPath, head); ok {
return lang
}
}
}
return ""
}