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 `/` 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 `/` 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 "" }