package mcp import ( "bufio" "context" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/elide" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/search/rerank" "github.com/zzet/gortex/internal/tokens" ) func (s *Server) registerCodingTools() { s.addTool( mcp.NewTool("get_editing_context", mcp.WithDescription("The primary tool to call before editing any file. Returns all symbols defined in the file, their signatures, direct dependencies, and immediate callers — everything needed to code without reading raw source lines."), mcp.WithString("path", mcp.Required(), mcp.Description("Relative file path")), mcp.WithString("detail", mcp.Description("brief or full (default: brief)")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithNumber("max_tokens", mcp.Description(tokenBudgetParamDescription)), mcp.WithString("if_none_match", mcp.Description("ETag from a previous response — returns not_modified if content unchanged")), mcp.WithBoolean("compress_bodies", mcp.Description("Also return a source_compressed view of the whole file with every function/method body replaced by a `{ /* N lines elided */ }` stub. Signatures, imports, types, and comments are preserved verbatim. Roughly 60-70% fewer tokens than raw source. Composable with format:\"gcx\". Default: false.")), mcp.WithString("keep", mcp.Description("Comma-separated symbol names, IDs, or node kinds (function / method / type) whose bodies stay verbatim when compress_bodies is set — every other body in the file is still stubbed. Use to keep the symbols you are about to edit at full source while compressing the rest. Ignored unless compress_bodies is true.")), mcp.WithString("fidelity_globs", mcp.Description(fidelityGlobsParamDescription)), ), s.handleGetEditingContext, ) s.addTool( mcp.NewTool("find_import_path", mcp.WithDescription("Given a symbol name you want to use in a file, returns the correct import path. Use instead of reading files or guessing package paths."), mcp.WithString("name", mcp.Required(), mcp.Description("Name of the symbol to import")), mcp.WithString("path", mcp.Required(), mcp.Description("File where you want to use the symbol (relative path)")), ), s.handleFindImportPath, ) s.addTool( mcp.NewTool("explain_change_impact", mcp.WithDescription("Given a list of symbols you plan to modify, returns risk-tiered blast radius: d=1 will break, d=2 likely affected, d=3 needs testing. Includes affected processes and communities."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of symbol IDs to modify")), mcp.WithBoolean("summary_only", mcp.Description("Return only by_depth_counts (e.g. {1:3, 2:12}) and drop the per-depth row lists — the cheapest blast-radius shape when you only need the headline counts.")), mcp.WithNumber("offset", mcp.Description("Skip this many affected rows (in depth order) before returning by_depth — pairs with limit to page a large blast radius.")), mcp.WithNumber("limit", mcp.Description("Max affected rows to return in by_depth (default 100, depth order). by_depth_counts always reports the full per-depth totals regardless.")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), ), s.handleEnhancedChangeImpact, ) s.addTool( mcp.NewTool("get_symbol_source", mcp.WithDescription("Returns the source code of a specific symbol (function, method, type) without reading the entire file. Use instead of Read when you know which symbol you need — saves 70-80% of tokens compared to reading the whole file. Fetching several symbols, or still localizing a task? batch_symbols reads many bodies in one call; explore returns the whole ranked neighborhood (source + call paths) for a task in one call."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol node ID (e.g. pkg/server.go::HandleRequest)")), mcp.WithNumber("context_lines", mcp.Description("Extra lines above/below the symbol (default: 3)")), mcp.WithString("if_none_match", mcp.Description("ETag from a previous response — returns not_modified if content unchanged")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithBoolean("compress_bodies", mcp.Description("Replace function/method bodies in the returned source with a `{ /* N lines elided */ }` stub. Signatures, doc-comments, and structure stay intact. ~30-40% of original tokens. Useful when you only need the surface signature of the symbol, not its implementation. Default: false.")), mcp.WithBoolean("allow_secrets", mcp.Description("Serve secret-shaped values verbatim when the symbol is a config / data-leaf value (.env, *.yaml, *.properties, ...). By default such values are withheld. Default: false.")), mcp.WithNumber("max_lines", mcp.Description("When the returned source exceeds this many lines, collapse runs of leaf statements inside function bodies into `… N lines elided …` markers while keeping the signature and the full control-flow skeleton — a structure-preserving alternative to a hard line cut. Omit or 0 to disable.")), ), s.handleGetSymbolSource, ) s.addTool( mcp.NewTool("batch_symbols", mcp.WithDescription("Returns signatures, source code, callers, and callees for multiple symbols in one call. Use instead of calling get_symbol_source or get_symbol multiple times — saves 60% round-trip overhead."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of symbol IDs")), mcp.WithBoolean("include_source", mcp.Description("Include source code for each symbol (default: false)")), mcp.WithNumber("context_lines", mcp.Description("Extra lines above/below source (default: 3, only if include_source)")), mcp.WithString("if_none_match", mcp.Description("ETag from a previous response — returns not_modified if content unchanged")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), ), s.handleBatchSymbols, ) s.addTool( mcp.NewTool("get_test_targets", mcp.WithDescription("Given changed symbol IDs, traces the call graph to find test files and test functions that exercise those symbols. Use after editing to know exactly which tests to run — no guessing, no running the entire suite."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of changed symbol IDs")), mcp.WithNumber("depth", mcp.Description("Caller traversal depth (default: 3)")), ), s.handleGetTestTargets, ) s.addTool( mcp.NewTool("suggest_pattern", mcp.WithDescription("Given an existing symbol as an example, extracts the structural pattern for creating similar code. Returns the example source, sibling symbols with the same pattern, registration/wiring code, test patterns, and files to edit. Source blocks longer than `max_source_lines` are elided to keep the response compact; pass a larger value when you need the full body. Use when adding a new function/handler/extractor that follows an existing convention."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to use as the pattern example")), mcp.WithNumber("max_source_lines", mcp.Description("Per-source elision threshold (default: 40). Each example / registration / test source block longer than this collapses to its head + a `... N lines elided ...` marker. Pass 0 to disable elision.")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for the project default.")), ), s.handleSuggestPattern, ) s.addTool( mcp.NewTool("get_edit_plan", mcp.WithDescription("Given symbols you plan to change, returns a dependency-ordered list of files and symbols to edit — definitions first, then implementations, then callers, then tests. Eliminates manual dependency reasoning. Use before any multi-file refactor."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of symbol IDs to change")), mcp.WithNumber("depth", mcp.Description("Dependent traversal depth (default: 3)")), ), s.handleGetEditPlan, ) s.addTool( mcp.NewTool("edit_symbol", mcp.WithDescription("Edit a symbol's source code directly by ID — no Read needed. Gortex resolves the file and line range, finds the old_source fragment, replaces it with new_source, and writes the file. Matching is line-ending tolerant: an LF-authored old_source matches a CRLF file (and vice versa), and new_source is written with the file's own line endings — the response carries eol_normalized:true when that fallback fired. Eliminates the Read→Edit roundtrip for ~80% of edits. Pass base_sha to guard against stale writes: if the on-disk file no longer matches the SHA you observed at read time, the call fails with `base_sha mismatch — re-read and resubmit` and the file is untouched. On success the response carries new_sha so you can pipeline the next edit without re-reading."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID (e.g. server.go::NewServer)")), mcp.WithString("old_source", mcp.Required(), mcp.Description("Exact source fragment to replace (must be unique within the symbol). CRLF/LF line-ending differences against the file are tolerated.")), mcp.WithString("new_source", mcp.Required(), mcp.Description("Replacement source fragment")), mcp.WithString("base_sha", mcp.Description("Optional git blob SHA-1 the caller observed at read time. When set, the call refuses to write if the on-disk file's current SHA differs (drift guard against silent clobbers).")), mcp.WithBoolean("dry_run", mcp.Description("Validate the edit and return a unified-diff preview without writing (status: would_apply). Use to review the change before committing it.")), ), s.handleEditSymbol, ) s.addTool( mcp.NewTool("read_file", mcp.WithDescription("Reads a file by path and returns its content. Pass offset (1-based start line) and/or limit (line count) to read a specific line WINDOW of a large file instead of the whole thing. Use sparingly — prefer get_symbol_source / get_editing_context for code. Useful when you need a non-indexed file (config, fixture, raw markdown) or when you genuinely need the full body. With compress_bodies=true, every function/method body is replaced by a `{ /* N lines elided */ }` stub — signatures + structure preserved, ~30-40% of original tokens. Composable with format:\"gcx\"."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path, or repo-prefixed / repo-root-relative path")), mcp.WithNumber("offset", mcp.Description("1-based line number to start reading from. Pair with limit to read a window of a large file. Omit or 0 to start at the first line.")), mcp.WithNumber("limit", mcp.Description("Maximum number of lines to return starting at offset. Omit or 0 to read to end of file. Set this (with or without offset) to read a bounded window instead of the whole file.")), mcp.WithBoolean("compress_bodies", mcp.Description("Replace function/method bodies with elided stubs (default: false)")), mcp.WithBoolean("allow_secrets", mcp.Description("Serve secret-shaped values in config / data-leaf files (.env, *.yaml, *.toml, *.properties, ...) verbatim. By default such values are withheld and only their keys are shown. Default: false.")), mcp.WithString("keep", mcp.Description("Comma-separated symbol names, IDs, or node kinds whose bodies stay verbatim when compress_bodies is set — every other body in the file is still stubbed. Ignored unless compress_bodies is true.")), mcp.WithString("fidelity_globs", mcp.Description(fidelityGlobsParamDescription)), mcp.WithNumber("max_lines", mcp.Description("When the file exceeds this many lines, collapse runs of leaf statements inside function bodies into `… N lines elided …` markers while keeping declarations and the control-flow skeleton. Falls back to a plain head cut for non-code files. Omit or 0 to disable.")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes; truncation flag rides on the response. Omit for no cap.")), mcp.WithString("if_none_match", mcp.Description("ETag from a previous response — returns not_modified if content unchanged")), ), s.handleReadFile, ) s.addTool( mcp.NewTool("edit_file", mcp.WithDescription("Edit any file (markdown, config, spec, template, source) by exact string replacement — no Read needed. Accepts absolute paths or paths relative to the indexed repo root. Matching is line-ending tolerant: an LF-authored old_string matches a CRLF file (and vice versa), and new_string is written with the file's own line endings — the response carries eol_normalized:true when that fallback fired. Writes atomically (temp+rename) and re-indexes the file so the graph stays fresh. Pass dry_run=true to validate the replacement without writing. Pass base_sha to guard against stale writes: if the on-disk file no longer matches the SHA you observed at read time, the call fails with `base_sha mismatch — re-read and resubmit` and the file is untouched. On success the response carries new_sha so you can pipeline the next edit without re-reading. Complements edit_symbol for non-code files that have no symbol ID."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path, or repo-prefixed / repo-root-relative path")), mcp.WithString("old_string", mcp.Required(), mcp.Description("Exact text to replace (must be unique unless replace_all=true). CRLF/LF line-ending differences against the file are tolerated.")), mcp.WithString("new_string", mcp.Required(), mcp.Description("Replacement text")), mcp.WithBoolean("replace_all", mcp.Description("Replace every occurrence instead of requiring uniqueness (default: false)")), mcp.WithNumber("expected_occurrences", mcp.Description("Guard: refuse the edit unless old_string matches exactly this many locations. Pairs with replace_all to assert the cardinality of a sweep (e.g. expected_occurrences:7 replace_all:true). Omit or 0 to disable the check.")), mcp.WithBoolean("dry_run", mcp.Description("Validate the replacement and report what would change without writing (default: false)")), mcp.WithString("base_sha", mcp.Description("Optional git blob SHA-1 the caller observed at read time. When set, the call refuses to write if the on-disk file's current SHA differs (drift guard against silent clobbers).")), mcp.WithBoolean("allow_parse_errors", mcp.Description("Bypass the pre-write parse gate. By default an edit that would introduce new tree-sitter parse errors (leaving the file more syntactically broken than before) is refused before the atomic write; set true to write anyway.")), ), s.handleEditFile, ) s.addTool( mcp.NewTool("write_file", mcp.WithDescription("Create a new file or overwrite an existing one with the given content — no Read needed. Accepts absolute paths or paths relative to the indexed repo root. Writes atomically (temp+rename) and re-indexes the file so the graph stays fresh. Pass dry_run=true to report what would happen without writing. Pass base_sha when overwriting to guard against stale writes: if the on-disk file no longer matches the SHA you observed at read time (or has been deleted), the call fails with `base_sha mismatch — re-read and resubmit` and the file is untouched. On success the response carries new_sha so you can pipeline the next edit without re-reading. Use for new docs, configs, specs, scaffolded files; prefer edit_symbol or edit_file when a symbol/string target exists."), mcp.WithString("path", mcp.Required(), mcp.Description("Absolute path, or repo-prefixed / repo-root-relative path")), mcp.WithString("content", mcp.Required(), mcp.Description("Full file content")), mcp.WithBoolean("dry_run", mcp.Description("Report would_create / would_overwrite without writing (default: false)")), mcp.WithString("base_sha", mcp.Description("Optional git blob SHA-1 the caller observed at read time. When set, write_file refuses to overwrite a divergent on-disk file (or write to a path the caller expected to exist but no longer does). Drift guard against silent clobbers on existing files; leave empty when creating a new file.")), mcp.WithBoolean("allow_parse_errors", mcp.Description("Bypass the pre-write parse gate. By default a write that would introduce new tree-sitter parse errors (relative to the prior content, or any error in a brand-new file) is refused before the atomic write; set true to write anyway.")), ), s.handleWriteFile, ) s.addTool( mcp.NewTool("rename_symbol", mcp.WithDescription("Plans a coordinated multi-file rename for a symbol. Plan-only — this call never writes to disk. Returns {file, line, old_text, new_text, confidence, reason} for the definition, every graph usage (calls / references / instantiates), receiver-line edits when renaming a type, and test-function names that embed the old identifier. Apply the returned edits with batch_edit (preferred — dependency-ordered, atomic), edit_file, or edit_symbol."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to rename (e.g. auth/token.go::validateToken)")), mcp.WithString("new_name", mcp.Required(), mcp.Description("New name for the symbol")), ), s.handleRenameSymbol, ) s.addTool( mcp.NewTool("smart_context", mcp.WithDescription("Assembles the minimal context needed for a task in one call. Searches for relevant symbols, gets their source and relationships, finds patterns to follow, and builds an edit plan. Replaces an entire exploration phase of 5-10 tool calls."), mcp.WithString("task", mcp.Required(), mcp.Description("Natural language description of what you want to do (e.g. 'add a new MCP tool called list_files')")), mcp.WithString("entry_point", mcp.Description("Optional symbol ID or file path to start from")), mcp.WithNumber("max_symbols", mcp.Description("Max symbols to include source for (default: 5)")), mcp.WithString("fidelity", mcp.Description("Set to \"graded\" to add a context_manifest: focus symbols at full source, their caller/callee adjacency ring as elided signature stubs, and the keyword-match remainder as outline-only entries — all packed under one token budget. Default \"flat\" keeps the legacy relevant_symbols shape.")), mcp.WithNumber("token_budget", mcp.Description("Token ceiling for the graded-fidelity manifest (default 8000). Entries are demoted full → compressed → outline as the budget fills. Ignored unless fidelity is \"graded\".")), mcp.WithBoolean("estimate", mcp.Description("Dry-run: skip assembling the payload and return only a token-cost projection (projected_tokens plus per-tier counts) for the task at the chosen fidelity, so the caller can budget before fetching the real context.")), mcp.WithString("if_none_match", mcp.Description("Pack-root etag from a previous smart_context response. When the recomputed pack root matches — nothing the pack covers has changed — the tool returns not_modified instead of resending the payload, turning a repeated call on an unchanged repo into a near-zero-token no-op.")), mcp.WithString("delta_from", mcp.Description("Pack root (etag) of a prior smart_context pack the caller still holds. Instead of re-emitting the full pack, the tool returns a `pack_delta` block — symbols/files/edges added, removed, or changed vs that prior pack — so an incrementally-shifted working set re-delivers only the diff. When the delta is materially smaller than the full pack the bulky symbol lists are dropped (merge the delta into your held pack); the response's `etag` still identifies the full pack for the next delta_from. If the prior pack is no longer cached the full pack is returned with `pack_delta.available=false`.")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), mcp.WithNumber("max_bytes", mcp.Description("Cap the marshaled response at this many bytes. The longest list is trimmed; truncation metadata rides on the response. Omit for no cap.")), mcp.WithString("repo", mcp.Description("Filter results to a specific repository prefix")), mcp.WithString("project", mcp.Description("Filter results to repositories in a specific project")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — restricts results to that scope's repositories.")), mcp.WithString("path", mcp.Description("Restrict the assembled context to one or more sub-paths (comma-separated) -- a monorepo-service slice. Anchored, slash-segment-boundary prefixes relative to the repo root. Unions with an inline path: clause in the task and a scope's saved paths.")), mcp.WithBoolean("include_call_paths", mcp.Description("Opt in to the in-pack anchored call-paths section (off by default). Overrides the project's smart_context.in_pack config for this call.")), mcp.WithBoolean("include_flows", mcp.Description("Opt in to the in-pack flow-spine and dynamic-boundary section (off by default). Overrides the project's smart_context.in_pack config for this call.")), mcp.WithBoolean("include_confidence", mcp.Description("Opt in to the in-pack retrieval-confidence verdict (off by default). Overrides the project's smart_context.in_pack config for this call.")), ), s.handleSmartContext, ) s.addTool( mcp.NewTool("plan_turn", mcp.WithDescription("Opening-move router. Returns a short ranked list of recommended tool calls (with pre-filled args) for a task — 'what should I call first?'. Use before smart_context when you want a cheaper routing decision; call smart_context directly when you're committed to exploring."), mcp.WithString("task", mcp.Required(), mcp.Description("Natural-language description of the task")), mcp.WithNumber("max_calls", mcp.Description("Max recommended calls (default: 4)")), ), s.handlePlanTurn, ) s.addTool( mcp.NewTool("get_untested_symbols", mcp.WithDescription("Returns functions and methods in non-test files that no test file reaches via the call graph — the inverse of get_test_targets. Ranked by fan_in descending so the most-used untested symbols surface first. Use to prioritize where to add test coverage."), mcp.WithNumber("limit", mcp.Description("Max entries returned (default: 50)")), mcp.WithString("file_prefix", mcp.Description("Restrict to symbols whose file path starts with this prefix (e.g. 'internal/auth/')")), mcp.WithNumber("min_fan_in", mcp.Description("Only flag symbols with at least this many callers; filters trivial helpers (default: 0)")), ), s.handleGetUntestedSymbols, ) s.addTool( mcp.NewTool("get_recent_changes", mcp.WithDescription("Returns files and symbols that changed since the last call (watch mode only). Use to re-orient after the user edits files outside of Claude Code's view, without re-reading anything."), mcp.WithString("since", mcp.Description("ISO 8601 timestamp (omit for all changes since index)")), ), s.handleGetRecentChanges, ) } type editingContext struct { File map[string]any `json:"file"` Defines []map[string]any `json:"defines"` Imports []map[string]any `json:"imports"` CalledBy []map[string]any `json:"called_by"` Calls []map[string]any `json:"calls"` } // resolveKeepPredicate turns the comma-separated `keep` parameter into // an elide.Keep predicate. Each token is matched against the supplied // indexed symbols by ID, by name, or as a node kind (function / // method / type / …); every matched symbol contributes its line range // — the precise path. Tokens are additionally offered as bare names // so `keep` still works on a file with no indexed symbols. The second // return value lists the distinct symbol names that resolved, for the // response envelope. Returns (nil, nil) when keep is empty. func resolveKeepPredicate(keep string, symbols []*graph.Node) (func(elide.Decl) bool, []string) { tokens := splitCSV(keep) if len(tokens) == 0 { return nil, nil } want := make(map[string]struct{}, len(tokens)) for _, t := range tokens { want[t] = struct{}{} } var ranges [][2]int var resolved []string seen := make(map[string]struct{}) for _, n := range symbols { if n == nil || n.Kind == graph.KindFile { continue } _, byID := want[n.ID] _, byName := want[n.Name] _, byKind := want[string(n.Kind)] if !byID && !byName && !byKind { continue } if n.StartLine > 0 && n.EndLine >= n.StartLine { ranges = append(ranges, [2]int{n.StartLine, n.EndLine}) } if n.Name != "" { if _, dup := seen[n.Name]; !dup { seen[n.Name] = struct{}{} resolved = append(resolved, n.Name) } } } pred := elide.KeepAny(elide.KeepLineRanges(ranges), elide.KeepNames(tokens)) return pred, resolved } // editingContextSymbolNodes reconstructs the *graph.Node slice the // elide.KeepAny predicate needs from the editing-context Defines // rows. We carry the node IDs only on the wire, but a `keep` token // can target a node by id, name, or kind — so we re-resolve every // defines row to a node here. Used only when compress_bodies=true. func (s *Server) editingContextSymbolNodes(filePath string, defines []map[string]any) []*graph.Node { if len(defines) == 0 { return nil } ids := make([]string, 0, len(defines)) for _, d := range defines { if id, _ := d["id"].(string); id != "" { ids = append(ids, id) } } if len(ids) == 0 { return nil } nodes := s.graph.GetNodesByIDs(ids) out := make([]*graph.Node, 0, len(ids)) for _, id := range ids { if n, ok := nodes[id]; ok && n != nil { out = append(out, n) } } return out } func (s *Server) handleGetEditingContext(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { fp, err := req.RequireString("path") if err != nil { return mcp.NewToolResultError("path is required"), nil } // Normalise to the graph's stored path form so a repo-relative path // (internal/x.go) doesn't miss the repo-prefixed nodes in multi-repo // mode — the cause of spurious "no symbols found for file" misses. fp = s.graphRelPath(fp) // Auto re-index stale file before querying. s.ensureFresh([]string{fp}) s.sessionFor(ctx).recordFile(fp) // Tool-call observer: credit the recent search for the symbols in // the file the agent is about to edit. s.creditFileConsumption(ctx, fp) out := editingContext{} var fileNodeForScope *graph.Node callerCap := 20 calleeCap := 20 // Fast path: when the backend implements FileEditingContext we // take all five projections in a small fixed number of // round-trips instead of the per-symbol GetCallers / GetCallChain // loop. The fallback retains the previous engine-based shape so // the in-memory backend is unaffected. if fc, ok := s.graph.(graph.FileEditingContext); ok { bundle := fc.FileEditingContext(fp, []graph.NodeKind{graph.KindFunction, graph.KindMethod}) if bundle == nil || (bundle.FileNode == nil && len(bundle.Defines) == 0) { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } fileNodeForScope = bundle.FileNode if fileNodeForScope == nil && len(bundle.Defines) > 0 { fileNodeForScope = bundle.Defines[0] } if !s.nodeInSessionScope(ctx, fileNodeForScope) { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } for _, n := range bundle.Defines { s.frecency.Record(n.ID) } if bundle.FileNode != nil { out.File = map[string]any{"id": bundle.FileNode.ID, "language": bundle.FileNode.Language} } for _, n := range bundle.Defines { entry := map[string]any{ "id": n.ID, "kind": n.Kind, "name": n.Name, "start_line": n.StartLine, } if n.Meta != nil { if sig, ok := n.Meta["signature"]; ok { entry["signature"] = sig } } out.Defines = append(out.Defines, entry) } for _, e := range bundle.Imports { out.Imports = append(out.Imports, map[string]any{ "id": e.To, "external": strings.HasPrefix(e.To, "external::"), }) } // Workspace-scope post-filter mirrors the legacy GetCallers / // GetCallChain WorkspaceID gate. sessWS, _, bound := s.sessionScope(ctx) var opts query.QueryOptions if bound { opts.WorkspaceID = sessWS } for _, n := range bundle.CalledBy { if bound && !opts.ScopeAllows(n) { continue } if len(out.CalledBy) >= callerCap { break } out.CalledBy = append(out.CalledBy, map[string]any{ "id": n.ID, "name": n.Name, "file_path": n.FilePath, "start_line": n.StartLine, }) } for _, n := range bundle.Calls { if bound && !opts.ScopeAllows(n) { continue } if len(out.Calls) >= calleeCap { break } out.Calls = append(out.Calls, map[string]any{ "id": n.ID, "name": n.Name, "file_path": n.FilePath, "start_line": n.StartLine, }) } } else { sg := s.engineFor(ctx).GetFileSymbols(fp) if len(sg.Nodes) == 0 { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } if !s.nodeInSessionScope(ctx, sg.Nodes[0]) { return mcp.NewToolResultError("no symbols found for file: " + fp), nil } sessWS, _, _ := s.sessionScope(ctx) for _, n := range sg.Nodes { if n.Kind == graph.KindFile { continue } s.frecency.Record(n.ID) } for _, n := range sg.Nodes { if n.Kind == graph.KindFile { out.File = map[string]any{"id": n.ID, "language": n.Language} break } } for _, n := range sg.Nodes { if n.Kind == graph.KindFile { continue } entry := map[string]any{ "id": n.ID, "kind": n.Kind, "name": n.Name, "start_line": n.StartLine, } if sig, ok := n.Meta["signature"]; ok { entry["signature"] = sig } out.Defines = append(out.Defines, entry) } for _, e := range sg.Edges { if e.Kind == graph.EdgeImports { out.Imports = append(out.Imports, map[string]any{ "id": e.To, "external": strings.HasPrefix(e.To, "external::"), }) } } callerSeen := make(map[string]bool) for _, n := range sg.Nodes { if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod { callers := s.engineFor(ctx).GetCallers(n.ID, query.QueryOptions{Depth: 1, Limit: callerCap, Detail: "brief", WorkspaceID: sessWS}) for _, cn := range callers.Nodes { if cn.FilePath != fp && !callerSeen[cn.ID] { callerSeen[cn.ID] = true out.CalledBy = append(out.CalledBy, map[string]any{ "id": cn.ID, "name": cn.Name, "file_path": cn.FilePath, "start_line": cn.StartLine, }) } } } } callSeen := make(map[string]bool) for _, n := range sg.Nodes { if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod { chain := s.engineFor(ctx).GetCallChain(n.ID, query.QueryOptions{Depth: 1, Limit: calleeCap, Detail: "brief", WorkspaceID: sessWS}) for _, cn := range chain.Nodes { if cn.FilePath != fp && !callSeen[cn.ID] { callSeen[cn.ID] = true out.Calls = append(out.Calls, map[string]any{ "id": cn.ID, "name": cn.Name, "file_path": cn.FilePath, "start_line": cn.StartLine, }) } } } } } // Optional: full-file compressed view. When compress_bodies=true, // emit a `source_compressed` field carrying the whole file // through the tree-sitter elider so the agent gets signatures + // structure in one call without paying the cost of raw bodies. // Failures (no grammar, parse error) are swallowed — the caller // still gets the structural sections that fired above. var sourceCompressed string var keptSymbols []string if req.GetBool("compress_bodies", false) { var language string if out.File != nil { if lang, ok := out.File["language"].(string); ok { language = lang } } if language != "" && elide.IsSupported(language) { // Use the file node (cached above from the editing-context // bundle) to find the on-disk path. Falls back to the first // defines node if no file node materialised (defensive — the // FileEditingContext implementation always returns one when // the file is indexed). var fileBytes []byte anchor := fileNodeForScope if anchor != nil { if absPath, rerr := s.resolveNodePath(anchor); rerr == nil { if content, ok := s.overlayContentFor(ctx, absPath); ok { fileBytes = []byte(content) } else if b, ferr := os.ReadFile(absPath); ferr == nil { fileBytes = b } } } if len(fileBytes) > 0 { // `keep` pins a chosen subset of symbols to their // verbatim bodies while the rest of the file is still // stubbed — keep the functions being edited at full // source and compress everything else. keepNodes := s.editingContextSymbolNodes(fp, out.Defines) keepPred, resolved := resolveKeepPredicate(req.GetString("keep", ""), keepNodes) keptSymbols = resolved decide := fidelityDecideForPath(parseFidelityGlobs(req.GetString("fidelity_globs", "")), fp) if compressed, cerr := elide.CompressWith(fileBytes, language, elide.Options{Keep: keepPred, Decide: decide}); cerr == nil { sourceCompressed = string(compressed) } } } } // ETag conditional fetch. etag := computeETag(out) if sourceCompressed != "" { // Fold the compressed view into the etag so a flag flip // invalidates the cached entry on the caller side. etag = computeETag([2]any{out, sourceCompressed}) } if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { return notModifiedResult(etag), nil } // Server-side accounting only — an editing-context bundle stands in // for reading the whole file before editing it. ctxLang := "" if fileNodeForScope != nil { ctxLang = fileNodeForScope.Language } if payload, merr := json.Marshal(out); merr == nil { s.recordFileBaselineSavings(ctx, "get_editing_context", fp, ctxLang, string(payload)+sourceCompressed) } // Omission notes: flag vendored/generated provenance and body // compression so the model doesn't over-trust the payload. omissions := pathOmissions(fp) if sourceCompressed != "" { omissions = append(omissions, omission("compressed", "source_compressed replaces function and method bodies with elided stubs; signatures kept")) } if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeEditingContext(out.File, out.Defines, out.Imports, out.CalledBy, out.Calls, etag, omissionKindsCSV(omissions))) } // Add etag to response by marshaling to map. result := map[string]any{ "file": out.File, "defines": out.Defines, "imports": out.Imports, "called_by": out.CalledBy, "calls": out.Calls, "etag": etag, } if sourceCompressed != "" { result["source_compressed"] = sourceCompressed result["bodies_elided"] = true if len(keptSymbols) > 0 { result["kept_symbols"] = keptSymbols } } if len(omissions) > 0 { result["omissions"] = omissions } if s.isTOON(ctx, req) { return returnTOON(result) } return s.respondJSONOrTOON(ctx, req, result) } // findImportLanguageByExt maps the target file's extension to the // language label graph nodes carry. Covers the languages that have a // real import system (the rest fall through to an empty string, // which disables language filtering rather than blocking the lookup). var findImportLanguageByExt = map[string]string{ ".go": "go", ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".py": "python", ".rb": "ruby", ".rs": "rust", ".java": "java", ".kt": "kotlin", ".kts": "kotlin", ".php": "php", ".cs": "csharp", } func languageForExtension(path string) string { return findImportLanguageByExt[strings.ToLower(filepath.Ext(path))] } func (s *Server) handleFindImportPath(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { symbolName, err := req.RequireString("name") if err != nil { return mcp.NewToolResultError("name is required"), nil } targetFile, err := req.RequireString("path") if err != nil { return mcp.NewToolResultError("path is required"), nil } // Accept qualified Go names ("graph.Node", "pkg.Sym") by splitting // on the final dot. The first segment carries the package // qualifier the lookup should bias toward; the second is the // actual identifier the graph stores. Falls through unchanged for // bare names. packageHint, bareName := "", symbolName if dot := strings.LastIndex(symbolName, "."); dot > 0 && dot < len(symbolName)-1 { packageHint = symbolName[:dot] bareName = symbolName[dot+1:] } candidates := s.scopedNodeSlice(ctx, s.engineFor(ctx).FindSymbols(bareName)) if len(candidates) == 0 { return mcp.NewToolResultError("symbol not found: " + symbolName), nil } // Filter by the target file's language so a Go import lookup // never resolves to a TypeScript declaration that just happens to // share the identifier. Falls back to the unfiltered candidate // set when we can't infer a language for the target — better an // approximate hit than no hit at all. targetLang := languageForExtension(targetFile) if targetLang != "" { filtered := candidates[:0:len(candidates)] for _, c := range candidates { if c.Language == "" || c.Language == targetLang { filtered = append(filtered, c) } } if len(filtered) > 0 { candidates = filtered } } // Find the best match (prefer different directory from target; // when a package qualifier was supplied, bias toward candidates // whose home directory's leaf matches it). targetDir := filepath.Dir(targetFile) var best *graph.Node for _, c := range candidates { if c.Kind == graph.KindFile || c.Kind == graph.KindImport { continue } if best == nil { best = c } candDir := filepath.Dir(c.FilePath) // Package-qualifier bias: when the caller wrote `graph.Node`, // prefer a candidate whose directory leaf is `graph`. Matches // the Go convention that the import alias defaults to the // last path segment. if packageHint != "" && filepath.Base(candDir) == packageHint && candDir != targetDir { best = c break } // Prefer symbols NOT in the same directory (actual imports). if candDir != targetDir { best = c } } if best == nil { return mcp.NewToolResultError("no importable symbol found: " + symbolName), nil } // Check if already imported. alreadyImported := false fileSymbols := s.engineFor(ctx).GetFileSymbols(targetFile) if len(fileSymbols.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileSymbols.Nodes[0]) { fileSymbols = nil } if fileSymbols != nil { for _, e := range fileSymbols.Edges { if e.Kind == graph.EdgeImports && strings.Contains(e.To, filepath.Dir(best.FilePath)) { alreadyImported = true break } } } // Defensive: if the matched node carries an absolute file path // (older snapshots produced before applyRepoPrefix was hardened // could land abs paths in the graph), fold it back to a // repo-relative form so the response stays consistent with every // other graph tool. Without this, agents see a leaked // `/Users/...` import_path that confuses code generation. importDir := filepath.Dir(best.FilePath) if filepath.IsAbs(importDir) { importDir = s.repoRelative(importDir) } return s.respondJSONOrTOON(ctx, req, map[string]any{ "symbol_id": best.ID, "import_path": importDir, "already_imported": alreadyImported, }) } func (s *Server) handleGetRecentChanges(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { if s.watcher == nil { return mcp.NewToolResultError("watch mode is not active"), nil } sinceStr := req.GetString("since", "") var changes []map[string]any if sinceStr != "" { t, err := time.Parse(time.RFC3339, sinceStr) if err != nil { return mcp.NewToolResultError("invalid timestamp: " + sinceStr), nil } for _, ev := range s.watcher.HistorySince(t) { changes = append(changes, map[string]any{ "file": ev.FilePath, "kind": ev.Kind, "nodes_added": ev.NodesAdded, "nodes_removed": ev.NodesRemoved, "timestamp": ev.Timestamp.Format(time.RFC3339), }) } } else { for _, ev := range s.watcher.History() { changes = append(changes, map[string]any{ "file": ev.FilePath, "kind": ev.Kind, "nodes_added": ev.NodesAdded, "nodes_removed": ev.NodesRemoved, "timestamp": ev.Timestamp.Format(time.RFC3339), }) } } return s.respondJSONOrTOON(ctx, req, map[string]any{ "changes": changes, "graph_current_as_of": time.Now().Format(time.RFC3339), }) } // suggestSymbolIDs builds a short "did you mean" hint for a symbol id that // could not be resolved. It offers in-scope ids that share the requested // name anywhere in the graph (the "right name, wrong path" case) and, when // the id carries a file path, the symbols defined in that file ranked by // name similarity (the "right path, stale name" case). Returns "" when // nothing plausible is nearby, so callers can append it unconditionally. func (s *Server) suggestSymbolIDs(ctx context.Context, id string) string { eng := s.engineFor(ctx) if eng == nil || s.graph == nil { return "" } pathPart, namePart := "", id if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { pathPart, namePart = parts[0], parts[1] } // Drop a receiver qualifier so Foo.Bar can match a stored "Bar". base := namePart if i := strings.LastIndex(base, "."); i >= 0 && i < len(base)-1 { base = base[i+1:] } seen := map[string]bool{id: true} var out []string add := func(n *graph.Node) bool { if n == nil || seen[n.ID] || !s.nodeInSessionScope(ctx, n) { return false } seen[n.ID] = true out = append(out, n.ID) return len(out) >= 5 } // 1. Same-named symbols anywhere — the common wrong-path case. if base != "" { for _, n := range eng.FindSymbols(base) { if add(n) { break } } } // 2. Nearest-named symbols in the same file — the stale-name case. if pathPart != "" && len(out) < 5 { if sg := eng.GetFileSymbols(s.graphRelPath(pathPart)); sg != nil { type scored struct { n *graph.Node dist int } ranked := make([]scored, 0, len(sg.Nodes)) for _, n := range sg.Nodes { if n == nil || n.Kind == graph.KindFile { continue } ranked = append(ranked, scored{n, levenshtein(strings.ToLower(n.Name), strings.ToLower(base))}) } sort.Slice(ranked, func(i, j int) bool { return ranked[i].dist < ranked[j].dist }) for _, r := range ranked { if add(r.n) { break } } } } if len(out) == 0 { return "" } return " — did you mean: " + strings.Join(out, ", ") } func (s *Server) handleGetSymbolSource(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } // Auto re-index stale file before querying. if parts := strings.SplitN(id, "::", 2); len(parts) == 2 { s.ensureFresh([]string{parts[0]}) } node := s.engineFor(ctx).GetSymbol(id) if node == nil { return mcp.NewToolResultError("symbol not found: " + id + s.suggestSymbolIDs(ctx, id)), nil } // A by-id fetch must not cross the session's workspace boundary — // reported the same as a genuine miss so the boundary isn't // probeable. if !s.nodeInSessionScope(ctx, node) { return mcp.NewToolResultError("symbol not found: " + id), nil } sess := s.sessionFor(ctx) sess.recordSymbol(id) sess.recordFile(node.FilePath) // Credit this consume back to the most recent matching search_symbols, // if any; no-op when the combo tracker isn't initialised or no search // window is active. if q := sess.attributedQuery(id); q != "" { s.combo.Record(q, id) } // Unconditionally record the access for frecency — this is the "symbols // the agent actually reads" signal, useful even when no prior search // sourced it (agents also fetch symbols by ID from recent history). s.frecency.Record(id) if node.StartLine == 0 || node.EndLine == 0 { return mcp.NewToolResultError("symbol has no line range: " + id), nil } contextLines := req.GetInt("context_lines", 3) // Resolve the file path against whichever indexer owns the repo. // Multi-repo mode is the source of truth — node.RepoPrefix names // the owning repo and MultiIndexer holds its RootPath. Single-repo // mode falls back to the lone indexer's RootPath. Bare-relative // paths must never reach readLines: os.Open would resolve them // against the daemon process cwd, which is unrelated to any repo // and silently produces wrong results. absPath, resolveErr := s.resolveNodePath(node) if resolveErr != nil { return mcp.NewToolResultError(resolveErr.Error()), nil } if guardErr := s.guardSymlinkWithinRepo(absPath); guardErr != nil { return mcp.NewToolResultError(guardErr.Error()), nil } source, startLine, totalFileChars, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("could not read source: %v", err)), nil } compressBodies := req.GetBool("compress_bodies", false) bodiesElided := false if compressBodies && elide.IsSupported(node.Language) { if out, eerr := elide.CompressString(source, node.Language); eerr == nil && out != source { source = out bodiesElided = true } } // Salience truncation: when the symbol is still larger than // max_lines, keep its control-flow skeleton and collapse runs of // leaf statements rather than cutting the tail off blind. salienceTruncated := false if maxLines := req.GetInt("max_lines", 0); maxLines > 0 { if out, truncated, _ := elide.SalienceTruncate([]byte(source), node.Language, maxLines); truncated { source = string(out) salienceTruncated = true } } // Withhold secret-shaped values when serving a config / data-leaf symbol // (e.g. a populated .env / yaml key) unless the caller opts out. secretsRedacted := false if red, did := s.maybeRedactConfigLeaf(node.Language, node.FilePath, req.GetBool("allow_secrets", false), source); did { source = red secretsRedacted = true } // Server-side accounting only — the savings value isn't returned to // the caller (agents don't act on it and it burns tokens in every // response). Aggregated stats remain available via the `savings` tool. returned := tokens.CachedCountInt64(source) fullFile := int64(tokens.EstimateFromSample(totalFileChars, source)) s.tokenStatsFor(ctx).record(s.savingsAttributionNode(node), "get_symbol_source", returned, fullFile) result := map[string]any{ "id": node.ID, "kind": node.Kind, "name": node.Name, "file_path": node.FilePath, "start_line": node.StartLine, "end_line": node.EndLine, "source": source, "from_line": startLine, } if sig, ok := node.Meta["signature"]; ok { result["signature"] = sig } if bodiesElided { result["bodies_elided"] = true } if salienceTruncated { result["salience_truncated"] = true } if secretsRedacted { result["secrets_redacted"] = true } // Omission notes: flag what the payload leaves out or reshapes so // the model doesn't reason about absent code. omissions := pathOmissions(node.FilePath) 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 secretsRedacted { omissions = append(omissions, omission("secrets_withheld", "secret-shaped values in this config symbol were withheld; pass allow_secrets:true to read them")) } if len(omissions) > 0 { result["omissions"] = omissions } // ETag conditional fetch. etag := computeETag(result) if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { return notModifiedResult(etag), nil } result["etag"] = etag if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeGetSymbolSource(node, source, startLine, etag, omissionKindsCSV(omissions))) } if s.isTOON(ctx, req) { return returnTOON(result) } return s.respondJSONOrTOON(ctx, req, result) } // readLines reads lines from a file, with optional context lines above/below. // Returns the source text, the first line number, the total file size in characters // (for token savings estimation), and any error. // // Disk path only — used by helpers that have no MCP request context. // Production handlers should call (*Server).readLinesForCtx so the // editor-buffer overlay is honoured when active. func readLines(path string, startLine, endLine, contextLines int) (string, int, int, error) { f, err := os.Open(path) if err != nil { return "", 0, 0, err } defer f.Close() from := startLine - contextLines if from < 1 { from = 1 } to := endLine + contextLines var lines []string var totalChars int scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ text := scanner.Text() totalChars += len(text) + 1 // +1 for newline stripped by Scanner if lineNum >= from && lineNum <= to { lines = append(lines, text) } } if err := scanner.Err(); err != nil { return "", 0, 0, err } return strings.Join(lines, "\n"), from, totalChars, nil } // readLinesForCtx is the overlay-aware counterpart to readLines. // When ctx carries an editor-overlay view AND the path is covered by // the overlay, the buffer content is used instead of reading from // disk — so get_symbol_source / get_editing_context / smart_context // return the editor's unsaved view of the file. Falls back to // readLines transparently when no overlay applies. func (s *Server) readLinesForCtx(ctx context.Context, absPath string, startLine, endLine, contextLines int) (string, int, int, error) { content, ok := s.overlayContentFor(ctx, absPath) if !ok { return readLines(absPath, startLine, endLine, contextLines) } return extractLinesFromContent(content, startLine, endLine, contextLines) } // extractLinesFromContent applies the same line-slicing logic readLines // uses to an in-memory buffer. Kept separate from readLines so the // disk path stays a single os.Open / Scanner loop. func extractLinesFromContent(content string, startLine, endLine, contextLines int) (string, int, int, error) { from := startLine - contextLines if from < 1 { from = 1 } to := endLine + contextLines lines := strings.Split(content, "\n") totalChars := 0 for _, l := range lines { totalChars += len(l) + 1 } if totalChars > 0 { // strings.Split adds a phantom trailing entry when the // content ends with a newline; account for the over-count by // trimming one byte (matches readLines which counts the // trailing \n only when Scanner produced a line for it). totalChars-- } var picked []string for i, l := range lines { lineNum := i + 1 if lineNum >= from && lineNum <= to { picked = append(picked, l) } } return strings.Join(picked, "\n"), from, totalChars, nil } func (s *Server) handleBatchSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { idsStr, err := req.RequireString("ids") if err != nil { return mcp.NewToolResultError("ids is required"), nil } ids := strings.Split(idsStr, ",") for i := range ids { ids[i] = strings.TrimSpace(ids[i]) } includeSource := false if v, ok := req.GetArguments()["include_source"].(bool); ok { includeSource = v } contextLines := req.GetInt("context_lines", 3) // Confine the caller/callee neighbourhoods below to the session // workspace. sessWS, _, _ := s.sessionScope(ctx) var results []map[string]any for _, id := range ids { node := s.engineFor(ctx).GetSymbol(id) // A node outside the session's workspace is reported as a // miss — identical to a genuinely absent ID so the boundary // stays opaque. if node == nil || !s.nodeInSessionScope(ctx, node) { results = append(results, map[string]any{ "id": id, "error": "symbol not found", }) continue } entry := map[string]any{ "id": node.ID, "kind": node.Kind, "name": node.Name, "file_path": node.FilePath, "start_line": node.StartLine, "end_line": node.EndLine, } if sig, ok := node.Meta["signature"]; ok { entry["signature"] = sig } // Callers (depth 1). if node.Kind == graph.KindFunction || node.Kind == graph.KindMethod { callers := s.engineFor(ctx).GetCallers(node.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief", WorkspaceID: sessWS}) var callerIDs []string for _, cn := range callers.Nodes { if cn.ID != node.ID { callerIDs = append(callerIDs, cn.ID) } } if len(callerIDs) > 0 { entry["callers"] = callerIDs } // Callees (depth 1). callees := s.engineFor(ctx).GetCallChain(node.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief", WorkspaceID: sessWS}) var calleeIDs []string for _, cn := range callees.Nodes { if cn.ID != node.ID { calleeIDs = append(calleeIDs, cn.ID) } } if len(calleeIDs) > 0 { entry["callees"] = calleeIDs } } // Source code (optional). if includeSource && node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(node); err == nil { if source, fromLine, totalFileChars, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, contextLines); err == nil { entry["source"] = source entry["from_line"] = fromLine returned := tokens.CachedCountInt64(source) fullFile := int64(tokens.EstimateFromSample(totalFileChars, source)) s.tokenStatsFor(ctx).record(s.savingsAttributionNode(node), "batch_symbols", returned, fullFile) } } } results = append(results, entry) } batchResult := map[string]any{ "symbols": results, "total": len(results), } // ETag conditional fetch. etag := computeETag(batchResult) if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { return notModifiedResult(etag), nil } batchResult["etag"] = etag if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeBatchSymbols(results, includeSource)) } if s.isTOON(ctx, req) { return returnTOON(batchResult) } return s.respondJSONOrTOON(ctx, req, batchResult) } // Test file patterns by language. var testFilePatterns = []struct { suffix string lang string }{ {"_test.go", "go"}, {".test.ts", "typescript"}, {".test.tsx", "typescript"}, {".spec.ts", "typescript"}, {".test.js", "javascript"}, {".spec.js", "javascript"}, {"_test.py", "python"}, {"test_", "python"}, {"_test.rs", "rust"}, {"Test.java", "java"}, {"_test.rb", "ruby"}, {"_test.exs", "elixir"}, {"_test.kt", "kotlin"}, {"Tests.swift", "swift"}, {"Test.scala", "scala"}, {"Test.php", "php"}, {"Test.cs", "csharp"}, } func isTestFile(path string) bool { for _, p := range testFilePatterns { if strings.Contains(path, p.suffix) { return true } } return strings.Contains(path, "__tests__/") || strings.Contains(path, "/test/") } // partitionProductionFirst returns a stable partition of nodes with // every production-code symbol ahead of every test symbol, preserving // the relative order within each group. Used to bias the limited // smart_context source-embed slots toward implementation over the // tests that exercise it without disturbing the upstream rerank order. func partitionProductionFirst(nodes []*graph.Node) []*graph.Node { if len(nodes) < 2 { return nodes } prod := make([]*graph.Node, 0, len(nodes)) tests := make([]*graph.Node, 0) for _, n := range nodes { if n != nil && isTestFile(n.FilePath) { tests = append(tests, n) continue } prod = append(prod, n) } if len(tests) == 0 { return nodes } return append(prod, tests...) } // buildWorkingSetClusters groups a smart_context working set by source // file, production files first, returning one entry per file as // {file, symbols:[ids], is_test}. Symbol IDs within a file and the file // groups themselves are sorted so the block is byte-stable and feeds a // deterministic pack root. func buildWorkingSetClusters(nodes []*graph.Node) []map[string]any { if len(nodes) == 0 { return nil } byFile := make(map[string][]string) isTest := make(map[string]bool) for _, n := range nodes { if n == nil { continue } byFile[n.FilePath] = append(byFile[n.FilePath], n.ID) isTest[n.FilePath] = isTestFile(n.FilePath) } prodFiles := make([]string, 0, len(byFile)) testFiles := make([]string, 0) for f := range byFile { if isTest[f] { testFiles = append(testFiles, f) continue } prodFiles = append(prodFiles, f) } sort.Strings(prodFiles) sort.Strings(testFiles) ordered := append(prodFiles, testFiles...) clusters := make([]map[string]any, 0, len(ordered)) for _, f := range ordered { ids := byFile[f] sort.Strings(ids) clusters = append(clusters, map[string]any{ "file": f, "symbols": ids, "is_test": isTest[f], }) } return clusters } func (s *Server) handleGetTestTargets(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { idsStr, err := req.RequireString("ids") if err != nil { return mcp.NewToolResultError("ids is required"), nil } ids := strings.Split(idsStr, ",") for i := range ids { ids[i] = strings.TrimSpace(ids[i]) } depth := req.GetInt("depth", 3) // For each symbol, trace callers and collect test nodes. type testTarget struct { File string `json:"file"` Functions []string `json:"functions"` } // Map: test file -> set of test function names. testFiles := make(map[string]map[string]bool) // Track which changed symbols are covered. coveredSymbols := make(map[string]bool) for _, id := range ids { node := s.engineFor(ctx).GetSymbol(id) if node == nil { continue } // Fast path: use the persistent EdgeTests edges that the // indexer's test-edge pass attached to the graph. A direct // inverse-edge walk is one hop instead of the BFS-on-EdgeCalls // that this tool used to do, and it's exact (no isTestFile // post-filter needed). if testers := s.engineFor(ctx).GetTesters(id); len(testers) > 0 { for _, tn := range testers { if tn == nil { continue } coveredSymbols[id] = true if testFiles[tn.FilePath] == nil { testFiles[tn.FilePath] = make(map[string]bool) } if tn.Kind == graph.KindFunction || tn.Kind == graph.KindMethod { testFiles[tn.FilePath][tn.Name] = true } } continue } // Fallback for graphs that haven't been re-indexed since the // EdgeTests pass shipped, or for indirect coverage (depth > 1). callers := s.engineFor(ctx).GetCallers(id, query.QueryOptions{Depth: depth, Limit: 100, Detail: "brief"}) for _, cn := range callers.Nodes { if !isTestFile(cn.FilePath) { continue } coveredSymbols[id] = true if testFiles[cn.FilePath] == nil { testFiles[cn.FilePath] = make(map[string]bool) } if cn.Kind == graph.KindFunction || cn.Kind == graph.KindMethod { testFiles[cn.FilePath][cn.Name] = true } } // Also check if the symbol itself is in a test file (e.g. test helper). if isTestFile(node.FilePath) { coveredSymbols[id] = true if testFiles[node.FilePath] == nil { testFiles[node.FilePath] = make(map[string]bool) } } } // Build result. var targets []testTarget for file, funcs := range testFiles { var names []string for name := range funcs { names = append(names, name) } targets = append(targets, testTarget{ File: file, Functions: names, }) } // Build run commands (Go-specific for now, extensible later). var runCommands []string for _, t := range targets { if strings.HasSuffix(t.File, "_test.go") { dir := filepath.Dir(t.File) if len(t.Functions) > 0 { runCommands = append(runCommands, fmt.Sprintf("go test -run %s ./%s/", strings.Join(t.Functions, "|"), dir)) } else { runCommands = append(runCommands, fmt.Sprintf("go test ./%s/", dir)) } } } // Uncovered symbols (no test found). var uncovered []string for _, id := range ids { if !coveredSymbols[id] { uncovered = append(uncovered, id) } } return s.respondJSONOrTOON(ctx, req, map[string]any{ "test_targets": targets, "run_commands": runCommands, "total_files": len(targets), "uncovered": uncovered, "coverage_note": fmt.Sprintf("%d/%d changed symbols have test coverage", len(coveredSymbols), len(ids)), }) } // elideSourceForPattern keeps the first head + last tail of a source // block when it exceeds maxLines, joined by `... N lines elided ...`. // suggest_pattern emits up to seven source blocks per call (example + // up to ten registration callers + up to three test patterns) — full // bodies were the main reason a single call could exceed 5KB. Pass // maxLines <= 0 to disable elision. func elideSourceForPattern(source string, maxLines int) string { if maxLines <= 0 || source == "" { return source } lines := strings.Split(source, "\n") if len(lines) <= maxLines { return source } // Keep ~2/3 of the budget at the head (declarations + body // opening), 1/3 at the tail (closing scope / return). A few-line // preview of each end is enough for an agent to follow the // pattern; the elision marker tells the caller they can ask for // more by raising max_source_lines. head := (maxLines * 2) / 3 if head < 1 { head = 1 } tail := maxLines - head if tail < 1 { tail = 1 } if head+tail >= len(lines) { return source } elided := len(lines) - head - tail var b strings.Builder b.WriteString(strings.Join(lines[:head], "\n")) b.WriteString("\n") fmt.Fprintf(&b, "... %d lines elided (raise max_source_lines to see) ...\n", elided) b.WriteString(strings.Join(lines[len(lines)-tail:], "\n")) return b.String() } func (s *Server) handleSuggestPattern(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { exampleID, err := req.RequireString("id") if err != nil { return mcp.NewToolResultError("id is required"), nil } node := s.engineFor(ctx).GetSymbol(exampleID) if node == nil { return mcp.NewToolResultError("symbol not found: " + exampleID), nil } // Per-source elision threshold. The handler stitches together // the example, every registration caller, and up to three test // patterns — each with full source — which used to balloon the // response to several KB on real handlers. Elide each source // past this many lines so the default response stays compact; // pass `max_source_lines: 0` to opt out. maxSourceLines := req.GetInt("max_source_lines", 40) result := map[string]any{ "example": map[string]any{ "id": node.ID, "kind": node.Kind, "name": node.Name, "file_path": node.FilePath, }, } // 1. Get the example source. if node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(node); err == nil { if source, _, _, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, 0); err == nil { result["example_source"] = elideSourceForPattern(source, maxSourceLines) } } } if sig, ok := node.Meta["signature"]; ok { result["signature"] = sig } // 2. Find siblings — same kind, same file, similar naming pattern. fileSymbols := s.engineFor(ctx).GetFileSymbols(node.FilePath) if len(fileSymbols.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileSymbols.Nodes[0]) { fileSymbols = &query.SubGraph{} } var siblings []map[string]any prefix := extractPrefix(node.Name) for _, sn := range fileSymbols.Nodes { if sn.ID == node.ID || sn.Kind != node.Kind { continue } siblings = append(siblings, map[string]any{ "id": sn.ID, "name": sn.Name, "start_line": sn.StartLine, }) } if len(siblings) > 10 { siblings = siblings[:10] } result["siblings"] = siblings result["siblings_count"] = len(fileSymbols.Nodes) - 1 // exclude file node // 3. Find how the example is wired/registered (callers at depth 1). callers := s.engineFor(ctx).GetCallers(exampleID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) var registration []map[string]any for _, cn := range callers.Nodes { if cn.ID == exampleID { continue } entry := map[string]any{ "id": cn.ID, "name": cn.Name, "file_path": cn.FilePath, "start_line": cn.StartLine, } // Get the registration source (the caller function that wires this symbol). if cn.StartLine > 0 && cn.EndLine > 0 { if absPath, err := s.resolveNodePath(cn); err == nil { if source, _, _, err := s.readLinesForCtx(ctx, absPath, cn.StartLine, cn.EndLine, 0); err == nil { entry["source"] = elideSourceForPattern(source, maxSourceLines) } } } registration = append(registration, entry) } result["registration"] = registration // 4. Find test patterns — look for test symbols with matching name prefix. var testPatterns []map[string]any if prefix != "" { // Search for test functions that match the example name. testSearch := s.scopedNodeSlice(ctx, s.engineFor(ctx).SearchSymbols(node.Name, 20)) for _, tn := range testSearch { if !isTestFile(tn.FilePath) { continue } if tn.Kind != graph.KindFunction && tn.Kind != graph.KindMethod { continue } entry := map[string]any{ "id": tn.ID, "name": tn.Name, "file_path": tn.FilePath, "start_line": tn.StartLine, } // Get test source. if tn.StartLine > 0 && tn.EndLine > 0 { if absPath, err := s.resolveNodePath(tn); err == nil { if source, _, _, err := s.readLinesForCtx(ctx, absPath, tn.StartLine, tn.EndLine, 0); err == nil { entry["source"] = elideSourceForPattern(source, maxSourceLines) } } } testPatterns = append(testPatterns, entry) if len(testPatterns) >= 3 { break } } } result["test_patterns"] = testPatterns // 5. Files to edit — where would you add a new instance of this pattern? filesToEdit := []map[string]any{ {"file": node.FilePath, "reason": "add new symbol here (same file as example)"}, } for _, reg := range registration { if fp, ok := reg["file_path"].(string); ok && fp != node.FilePath { filesToEdit = append(filesToEdit, map[string]any{ "file": fp, "reason": "update registration/wiring", }) } } for _, tp := range testPatterns { if fp, ok := tp["file_path"].(string); ok { filesToEdit = append(filesToEdit, map[string]any{ "file": fp, "reason": "add test for new symbol", }) break // one test file is enough } } result["files_to_edit"] = filesToEdit return s.respondJSONOrTOON(ctx, req, result) } func (s *Server) handleGetEditPlan(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { idsStr, err := req.RequireString("ids") if err != nil { return mcp.NewToolResultError("ids is required"), nil } ids := strings.Split(idsStr, ",") for i := range ids { ids[i] = strings.TrimSpace(ids[i]) } depth := req.GetInt("depth", 3) type editStep struct { File string `json:"file"` Symbols []string `json:"symbols"` Reason string `json:"reason"` Order int `json:"order"` } // Track files by category and depth. type fileInfo struct { symbols map[string]bool reason string order int // lower = edit first } files := make(map[string]*fileInfo) addFile := func(filePath, symbol, reason string, order int) { if fi, ok := files[filePath]; ok { fi.symbols[symbol] = true // Keep the lowest (highest priority) order. if order < fi.order { fi.order = order fi.reason = reason } } else { files[filePath] = &fileInfo{ symbols: map[string]bool{symbol: true}, reason: reason, order: order, } } } changedFiles := make(map[string]bool) // Order 0: The changed symbols themselves (definitions). for _, id := range ids { node := s.engineFor(ctx).GetSymbol(id) if node == nil { continue } addFile(node.FilePath, node.Name, "definition — change starts here", 0) changedFiles[node.FilePath] = true // Check if symbol is an interface — implementations need updating. if node.Kind == graph.KindInterface { impls := s.scopedNodeSlice(ctx, s.engineFor(ctx).FindImplementations(id)) for _, impl := range impls { addFile(impl.FilePath, impl.Name, "implements "+node.Name+" — must conform to changes", 1) } } // Check MemberOf — if changing a type, its methods may need updating. if node.Kind == graph.KindType || node.Kind == graph.KindInterface { inEdges := s.engineFor(ctx).GetInEdges(id) for _, e := range inEdges { if e.Kind == graph.EdgeMemberOf { memberNode := s.engineFor(ctx).GetSymbol(e.From) if memberNode != nil { addFile(memberNode.FilePath, memberNode.Name, "member of "+node.Name, 1) } } } } } // Order 2-N: Dependents at increasing depth (callers/importers). for _, id := range ids { dependents := s.engineFor(ctx).GetDependents(id, query.QueryOptions{Depth: depth, Limit: 100, Detail: "brief"}) for _, dn := range dependents.Nodes { if dn.Kind == graph.KindFile { continue } // Skip the changed symbols themselves. isChanged := false for _, cid := range ids { if dn.ID == cid { isChanged = true break } } if isChanged { continue } if isTestFile(dn.FilePath) { addFile(dn.FilePath, dn.Name, "test — verify after changes", 100) } else if changedFiles[dn.FilePath] { // Same file as a changed symbol, already covered. addFile(dn.FilePath, dn.Name, "definition — change starts here", 0) } else { addFile(dn.FilePath, dn.Name, "dependent — may need updating", 2) } } } // Sort by order, then by file path. type sortableStep struct { filePath string info *fileInfo } var sorted []sortableStep for fp, fi := range files { sorted = append(sorted, sortableStep{fp, fi}) } // Stable sort: order first, then alphabetical. for i := 0; i < len(sorted); i++ { for j := i + 1; j < len(sorted); j++ { if sorted[j].info.order < sorted[i].info.order || (sorted[j].info.order == sorted[i].info.order && sorted[j].filePath < sorted[i].filePath) { sorted[i], sorted[j] = sorted[j], sorted[i] } } } var steps []editStep for _, s := range sorted { var symbols []string for sym := range s.info.symbols { symbols = append(symbols, sym) } steps = append(steps, editStep{ File: s.filePath, Symbols: symbols, Reason: s.info.reason, Order: s.info.order, }) } // Separate test files. var editSteps, testSteps []editStep for _, step := range steps { if isTestFile(step.File) { testSteps = append(testSteps, step) } else { editSteps = append(editSteps, step) } } return s.respondJSONOrTOON(ctx, req, map[string]any{ "edit_order": editSteps, "test_after": testSteps, "total_files": len(steps), "summary": fmt.Sprintf("%d files to edit, %d test files to verify", len(editSteps), len(testSteps)), }) } // extractPrefix returns the common prefix of a camelCase/PascalCase name. // e.g. "handleGetSymbol" -> "handle", "TestNewServer" -> "Test" func (s *Server) handleSmartContext(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { task, err := req.RequireString("task") if err != nil { return mcp.NewToolResultError("task is required"), nil } // Lift any inline path: clause out of the task text so a // caller can scope smart_context to a monorepo sub-path the // same way search_symbols does. The residual text drives // keyword extraction. taskFQ := parseFieldQuery(task) if taskFQ.Text != "" { task = taskFQ.Text } entryPoint := req.GetString("entry_point", "") // Seed count defaults adaptively to repo size — req.GetInt can't // tell an absent argument from an explicit value, so detect // presence on the raw arguments map and only scale when the caller // left it out. maxSymbols := req.GetInt("max_symbols", 5) if _, present := req.GetArguments()["max_symbols"]; !present { nodes := 0 if s.graph != nil { nodes = s.graph.NodeCount() } maxSymbols = smartContextSeedCount(nodes) } graded := req.GetString("fidelity", "") == "graded" // Token budget for the graded manifest defaults adaptively to repo // size — same absent-vs-explicit detection as max_symbols, since // req.GetInt can't distinguish them. tokenBudget := req.GetInt("token_budget", defaultManifestBudget) if _, present := req.GetArguments()["token_budget"]; !present { nodes := 0 if s.graph != nil { nodes = s.graph.NodeCount() } tokenBudget = manifestBudgetForNodeCount(nodes) } result := map[string]any{ "task": task, } // 1. Extract keywords from task description. Kept internal — the // caller doesn't need the derivation to act on the result, and // echoing 5-10 keywords per response is pure token bloat. keywords := extractKeywords(task) // 2. Search for relevant symbols using each keyword. seen := make(map[string]bool) var relevantSymbols []*graph.Node for _, kw := range keywords { if len(kw) < 3 { continue } matches := s.scopedNodeSlice(ctx, s.engineFor(ctx).SearchSymbols(kw, 10)) for _, m := range matches { if m.Kind == graph.KindFile || m.Kind == graph.KindImport { continue } if !seen[m.ID] { seen[m.ID] = true relevantSymbols = append(relevantSymbols, m) } } } // 3. If entry point given, resolve it and prioritize. var entryNode *graph.Node if entryPoint != "" { // Try as symbol ID first. entryNode = s.engineFor(ctx).GetSymbol(entryPoint) if entryNode == nil { // Try as file path — get the most important symbol in the file. fileSym := s.engineFor(ctx).GetFileSymbols(entryPoint) if len(fileSym.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileSym.Nodes[0]) { fileSym = &query.SubGraph{} } if len(fileSym.Nodes) > 0 { for _, n := range fileSym.Nodes { if n.Kind != graph.KindFile { entryNode = n break } } } } if entryNode != nil && !seen[entryNode.ID] { relevantSymbols = append([]*graph.Node{entryNode}, relevantSymbols...) seen[entryNode.ID] = true } } // 3b. Apply repo/project filter to relevant symbols. allowed, filterErr := s.resolveRepoFilter(ctx, req) if filterErr != nil { return mcp.NewToolResultError(filterErr.Error()), nil } relevantSymbols = filterNodes(relevantSymbols, allowed) // Sub-path scoping: a `path` argument, an inline path: clause, or // a scope's saved paths narrow smart_context to a monorepo // service slice. if pathFilter := s.resolvePathFilter(req, taskFQ); len(pathFilter) > 0 { relevantSymbols = applyPathFilter(relevantSymbols, pathFilter) } // 3c. Fold frequently-missed symbols (that match task keywords) // into the working set before ranking, so feedback can pull a // previously-dropped result back into the embedded slots — not // just append it at the tail. if s.feedback != nil && s.feedback.HasData() { missed := s.feedback.MissedSymbolsForQuery(task, 3) injected := 0 for _, missedID := range missed { if injected >= 2 { break } if seen[missedID] { continue } missedNode := s.readerFor(ctx).GetNode(missedID) if missedNode == nil { continue } nameLower := strings.ToLower(missedNode.Name) for _, kw := range keywords { if strings.Contains(nameLower, strings.ToLower(kw)) { relevantSymbols = append(relevantSymbols, missedNode) seen[missedID] = true injected++ break } } } } // 3d. Rank the deduped working set through the full rerank // pipeline — the same churn / HITS / community / co-change / // frecency / feedback / path-penalty / source-bias / provenance // scoring that powers search_symbols and winnow. The keyword-merge // order seeds TextRank so BM25 provenance survives into the rerank. // The pipeline is nil in the test harness (Rerank disabled); fall // back to the legacy feedback-aware local sort there so ordering // stays sensible without a pipeline. if len(relevantSymbols) > 0 { pipeline := s.engineFor(ctx).Rerank() if pipeline != nil { cands := make([]*rerank.Candidate, len(relevantSymbols)) idxByID := make(map[string]int, len(relevantSymbols)) for i, sym := range relevantSymbols { cands[i] = &rerank.Candidate{Node: sym, TextRank: i, VectorRank: -1} idxByID[sym.ID] = i } rctx := s.buildRerankContext(ctx, task) pipeline.Rerank(task, cands, rctx) ordered := make([]*graph.Node, 0, len(cands)) for _, cand := range cands { ordered = append(ordered, relevantSymbols[idxByID[cand.Node.ID]]) } relevantSymbols = ordered } else if s.feedback != nil && s.feedback.HasData() { type scored struct { node *graph.Node score float64 } scoredSyms := make([]scored, len(relevantSymbols)) for i, sym := range relevantSymbols { baseScore := 1.0 / float64(i+1) // BM25 rank-based score fbScore := s.feedback.GetSymbolScoreForQuery(sym.ID, task) scoredSyms[i] = scored{node: sym, score: baseScore + fbScore*0.3} } sort.SliceStable(scoredSyms, func(i, j int) bool { return scoredSyms[i].score > scoredSyms[j].score }) for i, ss := range scoredSyms { relevantSymbols[i] = ss.node } } } // 3d-bias. Bias production code ahead of tests. A stable partition // keeps the rerank order within each group but pulls every // non-test symbol in front of every test symbol, so the limited // full-source embed slots and the head of files_to_edit favour the // implementation over the test that exercises it. relevantSymbols = partitionProductionFirst(relevantSymbols) // 3e. Re-pin the resolved entry point at the head: a caller that // named an entry_point wants it first regardless of rerank order. if entryNode != nil && len(relevantSymbols) > 0 && relevantSymbols[0].ID != entryNode.ID { reordered := make([]*graph.Node, 0, len(relevantSymbols)) reordered = append(reordered, entryNode) for _, sym := range relevantSymbols { if sym.ID != entryNode.ID { reordered = append(reordered, sym) } } relevantSymbols = reordered } // Pack strategy: reorder the working set per GORTEX_PACK_STRATEGY // before the count cap / manifest budget fill. The default (top-k, // rank-faithful) is identity, so existing callers are unaffected; // density / file-grouped re-order by token efficiency or file // coherence. This is the same strategy the eval harness A/Bs. relevantSymbols = s.applyPackStrategy(relevantSymbols) // 4. Limit to top N most relevant symbols. In graded-fidelity mode // the overflow seeds the manifest's outline tier instead of being // discarded. var outlineCandidates []*graph.Node if len(relevantSymbols) > maxSymbols { outlineCandidates = append(outlineCandidates, relevantSymbols[maxSymbols:]...) relevantSymbols = relevantSymbols[:maxSymbols] } // 4b. Estimate mode short-circuits: project the symbol-delivery // token cost and return it without assembling the payload, so the // caller can budget before fetching the real context. if req.GetBool("estimate", false) { estResult := map[string]any{ "task": task, "estimate": s.buildSmartContextEstimate( ctx, graded, tokenBudget, relevantSymbols, outlineCandidates), } if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeSmartContextEstimate(estResult)) } if s.isTOON(ctx, req) { return returnTOON(estResult) } return s.respondJSONOrTOON(ctx, req, estResult) } // 5. Get source and signatures for relevant symbols. Source is // only embedded for the top smartCtxMaxSource functions/methods — // signatures alone cover 80% of agent decision-making and each // full source snippet adds several hundred tokens. Callers that // need more can follow up with get_symbol_source for specific IDs. sourcesEmbedded := 0 var symbolContexts []map[string]any for _, sym := range relevantSymbols { entry := map[string]any{ "id": sym.ID, "kind": sym.Kind, "name": sym.Name, "file_path": sym.FilePath, "language": sym.Language, "start_line": sym.StartLine, } if sig, ok := sym.Meta["signature"]; ok { entry["signature"] = sig } if !graded && sourcesEmbedded < smartCtxMaxSource && (sym.Kind == graph.KindFunction || sym.Kind == graph.KindMethod) && sym.StartLine > 0 && sym.EndLine > 0 { if absPath, err := s.resolveNodePath(sym); err == nil { if source, _, totalFileChars, err := s.readLinesForCtx(ctx, absPath, sym.StartLine, sym.EndLine, 0); err == nil { if red, did := s.maybeRedactConfigLeaf(sym.Language, sym.FilePath, false, source); did { source = red } entry["source"] = source sourcesEmbedded++ returned := tokens.CachedCountInt64(source) fullFile := int64(tokens.EstimateFromSample(totalFileChars, source)) s.tokenStatsFor(ctx).record(s.savingsAttributionNode(sym), "smart_context", returned, fullFile) } } } symbolContexts = append(symbolContexts, entry) } result["relevant_symbols"] = symbolContexts // Report the focus-symbol count to the retrieval query log so a // zero-result context assembly is recorded as such. recordQueryResultCount(ctx, len(symbolContexts)) // 5b1. Graded-fidelity manifest: focus symbols at full source, // their caller/callee adjacency ring as elided signature stubs, // and the keyword-match remainder as an outline — all packed // under one token budget. if graded { result["context_manifest"] = s.buildContextManifest( ctx, relevantSymbols, outlineCandidates, tokenBudget) } // 5b. Include cross-repo dependencies when in multi-repo mode. if s.multiIndexer != nil && s.multiIndexer.IsMultiRepo() { var crossRepoDeps []map[string]any crossSeen := make(map[string]bool) for _, sym := range relevantSymbols { // Check outgoing edges for cross-repo references. outEdges := s.engineFor(ctx).GetOutEdges(sym.ID) for _, e := range outEdges { if !e.CrossRepo || crossSeen[e.To] { continue } crossSeen[e.To] = true targetNode := s.engineFor(ctx).GetSymbol(e.To) if targetNode == nil { continue } dep := map[string]any{ "id": targetNode.ID, "kind": targetNode.Kind, "name": targetNode.Name, "file_path": targetNode.FilePath, "repo_prefix": targetNode.RepoPrefix, "edge_kind": e.Kind, } if sig, ok := targetNode.Meta["signature"]; ok { dep["signature"] = sig } crossRepoDeps = append(crossRepoDeps, dep) } } if len(crossRepoDeps) > 0 { result["cross_repo_dependencies"] = crossRepoDeps } } // 6. If we have an entry point, get its pattern (registration, siblings, tests). if entryNode != nil { // File context: imports and structure. fileCtx := s.engineFor(ctx).GetFileSymbols(entryNode.FilePath) if len(fileCtx.Nodes) > 0 && !s.nodeInSessionScope(ctx, fileCtx.Nodes[0]) { fileCtx = &query.SubGraph{} } var fileSymbols []string for _, n := range fileCtx.Nodes { if n.Kind != graph.KindFile { fileSymbols = append(fileSymbols, fmt.Sprintf("%s %s (line %d)", n.Kind, n.Name, n.StartLine)) } } result["entry_file_symbols"] = fileSymbols // Callers and callees. callers := s.engineFor(ctx).GetCallers(entryNode.ID, query.QueryOptions{Depth: 1, Limit: 5, Detail: "brief"}) var callerIDs []string for _, cn := range callers.Nodes { if cn.ID != entryNode.ID { callerIDs = append(callerIDs, cn.ID) } } if len(callerIDs) > 0 { result["callers"] = callerIDs } callees := s.engineFor(ctx).GetCallChain(entryNode.ID, query.QueryOptions{Depth: 1, Limit: 5, Detail: "brief"}) var calleeIDs []string for _, cn := range callees.Nodes { if cn.ID != entryNode.ID { calleeIDs = append(calleeIDs, cn.ID) } } if len(calleeIDs) > 0 { result["callees"] = calleeIDs } } // 7. Find test files related to the keywords. var testFiles []string testSeen := make(map[string]bool) for _, sym := range relevantSymbols { callers := s.engineFor(ctx).GetCallers(sym.ID, query.QueryOptions{Depth: 2, Limit: 20, Detail: "brief"}) for _, cn := range callers.Nodes { if isTestFile(cn.FilePath) && !testSeen[cn.FilePath] { testSeen[cn.FilePath] = true testFiles = append(testFiles, cn.FilePath) } } } if len(testFiles) > 5 { testFiles = testFiles[:5] } result["related_test_files"] = testFiles // 8. Files likely to edit. fileSet := make(map[string]bool) for _, sym := range relevantSymbols { fileSet[sym.FilePath] = true } var filesToEdit []string for f := range fileSet { filesToEdit = append(filesToEdit, f) } // Sorted so the assembled pack is byte-stable across identical // calls — the pack-root etag below depends on it. sort.Strings(filesToEdit) for _, tf := range testFiles { if !fileSet[tf] { filesToEdit = append(filesToEdit, tf) } } result["files_to_edit"] = filesToEdit // 8b. Clustered working set — the same symbols grouped by their // source file (production files first), so an agent sees which // files carry the relevant symbols and which are tests without // re-deriving it from the flat files_to_edit list. files_to_edit // is kept for back-compat. if ws := buildWorkingSetClusters(relevantSymbols); len(ws) > 0 { result["working_set"] = ws } // 9. Fused blast radius — computed for every call, not just when an // entry point is named. Groups the working set's callers by their // source file and lists the tests that cover the working set via // the exact EdgeTests inverse walk, so an agent sees what its edit // can break and what guards it before it touches a line. if br := s.buildBlastRadius(ctx, relevantSymbols); br != nil { result["blast_radius"] = br } // Opt-in in-pack enrichment sections (off by default). inPackSections := s.smartContextSections(req.GetArguments(), entryPoint) s.attachInPackSections(result, inPackSections, relevantSymbols) if inPackSections.Confidence { if v := s.inPackConfidence(ctx, task); v != nil { addInPackSection(result, "confidence", v) } } // Always-on low-confidence retrieval note: independent of the opt-in // verbose confidence block above, when the retrieval that seeded this // pack looks untrustworthy (flat ranked distribution, or a head symbol // anchored only by speculative edges) attach a compact, actionable note // routing the agent to Gortex's richer escape hatches. Suppressed for // single-symbol / distinctive-identifier lookups, so a confident exact // query never draws a hedge. if note := s.lowConfidenceRetrievalNote(ctx, task, relevantSymbols); note != nil { result["retrieval_note"] = note } // Pack-assembly passes: recover the edges between pack symbols a many-rooted // retrieval leaves disconnected, and surface class-hierarchy siblings. if rec := s.recoverPackEdges(relevantSymbols); len(rec) > 0 { result["recovered_edges"] = rec } if sibs := s.packHierarchySiblings(relevantSymbols); len(sibs) > 0 { result["hierarchy_siblings"] = sibs } // Pack-root dedup: hash the assembled context pack. When the // caller passes back the pack root it already holds and nothing // the pack covers has changed, return not_modified instead of // retransmitting the whole payload. etag := computePackRoot(result) if ifNoneMatch := req.GetString("if_none_match", ""); ifNoneMatch != "" && ifNoneMatch == etag { return notModifiedResult(etag), nil } result["etag"] = etag // Delta context packing: cache this pack's canonical view under its // root, and when the caller passes a prior root it already holds, // return only the added/removed/changed symbols vs that pack instead // of re-emitting the whole thing. When the delta is materially // smaller than the full pack we drop the bulky symbol lists from the // response (the agent merges the delta into its held pack); the pack // root still identifies the full pack for the next delta_from. currentView := extractPackView(result, true) s.packCache.put(etag, currentView) if deltaFrom := req.GetString("delta_from", ""); deltaFrom != "" { if prior, ok := s.packCache.get(deltaFrom); ok { delta := diffPackViews(prior, currentView, deltaFrom, etag) result["pack_delta"] = delta if worth, _ := delta["worth_it"].(bool); worth { result["delta"] = true delete(result, "relevant_symbols") delete(result, "context_manifest") // The delta is structured JSON; bypass the GCX/TOON // pack encoders, which assume the full pack shape. return mcp.NewToolResultJSON(result) } } else { result["pack_delta"] = map[string]any{ "available": false, "base_root": deltaFrom, "new_root": etag, "reason": "prior pack not in cache (evicted or never seen) — full pack returned", } } } if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeSmartContext(result)) } if s.isTOON(ctx, req) { return returnTOON(result) } return s.respondJSONOrTOON(ctx, req, result) } // buildBlastRadius assembles the always-on blast-radius block for a // smart_context working set: callers grouped by their source file and // the tests that cover the set. Callers come from a one-hop GetCallers // walk; covering tests come from the exact EdgeTests inverse walk // (Engine.GetTesters) — the same edge get_test_targets trusts — not the // path-name heuristic. Every list is sorted so the block is byte-stable // and feeds a deterministic pack root. Returns nil when there is no // graph/engine to walk. func (s *Server) buildBlastRadius(ctx context.Context, workingSet []*graph.Node) map[string]any { eng := s.engineFor(ctx) if eng == nil || s.graph == nil || len(workingSet) == 0 { return nil } // Callers grouped by the file each caller lives in. callersByFile := make(map[string]map[string]bool) for _, sym := range workingSet { if sym == nil { continue } callers := eng.GetCallers(sym.ID, query.QueryOptions{Depth: 1, Limit: ringScanLimit, Detail: "brief"}) for _, cn := range callers.Nodes { if cn.ID == sym.ID || cn.Kind == graph.KindFile { continue } set, ok := callersByFile[cn.FilePath] if !ok { set = make(map[string]bool) callersByFile[cn.FilePath] = set } set[cn.ID] = true } } callerFiles := make([]string, 0, len(callersByFile)) for f := range callersByFile { callerFiles = append(callerFiles, f) } sort.Strings(callerFiles) callerGroups := make([]map[string]any, 0, len(callerFiles)) for _, f := range callerFiles { ids := make([]string, 0, len(callersByFile[f])) for id := range callersByFile[f] { ids = append(ids, id) } sort.Strings(ids) callerGroups = append(callerGroups, map[string]any{ "file": f, "callers": ids, }) } // Covering tests via the exact EdgeTests inverse walk. type testRef struct{ file, function string } testSeen := make(map[string]bool) var tests []testRef anyTesters := false for _, sym := range workingSet { if sym == nil { continue } for _, tn := range eng.GetTesters(sym.ID) { if tn == nil { continue } anyTesters = true key := tn.FilePath + "\x1f" + tn.Name if testSeen[key] { continue } testSeen[key] = true tests = append(tests, testRef{file: tn.FilePath, function: tn.Name}) } } sort.Slice(tests, func(i, j int) bool { if tests[i].file != tests[j].file { return tests[i].file < tests[j].file } return tests[i].function < tests[j].function }) coveringTests := make([]map[string]any, 0, len(tests)) for _, tr := range tests { coveringTests = append(coveringTests, map[string]any{ "file": tr.file, "function": tr.function, }) } br := map[string]any{ "callers_by_file": callerGroups, "covering_tests": coveringTests, } if !anyTesters { br["warning"] = "no covering tests found" } return br } // extractKeywords splits a task description into searchable keywords. // Filters out common stop words and short words, then reorders so // identifier-shape keywords (camelCase, snake_case, anything with an // uppercase letter after a lowercase one) come first. The BM25 budget // downstream then fills with the most-likely-to-be-the-actual-target // candidates before generic English verbs ("find", "caller", …) // flood it with unrelated hits. func extractKeywords(task string) []string { stopWords := map[string]bool{ "a": true, "an": true, "the": true, "is": true, "are": true, "was": true, "were": true, "be": true, "been": true, "being": true, "have": true, "has": true, "had": true, "do": true, "does": true, "did": true, "will": true, "would": true, "could": true, "should": true, "may": true, "might": true, "shall": true, "can": true, "for": true, "and": true, "but": true, "or": true, "nor": true, "not": true, "so": true, "yet": true, "both": true, "to": true, "of": true, "in": true, "on": true, "at": true, "by": true, "with": true, "from": true, "into": true, "that": true, "this": true, "it": true, "its": true, "as": true, "if": true, "add": true, "new": true, "create": true, "make": true, "called": true, "like": true, "use": true, "using": true, "how": true, "what": true, "want": true, "need": true, "all": true, "each": true, "which": true, // Common task verbs / nouns that aren't symbol candidates — // a task like "find every caller of FooBar" used to surface // `findLocked` / `findDeclaration` first because "find" hit // the BM25 budget before the real identifier. Stop them. "find": true, "finds": true, "found": true, "finding": true, "get": true, "gets": true, "got": true, "getting": true, "set": true, "sets": true, "setting": true, "list": true, "lists": true, "show": true, "shows": true, "showing": true, "tell": true, "tells": true, "every": true, "any": true, "some": true, "most": true, "caller": true, "callers": true, "callee": true, "callees": true, "user": true, "users": true, "users'": true, "function": true, "functions": true, "method": true, "methods": true, "file": true, "files": true, "symbol": true, "symbols": true, "code": true, "source": true, } // Split on whitespace and punctuation. words := strings.FieldsFunc(task, func(r rune) bool { if r >= 'a' && r <= 'z' { return false } if r >= 'A' && r <= 'Z' { return false } if r >= '0' && r <= '9' { return false } return r != '_' }) seen := make(map[string]bool) var identifiers, plain []string for _, w := range words { lower := strings.ToLower(w) if len(lower) < 3 || stopWords[lower] || seen[lower] { continue } seen[lower] = true if hasIdentifierShape(w) { identifiers = append(identifiers, w) } else { plain = append(plain, w) } } return append(identifiers, plain...) } // hasIdentifierShape reports whether w looks like a code identifier // the user explicitly typed (camelCase, snake_case, has digits) and // therefore deserves to drive the candidate search before generic // English verbs. func hasIdentifierShape(w string) bool { hasUnderscore := false hasDigit := false hasInternalUpper := false for i, r := range w { switch { case r == '_': hasUnderscore = true case r >= '0' && r <= '9': hasDigit = true case r >= 'A' && r <= 'Z': if i > 0 { hasInternalUpper = true } } } return hasUnderscore || hasDigit || hasInternalUpper } func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } newName, err := req.RequireString("new_name") if err != nil { return mcp.NewToolResultError("new_name is required"), nil } node := s.engineFor(ctx).GetSymbol(id) if node == nil { return mcp.NewToolResultError("symbol not found: " + id), nil } oldName := node.Name if oldName == newName { return mcp.NewToolResultError("new_name is the same as the current name"), nil } // Resolve abs paths per node/edge — single rootPath is wrong in // multi-repo mode where each repo has its own root. resolvePath := func(graphPath string) string { abs, err := s.resolveGraphPath(graphPath) if err != nil { return "" } return abs } type renameEdit struct { File string `json:"file"` Line int `json:"line"` OldText string `json:"old_text"` NewText string `json:"new_text"` Confidence string `json:"confidence"` Reason string `json:"reason"` } var edits []renameEdit editSeen := make(map[string]bool) // file:line dedup // 1. The definition itself. defLine := readSingleLineAt(resolvePath(node.FilePath), node.StartLine) if defLine != "" && strings.Contains(defLine, oldName) { key := fmt.Sprintf("%s:%d", node.FilePath, node.StartLine) if !editSeen[key] { editSeen[key] = true edits = append(edits, renameEdit{ File: node.FilePath, Line: node.StartLine, OldText: defLine, NewText: strings.Replace(defLine, oldName, newName, 1), Confidence: "high", Reason: "definition", }) } } // 2. All graph usages (calls, references, instantiates). usages := s.engineFor(ctx).FindUsages(id) for _, edge := range usages.Edges { if edge.Line == 0 { continue } // Read the source line at the reference. srcLine := readSingleLineAt(resolvePath(edge.FilePath), edge.Line) if srcLine == "" || !strings.Contains(srcLine, oldName) { continue } key := fmt.Sprintf("%s:%d", edge.FilePath, edge.Line) if editSeen[key] { continue } editSeen[key] = true edits = append(edits, renameEdit{ File: edge.FilePath, Line: edge.Line, OldText: srcLine, NewText: strings.Replace(srcLine, oldName, newName, 1), Confidence: "high", Reason: string(edge.Kind), }) } // 3. MemberOf edges — if renaming a type, its methods' receiver annotations may reference it. if node.Kind == graph.KindType || node.Kind == graph.KindInterface { inEdges := s.engineFor(ctx).GetInEdges(id) for _, edge := range inEdges { if edge.Kind != graph.EdgeMemberOf { continue } memberNode := s.engineFor(ctx).GetSymbol(edge.From) if memberNode == nil { continue } // Check if the member's ID contains the old type name (e.g. "file.go::TypeName.MethodName"). if strings.Contains(memberNode.ID, oldName+".") { // The receiver line may mention the type name. srcLine := readSingleLineAt(resolvePath(memberNode.FilePath), memberNode.StartLine) if srcLine != "" && strings.Contains(srcLine, oldName) { key := fmt.Sprintf("%s:%d", memberNode.FilePath, memberNode.StartLine) if !editSeen[key] { editSeen[key] = true edits = append(edits, renameEdit{ File: memberNode.FilePath, Line: memberNode.StartLine, OldText: srcLine, NewText: strings.Replace(srcLine, oldName, newName, 1), Confidence: "high", Reason: "member receiver", }) } } } } } // 4. Test files that reference the old name (text search fallback). for _, edge := range usages.Edges { if !isTestFile(edge.FilePath) { continue } // Already covered by graph edges above, but check for test function names // like "TestValidateToken" that contain the old name. for _, n := range usages.Nodes { if n.FilePath == edge.FilePath && strings.Contains(n.Name, oldName) { srcLine := readSingleLineAt(resolvePath(n.FilePath), n.StartLine) if srcLine == "" { continue } key := fmt.Sprintf("%s:%d", n.FilePath, n.StartLine) if editSeen[key] { continue } editSeen[key] = true edits = append(edits, renameEdit{ File: n.FilePath, Line: n.StartLine, OldText: srcLine, NewText: strings.Replace(srcLine, oldName, newName, 1), Confidence: "medium", Reason: "test function name", }) } } } // Collect affected files. fileSet := make(map[string]bool) for _, e := range edits { fileSet[e.File] = true } return s.respondJSONOrTOON(ctx, req, map[string]any{ "old_name": oldName, "new_name": newName, "edits": edits, "total_edits": len(edits), "files_affected": len(fileSet), }) } func (s *Server) handleEditSymbol(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, err := s.symbolIDArg(ctx, req) if err != nil { return mcp.NewToolResultError("id is required"), nil } oldSource, err := req.RequireString("old_source") if err != nil { return mcp.NewToolResultError("old_source is required"), nil } newSource, err := req.RequireString("new_source") if err != nil { return mcp.NewToolResultError("new_source is required"), nil } baseSHA := normalizeExpectedSHA(req.GetString("base_sha", "")) dryRun := req.GetBool("dry_run", false) if oldSource == newSource { return mcp.NewToolResultError("old_source and new_source are identical"), nil } node := s.engineFor(ctx).GetSymbol(id) if node == nil { return mcp.NewToolResultError("symbol not found: " + id + s.suggestSymbolIDs(ctx, id)), nil } if node.StartLine == 0 || node.EndLine == 0 { return mcp.NewToolResultError("symbol has no line range: " + id), nil } // Resolve file path. absPath, err := s.resolveNodePath(node) if err != nil { return mcp.NewToolResultError(err.Error()), nil } // Read the entire file ONCE — both the drift check and the // patch operate on the same byte snapshot so a concurrent // writer cannot wedge a diff between the SHA we accept and the // content we splice into. content, err := os.ReadFile(absPath) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("could not read file: %v", err)), nil } if baseSHA != "" && gitBlobSHA(content) != baseSHA { return mcp.NewToolResultError(errBaseSHADrift), nil } fileStr := string(content) // Extract the symbol's source lines to verify old_source is within them. lines := strings.Split(fileStr, "\n") if node.StartLine > len(lines) || node.EndLine > len(lines) { return mcp.NewToolResultError("symbol line range exceeds file length"), nil } symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") if findEOLMatches(symbolSource, oldSource).count == 0 { // Expand search to include preceding doc comments (agents often include // them because get_symbol_source returns context_lines above the symbol). expandedStart := node.StartLine - 1 for expandedStart > 0 { trimmed := strings.TrimSpace(lines[expandedStart-1]) if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") || trimmed == "" { expandedStart-- } else { break } } if expandedStart < node.StartLine-1 { symbolSource = strings.Join(lines[expandedStart:node.EndLine], "\n") } } srcMatches := findEOLMatches(symbolSource, oldSource) if srcMatches.count == 0 { return mcp.NewToolResultError(fmt.Sprintf( "old_source not found within symbol %s (lines %d-%d). Use get_symbol_source to see the current code.", id, node.StartLine, node.EndLine)), nil } // Verify old_source is unique within the symbol. if srcMatches.count > 1 { return mcp.NewToolResultError( "old_source appears multiple times within the symbol. Provide a larger fragment to make it unique."), nil } // Apply the edit to the full file content. // Find old_source within the symbol's line range only (not the whole file). // Use the expanded start if doc comments were included. effectiveStart := node.StartLine if findEOLMatches(strings.Join(lines[node.StartLine-1:node.EndLine], "\n"), oldSource).count == 0 { // Recalculate expanded start for offset computation. expandedStart := node.StartLine - 1 for expandedStart > 0 { trimmed := strings.TrimSpace(lines[expandedStart-1]) if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") || trimmed == "" { expandedStart-- } else { break } } effectiveStart = expandedStart + 1 } symbolStart := 0 for i := 0; i < effectiveStart-1 && i < len(lines); i++ { symbolStart += len(lines[i]) + 1 // +1 for newline } symbolEnd := symbolStart + len(symbolSource) if symbolEnd > len(fileStr) { symbolEnd = len(fileStr) } // Find old_source within the symbol region (EOL-tolerant: an // LF-authored fragment still matches a CRLF region). Spans are byte // offsets into the raw region, so the splice below always lands on the // real on-disk bytes. regionMatches := findEOLMatches(fileStr[symbolStart:symbolEnd], oldSource) if len(regionMatches.spans) == 0 { return mcp.NewToolResultError("old_source not found in symbol region"), nil } span := regionMatches.spans[0] // Build the new file content. editStart := symbolStart + span.start editEnd := symbolStart + span.end effectiveNew := newSource if regionMatches.normalized { // Rewrite new_source's terminators to the matched region's own // style so the splice never introduces mixed line endings. effectiveNew = adaptToDominantEOL(newSource, fileStr[editStart:editEnd]) } newContent := fileStr[:editStart] + effectiveNew + fileStr[editEnd:] if newContent == fileStr { return mcp.NewToolResultError( "old_source and new_source are identical after line-ending normalization"), nil } newContentBytes := []byte(newContent) if dryRun { // Validate everything but skip the write; return a unified-diff // preview so the caller can review the change before committing. preview := map[string]any{ "file": node.FilePath, "symbol": id, "lines_before": strings.Count(oldSource, "\n") + 1, "lines_after": strings.Count(newSource, "\n") + 1, "start_line": node.StartLine, "status": "would_apply", "dry_run": true, "diff": unifiedDiff(node.FilePath, fileStr, newContent), "new_sha": gitBlobSHA(newContentBytes), } if regionMatches.normalized { preview["eol_normalized"] = true } return s.respondJSONOrTOON(ctx, req, preview) } // Write the file. if err := os.WriteFile(absPath, newContentBytes, 0o644); err != nil { return mcp.NewToolResultError(fmt.Sprintf("could not write file: %v", err)), nil } sess := s.sessionFor(ctx) sess.recordModified(node.FilePath) sess.recordSymbol(id) reindexed := s.reindexFile(absPath) // Count lines changed. oldLines := strings.Count(oldSource, "\n") + 1 newLines := strings.Count(newSource, "\n") + 1 resp := map[string]any{ "file": node.FilePath, "symbol": id, "lines_before": oldLines, "lines_after": newLines, "start_line": node.StartLine, "status": "applied", "reindexed": reindexed, "new_sha": gitBlobSHA(newContentBytes), } if regionMatches.normalized { resp["eol_normalized"] = true } if health := s.fileSyntaxHealth(node.FilePath, absPath); health != nil { resp["syntax_health"] = health } return s.respondJSONOrTOON(ctx, req, resp) } // readSingleLineAt reads a single line from an absolute filesystem path. // Returns "" on error. Caller is responsible for resolving relative graph // paths to abs first (via Server.resolveGraphPath / resolveNodePath) so a // missing repo root surfaces as an error instead of silently opening the // wrong file relative to the daemon process CWD. func readSingleLineAt(absPath string, lineNum int) string { if absPath == "" { return "" } f, err := os.Open(absPath) if err != nil { return "" } defer f.Close() scanner := bufio.NewScanner(f) current := 0 for scanner.Scan() { current++ if current == lineNum { return scanner.Text() } } return "" } // extractPrefix returns the common prefix of a camelCase/PascalCase name. // e.g. "handleGetSymbol" -> "handle", "TestNewServer" -> "Test" func extractPrefix(name string) string { for i := 1; i < len(name); i++ { if name[i] >= 'A' && name[i] <= 'Z' { return name[:i] } } return name }