package mcp import ( "context" "encoding/json" "fmt" "maps" "math" "os" "path/filepath" "slices" "sort" "strings" "time" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/audit" "github.com/zzet/gortex/internal/blame" "github.com/zzet/gortex/internal/contracts" "github.com/zzet/gortex/internal/coverage" "github.com/zzet/gortex/internal/excludes" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/persistence" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/semantic" "github.com/zzet/gortex/internal/tokens" "go.uber.org/zap" ) // ensureFresh re-indexes any of the given file paths whose on-disk content has // drifted from the indexed snapshot, so a subsequent graph read serves current // data instead of a stale body. Returns the paths that were refreshed (capped at // a handful per call). // // The self-heal is gated on IsTrackedStale, which is false for untracked, new, // or already-current files — only a genuinely changed, already-indexed file is // re-indexed. That gate is what makes this safe in multi-repo mode: an earlier // version keyed the staleness check off the lone single-Indexer, whose mtime // map is empty for cross-repo paths, so every file looked stale and the // resulting mass re-index raced the live read surface and crashed the transport. // Routing each path to its owning per-repo indexer keeps the check accurate and // the work bounded to the file the caller is about to read. func (s *Server) ensureFresh(filePaths []string) []string { var refreshed []string const limit = 5 for _, fp := range filePaths { if len(refreshed) >= limit { break } idx, absPath := s.freshnessIndexer(fp) if idx == nil { continue } root := idx.RootPath() if root == "" { continue } rel, err := filepath.Rel(root, absPath) if err != nil || strings.HasPrefix(rel, "..") { continue } // IsTrackedStale is false for untracked / new / current files, so // only a known-and-changed file triggers a re-index — no mass churn. if !idx.IsTrackedStale(rel) { continue } if err := idx.IndexFile(absPath); err != nil { s.logger.Warn("auto re-index failed", zap.String("file", fp), zap.String("resolved", absPath), zap.Error(err)) continue } // Advance the recorded mtime so a follow-up read in the same window // sees the file as current and skips a redundant re-index. idx.RefreshFileMtime(absPath) refreshed = append(refreshed, fp) } return refreshed } // freshnessIndexer resolves a repo-prefixed, repo-relative, or absolute path to // the indexer that owns it and the file's absolute path, for both single- and // multi-repo daemons. In single-repo mode an active file watcher already owns // freshness, so on-read auto-refresh stands down rather than fight it; in // multi-repo mode each path is routed to its per-repo indexer, whose mtime map // is populated — which is what makes the staleness check trustworthy. func (s *Server) freshnessIndexer(fp string) (*indexer.Indexer, string) { if s.multiIndexer != nil { abs := fp if !filepath.IsAbs(abs) { if resolved := s.multiIndexer.ResolveFilePath(fp); resolved != "" { abs = resolved } } idx, _ := s.multiIndexer.IndexerForFile(abs) return idx, abs } if s.indexer == nil || s.watcher != nil { return nil, "" } abs := fp if !filepath.IsAbs(abs) { if root := s.indexer.RootPath(); root != "" { abs = filepath.Join(root, fp) } } return s.indexer, abs } func (s *Server) registerEnhancementTools() { // verify_change s.addTool( mcp.NewTool("verify_change", mcp.WithDescription("Given proposed signature changes, checks all callers and interface implementors for contract violations. Use before refactoring to catch breaking changes."), mcp.WithString("changes", mcp.Required(), mcp.Description("JSON array of {symbol_id, new_signature} objects")), mcp.WithBoolean("compact", mcp.Description("One-line-per-violation text output")), ), s.handleVerifyChange, ) // check_guards s.addTool( mcp.NewTool("check_guards", mcp.WithDescription("Evaluates project-specific guard rules against a set of changed symbols. Reports co-change and boundary violations."), mcp.WithString("ids", mcp.Required(), mcp.Description("Comma-separated list of changed symbol IDs")), mcp.WithBoolean("compact", mcp.Description("One-line-per-rule text output")), 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.handleCheckGuards, ) // prefetch_context s.addTool( mcp.NewTool("prefetch_context", mcp.WithDescription("Predicts what context you will need next based on recent activity and a task description. Returns ranked symbols with relevance reasons."), mcp.WithString("task", mcp.Description("Natural language task description")), mcp.WithString("recent_symbols", mcp.Description("Comma-separated list of recently viewed symbol IDs")), mcp.WithBoolean("include_source", mcp.Description("Include source code for top 5 candidates")), mcp.WithNumber("limit", mcp.Description("Max candidates to return (default: 10)")), mcp.WithString("cursor", mcp.Description("Opaque pagination cursor from a previous `next_cursor` to fetch the next page.")), mcp.WithBoolean("paginate", mcp.Description("When true, the server caps each page at the project default budget and returns `next_cursor` for any tail.")), mcp.WithString("fields", mcp.Description("Comma-separated list of fields to keep on each candidate (e.g. \"id,confidence,reason\").")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output")), 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)), ), s.handlePrefetchContext, ) // analyze — unified graph analysis tool (dead_code, hotspots, cycles, would_create_cycle) s.addTool( mcp.NewTool("analyze", mcp.WithDescription(analyzeGroupedSummary), mcp.WithString("kind", mcp.Required(), mcp.Description(fmt.Sprintf("Analysis kind, one of: %s. Pass \"help\" for the full one-line-per-kind reference.", analyzeKindsCSV()))), mcp.WithString("framework", mcp.Description("(dbt_models) Filter to one transformation framework — dbt or sqlmesh")), mcp.WithString("materialized", mcp.Description("(dbt_models) Substring match on the model materialization — table, view, incremental, …")), mcp.WithBoolean("compact", mcp.Description("One-line-per-result text output")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format, per-kind hand-tuned encoder), 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("include_variables", mcp.Description("(dead_code) Include variable nodes (default false — usually false positives without data-flow analysis)")), mcp.WithBoolean("include_fields", mcp.Description("(dead_code) Include struct/class field nodes (default false — graph can't always pick a candidate for intra-function field reads, so fields look dead even when used)")), mcp.WithBoolean("include_constants", mcp.Description("(dead_code) Include constant nodes (default false — same caveat as variables)")), mcp.WithBoolean("include_cgo_exports", mcp.Description("(dead_code) Include functions annotated //export — default false; CGo exports have no Go-level callers")), mcp.WithBoolean("include_linkname_targets", mcp.Description("(dead_code) Include //go:linkname targets — default false; they are linked by name from outside the package")), mcp.WithBoolean("skip_cross_repo_nodes", mcp.Description("(dead_code) Drop nodes whose RepoPrefix is set — useful when cross-repo linking is incomplete")), mcp.WithNumber("threshold", mcp.Description("(hotspots) Complexity score threshold (default: mean + 2σ)")), mcp.WithString("repo", mcp.Description("Narrow this analysis to a single repository prefix, clamped to the session workspace. Applies to graph-node, edge-walk, graph-algorithm, framework, and file/AST-scan kinds (dead_code, hotspots, cycles, health_score, todos, stale_code, ownership, coverage_gaps, impact, bottlenecks, k8s_resources, dbt_models, external_calls, channel_ops, pubsub, routes, models, pagerank, sast, …). Community / git-mining / per-id / synthesizer kinds are workspace-bound but not repo-narrowed in v1. NOTE: for kind=cross_repo this names the repo whose cross-repo boundary dependencies to report (its existing meaning), not a result narrow.")), mcp.WithString("project", mcp.Description("Narrow this analysis to the repositories in a project, clamped to the session workspace. Applies to graph-node kinds (see `repo`).")), mcp.WithString("workspace", mcp.Description("Restrict the analysis to the active workspace slug; daemon sessions may only name their own workspace.")), mcp.WithString("scope", mcp.Description("Name of a saved scope (see save_scope) — its repositories narrow graph-node analyses, clamped to the session workspace. NOTE: for kind=cycles this is instead a file-path / package prefix that limits the cycle search (its existing meaning), not a saved-scope name. Community / git-mining / per-id / synthesizer kinds (clusters, concepts, suggest_boundaries, blame, coverage, fixes_history, retrieval_log, temporal_verify, would_create_cycle, def_use, synthesizers, resolution_outcomes, sql_rebuild) are workspace-bound but not repo-narrowed in v1.")), mcp.WithString("from_id", mcp.Description("(would_create_cycle) Source symbol ID")), mcp.WithString("to_id", mcp.Description("(would_create_cycle) Target symbol ID")), mcp.WithString("profile", mcp.Description("(coverage) Path to a Go cover.out profile, absolute or relative to the indexed repo root")), mcp.WithNumber("older_than", mcp.Description("(stale_code) Symbols last touched more than this many days ago — default 365")), mcp.WithString("email", mcp.Description("(stale_code) Filter to a single author email")), mcp.WithString("kinds", mcp.Description("(stale_code, ownership) Comma-separated kinds — default function,method; pass 'all' for every blame-eligible kind")), mcp.WithNumber("min_symbols", mcp.Description("(ownership) Drop authors with fewer than this many symbols — default 1")), mcp.WithString("path_prefix", mcp.Description("(ownership, coverage_gaps) Scope to nodes under this file-path prefix — e.g. 'internal/auth/'")), mcp.WithNumber("min_pct", mcp.Description("(coverage_gaps) Lower-inclusive coverage threshold — default 0")), mcp.WithNumber("max_pct", mcp.Description("(coverage_gaps) Upper-exclusive coverage threshold — default 100, i.e. anything not fully covered")), mcp.WithString("provider", mcp.Description("(stale_flags) Filter to a single provider — launchdarkly, growthbook, unleash, internal")), mcp.WithString("tag", mcp.Description("(todos) Filter by tag — TODO / FIXME / HACK / XXX / NOTE — case-insensitive. (releases) Filter to one release tag — returns the file list whose meta.added_in matches; populate via enrich_releases first.")), mcp.WithString("assignee", mcp.Description("(todos) Filter by exact assignee — case-sensitive")), mcp.WithString("ticket", mcp.Description("(todos) Filter by exact ticket reference — e.g. PROJ-42")), mcp.WithBoolean("has_assignee", mcp.Description("(todos) Keep only TODOs that have an assignee set")), mcp.WithString("base_kind", mcp.Description("(cross_repo) Scope to one base relation — calls, implements, or extends")), mcp.WithNumber("limit", mcp.Description("(cross_repo, error_surface, unsafe_patterns) Cap the number of rows returned — default 200")), mcp.WithString("language", mcp.Description("(unsafe_patterns, sast, hygiene) Comma-separated subset of languages to keep — rust, python, javascript, typescript, go, java, ruby, php")), mcp.WithString("detector", mcp.Description("(unsafe_patterns, sast, hygiene) Comma-separated subset of bundled detector names. The full catalog is available via the search_ast tool with no args; for SAST the names follow `py-*` / `go-*` / `js-*` / `java-*` / `ruby-*` / `php-*` / `rust-*` / `hygiene-*` conventions.")), mcp.WithString("severity", mcp.Description("(unsafe_patterns, sast, hygiene) Comma-separated subset of severity labels to keep — error, warning, info")), mcp.WithBoolean("exclude_tests", mcp.Description("(unsafe_patterns, sast, hygiene) Override the per-detector default (defaults to true — test-only matches are dropped)")), mcp.WithString("cwe", mcp.Description("(sast) Comma-separated subset of MITRE CWE identifiers to keep — e.g. 'CWE-78,CWE-89'")), mcp.WithBoolean("kinds_only", mcp.Description("(sast) Return only the per-detector + per-CWE breakdown; omit per-site `matches` rows. Use for a SAST surface snapshot without paying row bytes.")), mcp.WithString("grade", mcp.Description("(health_score) Comma-separated A..F subset to keep — e.g. 'd,f' for the worst-scoring symbols only")), mcp.WithNumber("min_score", mcp.Description("(health_score) Drop rows whose composite score is below this (0..100)")), mcp.WithNumber("max_score", mcp.Description("(health_score) Drop rows whose composite score is above this (0..100)")), mcp.WithNumber("min_axes", mcp.Description("(health_score) Require at least this many populated axes per row (default 1; raise to demand multi-signal confidence)")), mcp.WithString("roll_up", mcp.Description("(health_score) Aggregate per-symbol scores up to a coarser scope — 'file' (per-file average + per-grade counts) or 'repo' (per-repo). Omit for per-symbol rows.")), mcp.WithString("ids", mcp.Description("(impact) Comma-separated symbol IDs — score only these, the blast radius of changing specific symbols.")), mcp.WithString("name", mcp.Description("(named) The query bundle to run. Omit to list every available bundle.")), mcp.WithString("group_by", mcp.Description("(tests_as_edges) symbol (default — tested symbol → its tests) or test (test → symbols it exercises).")), mcp.WithString("algorithm", mcp.Description("(clusters) Community-detection algorithm — leiden (default), louvain, or spectral (recursive Fiedler-vector bisection).")), mcp.WithNumber("min_size", mcp.Description("(clusters) Drop clusters with fewer than this many members — default 3.")), mcp.WithNumber("resolution", mcp.Description("(clusters, leiden) Modularity resolution γ — default 1.0. Higher γ (e.g. 2.0) yields more, smaller communities; lower γ (e.g. 0.5) yields fewer, larger ones. γ = 1.0 is standard modularity and uses the cached incremental partition.")), ), s.handleAnalyze, ) // winnow_symbols — multi-axis constraint-chain retrieval s.addTool( mcp.NewTool("winnow_symbols", mcp.WithDescription("Structured constraint-chain retrieval. Combines BM25 text matching with structural filters (kind, language, fan-in/out, community, path prefix, churn, test classification) and returns a ranked list with per-axis score contributions. Use when search_symbols' free-text-only query is too coarse — e.g. 'methods in the auth community with fan-in >= 5 touching handlers/' or 'production functions only, no tests'."), mcp.WithString("kind", mcp.Description("Comma-separated node kinds to keep (function, method, type, interface, variable, contract)")), mcp.WithString("language", mcp.Description("Filter to a single language (go, typescript, python, ...)")), mcp.WithString("path_prefix", mcp.Description("Comma-separated file path prefixes — any match passes")), mcp.WithString("community", mcp.Description("Community ID (community-0) or label to scope to a functional cluster")), mcp.WithString("text_match", mcp.Description("BM25 text query; when absent ranking is purely structural")), mcp.WithNumber("min_fan_in", mcp.Description("Minimum incoming calls+references (default: 0)")), mcp.WithNumber("min_fan_out", mcp.Description("Minimum outgoing calls (default: 0)")), mcp.WithNumber("min_churn", mcp.Description("Minimum session modification count (default: 0)")), mcp.WithBoolean("is_test", mcp.Description("Tri-state test filter: true keeps only test symbols, false keeps only production symbols. Omit for no constraint.")), mcp.WithString("test_role", mcp.Description("Comma-separated test roles to keep: test, benchmark, fuzz, example")), mcp.WithNumber("limit", mcp.Description("Max results (default: 20)")), mcp.WithString("cursor", mcp.Description("Opaque pagination cursor from a previous `next_cursor` to fetch the next page.")), mcp.WithBoolean("paginate", mcp.Description("When true, the server caps each page at the project default budget and returns `next_cursor` for any tail.")), 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.")), mcp.WithString("fields", mcp.Description("Comma-separated list of fields to keep on each result (e.g. \"id,score,fan_in\").")), mcp.WithBoolean("compact", mcp.Description("One-line-per-result text output")), 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("ref", mcp.Description("Filter results to repositories with a specific reference tag")), ), s.handleWinnowSymbols, ) // scaffold s.addTool( mcp.NewTool("scaffold", mcp.WithDescription("Generates code scaffolding from an existing symbol pattern, including registration wiring and test stubs."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to use as the pattern example")), mcp.WithString("new_name", mcp.Required(), mcp.Description("Name for the new symbol")), mcp.WithBoolean("dry_run", mcp.Description("Return scaffold without writing files (default: true)")), mcp.WithBoolean("compact", mcp.Description("Compact text output")), ), s.handleScaffold, ) // diff_context s.addTool( mcp.NewTool("diff_context", mcp.WithDescription("Returns graph-enriched context for symbols affected by a git diff: source, callers, callees, community, processes, and per-file risk."), mcp.WithString("scope", mcp.Description("unstaged (default), staged, all, or compare")), mcp.WithString("base_ref", mcp.Description("Branch/commit for compare scope (default: main)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol condensed output")), mcp.WithString("repo", mcp.Description("Repository prefix or path (multi-repo mode); defaults to the lone tracked repo or the session's cwd-bound repo")), ), s.handleDiffContext, ) // index_health s.addTool( mcp.NewTool("index_health", mcp.WithDescription("Reports the health and completeness of the Gortex index: parse failures, stale files, language coverage, and health score."), mcp.WithBoolean("compact", mcp.Description("Single-line summary output")), 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; truncation metadata rides on the response.")), ), s.handleIndexHealth, ) // get_symbol_history s.addTool( mcp.NewTool("get_symbol_history", mcp.WithDescription("Returns symbols modified during the current session with modification counts. Flags churning symbols (modified 3+ times)."), mcp.WithString("id", mcp.Description("Specific symbol ID (omit for all)")), mcp.WithBoolean("compact", mcp.Description("One-line-per-symbol text output")), ), s.handleGetSymbolHistory, ) // batch_edit s.addTool( mcp.NewTool("batch_edit", mcp.WithDescription("Applies multiple edits in one atomic, dependency-ordered batch. Symbol edits are ordered definitions→callers; the graph re-indexes after each edit; the batch stops on the first failure and skips the rest. Each edit is one of two operations selected by `op`:\n • edit_symbol (default): {id, old_source, new_source} — replace a fragment inside a symbol's body.\n • edit_file: {op:\"edit_file\", path, old_string, new_string, replace_all?} — replace a string in any file (imports, config, comments).\nPass `edits` as a JSON array of objects (a JSON-encoded string of the same array is also accepted for backward compatibility)."), mcp.WithArray("edits", mcp.Required(), mcp.Description("Edit operations. Each item is an edit_symbol or edit_file object selected by the `op` discriminator."), mcp.Items(batchEditItemsSchema()), ), mcp.WithBoolean("dry_run", mcp.Description("Return the dependency-ordered plan without applying changes")), mcp.WithBoolean("compact", mcp.Description("One-line-per-edit summary")), ), s.handleBatchEdit, ) // contracts — unified contracts tool (list + check + validate) s.addTool( mcp.NewTool("contracts", mcp.WithDescription("API contracts tool. action=list (default): lists detected contracts (HTTP, gRPC, Thrift, GraphQL, topics, WebSocket, env, OpenAPI). action=check: detects orphan providers/consumers across repos. action=validate: diffs provider↔consumer request/response shapes and flags breaking/warning/info issues. action=bridge: queries the persisted contract-bridge subgraph — one node per matched provider↔consumer group (HTTP route, gRPC/Thrift method, pub/sub topic) — ranked by reciprocal rank fusion over text, path/repo, graph-adjacency, and consumer-degree signals (mode=rank, pass query and/or symbol), or expanded from a symbol into its cross-service blast radius (mode=impact, pass symbol).\n\nDEFAULT SCOPE for list: auto-scopes to the active project's repos and hides dependency-origin contracts (type=dependency, vendored paths like vendor/, node_modules/). The response reports other_repos (count of contracts filtered out of scope) and dependencies_skipped (count of dep contracts hidden). To widen scope, pass repo=, project=, ref=, or all_repos=true. To include dependency contracts, pass include_deps=true."), mcp.WithString("action", mcp.Description("list (default), check, validate, or bridge")), mcp.WithString("repo", mcp.Description("Filter by repository prefix")), mcp.WithString("project", mcp.Description("Filter to repositories in a specific project (resolves to the project's repo set)")), mcp.WithString("ref", mcp.Description("Filter to repositories tagged with this ref")), mcp.WithBoolean("all_repos", mcp.Description("(list) Disable active-project auto-scope; return contracts from every indexed repo. Default false.")), mcp.WithBoolean("include_deps", mcp.Description("(list) Include type=dependency contracts and contracts from vendored paths (vendor/, node_modules/, Pods/, .venv/). Default false.")), mcp.WithString("type", mcp.Description("(list) Filter by type: http, grpc, thrift, graphql, topic, ws, env, openapi, dependency")), mcp.WithString("query", mcp.Description("(bridge) Free-text query ranked against bridge canonical keys, contract names, repos, and file paths")), mcp.WithString("symbol", mcp.Description("(bridge) Symbol ID anchoring the graph-adjacency signal (mode=rank) or the blast-radius expansion (mode=impact)")), mcp.WithString("mode", mcp.Description("(bridge) rank (default) or impact")), mcp.WithString("role", mcp.Description("(list) Filter by role: provider or consumer")), mcp.WithNumber("limit", mcp.Description("(list) Max contracts per page (default: 200)")), mcp.WithString("cursor", mcp.Description("(list) Opaque pagination cursor from a previous `next_cursor` to fetch the next page.")), mcp.WithBoolean("paginate", mcp.Description("(list) When true, caps each page at the project default budget and returns `next_cursor` for any tail.")), mcp.WithNumber("max_bytes", mcp.Description("(list) Cap the marshaled response at this many bytes; the longest list is trimmed with truncation metadata.")), mcp.WithString("fields", mcp.Description("(list) Comma-separated list of fields to keep on each contract (e.g. \"type,role,id\").")), mcp.WithBoolean("compact", mcp.Description("One-line-per-contract text output")), 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.handleContracts, ) // api_impact — fused pre-change report for an API route handler. s.addTool( mcp.NewTool("api_impact", mcp.WithDescription("Fused pre-change impact report for an API route — call this BEFORE modifying any route handler. Given a route path substring (or handler file substring), returns ONE report composing: the route's response shape, every consumer (same-repo and cross-repo via contract pairing) with the fields it accesses, field-level response-shape mismatches (real type-aware diffing, not regex), best-effort middleware, the execution flows (processes) it triggers, the true blast radius (affected callers + test files to run), and a fused risk level. Beats hand-assembling routes + contracts validate + impact: one call, one answer to \"what breaks if I change this endpoint?\"."), mcp.WithString("route", mcp.Description("Route path substring to match (e.g. /v1/users). At least one of route|file is required.")), mcp.WithString("file", mcp.Description("Handler file path substring to match — an alternative to route.")), mcp.WithString("repo", mcp.Description("Filter by repository prefix")), mcp.WithString("project", mcp.Description("Filter to repositories in a specific project")), mcp.WithString("ref", mcp.Description("Filter to repositories tagged with this ref")), 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.handleAPIImpact, ) // feedback — unified feedback tool (record + query) s.addTool( mcp.NewTool("feedback", mcp.WithDescription("Agent learning feedback. action=record: report which symbols from smart_context/prefetch_context were useful, not_needed, or missing (improves future context). action=query: aggregated stats — most useful, most missed, accuracy."), mcp.WithString("action", mcp.Required(), mcp.Description("record or query")), mcp.WithString("task", mcp.Description("(record) The task description used in the original context call")), mcp.WithString("useful", mcp.Description("(record) Comma-separated symbol IDs that were useful")), mcp.WithString("not_needed", mcp.Description("(record) Comma-separated symbol IDs that were returned but not needed")), mcp.WithString("missing", mcp.Description("(record) Comma-separated symbol IDs that should have been included")), mcp.WithString("tool_source", mcp.Description("Which tool produced the context: smart_context or prefetch_context (default: smart_context). For query: filter by source or 'all'")), mcp.WithNumber("top_n", mcp.Description("(query) Number of top symbols to return per category (default: 10)")), mcp.WithBoolean("compact", mcp.Description("(query) One-line-per-symbol text output")), mcp.WithString("format", mcp.Description("(query) Output format: json (default), gcx (GCX1 compact wire format), or toon")), ), s.handleFeedback, ) // export_context s.addTool( mcp.NewTool("export_context", mcp.WithDescription("Generates a portable context briefing for a task as self-contained markdown or JSON. Use for sharing context outside MCP — paste into Slack, PRs, docs, or non-MCP AI tools."), mcp.WithString("task", mcp.Required(), mcp.Description("Natural language task description")), 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 (default: 5)")), mcp.WithString("format", mcp.Description("Output format: markdown (default) or json")), mcp.WithNumber("token_budget", mcp.Description("Approximate token budget for output (default: 2000, max: 8000)")), ), s.handleExportContext, ) // audit_agent_config s.addTool( mcp.NewTool("audit_agent_config", mcp.WithDescription("Scans agent config files (CLAUDE.md, AGENTS.md, .cursor/rules, .github/copilot-instructions.md, etc.) for stale symbol references, dead file paths, and bloat — validated against the Gortex graph."), mcp.WithString("files", mcp.Description("Optional comma-separated file paths to audit (relative to repo root). If omitted, auto-discovers known agent config files.")), mcp.WithString("root", mcp.Description("Optional repo root override. Defaults to the indexer's root.")), mcp.WithBoolean("compact", mcp.Description("One-line-per-finding text output")), ), s.handleAuditAgentConfig, ) } // --------------------------------------------------------------------------- // 10.2 handleVerifyChange // --------------------------------------------------------------------------- func (s *Server) handleVerifyChange(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { changesStr, err := req.RequireString("changes") if err != nil { return mcp.NewToolResultError("changes is required"), nil } var changes []analysis.SignatureChange if err := json.Unmarshal([]byte(changesStr), &changes); err != nil { return mcp.NewToolResultError("invalid changes JSON: " + err.Error()), nil } if len(changes) == 0 { return mcp.NewToolResultError("changes array is empty"), nil } result := analysis.VerifyChanges(s.graph, s.engine, changes) if isCompact(req) { var b strings.Builder for _, v := range result.Violations { fmt.Fprintf(&b, "%s %s %s:%d %s\n", v.Kind, v.SymbolID, v.FilePath, v.Line, v.Description) } // One line per changed function: how its call sites consume the // return value, so a return-signature change shows exactly which // sites bind / return / branch on the result. for _, ru := range result.ReturnUsage { fmt.Fprintf(&b, "return_usage %s call_sites:%d", ru.SymbolID, ru.CallSites) labels := make([]string, 0, len(ru.Counts)) for label := range ru.Counts { labels = append(labels, label) } sort.Strings(labels) for _, label := range labels { fmt.Fprintf(&b, " %s:%d", label, ru.Counts[label]) } if ru.Unclassified > 0 { fmt.Fprintf(&b, " unclassified:%d", ru.Unclassified) } b.WriteString("\n") } if result.Clean { fmt.Fprintf(&b, "clean: checked %d callers, %d implementors\n", result.CheckedCallers, result.CheckedImpls) } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, result) } // --------------------------------------------------------------------------- // 10.3 handleCheckGuards // --------------------------------------------------------------------------- func (s *Server) handleCheckGuards(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]) } if len(s.guardRules) == 0 && s.architecture.IsEmpty() { if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeCheckGuards(nil, true)) } empty := map[string]any{ "violations": []any{}, "message": "no guard rules configured", } if s.isTOON(ctx, req) { return returnTOON(empty) } return s.respondJSONOrTOON(ctx, req, empty) } violations := analysis.EvaluateGuards(s.graph, s.guardRules, ids) violations = append(violations, analysis.EvaluateArchitecture(s.graph, s.architecture, ids)...) if isCompact(req) { var b strings.Builder for _, v := range violations { fmt.Fprintf(&b, "%s %s %s\n", v.Kind, v.RuleName, v.Description) } if len(violations) == 0 { b.WriteString("no guard rule violations\n") } return mcp.NewToolResultText(b.String()), nil } if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeCheckGuards(violations, false)) } result := map[string]any{ "violations": violations, "total": len(violations), } if s.isTOON(ctx, req) { return returnTOON(result) } return s.respondJSONOrTOON(ctx, req, result) } // --------------------------------------------------------------------------- // 10.4 handlePrefetchContext // --------------------------------------------------------------------------- // prefetchCandidate holds a scored symbol for prefetch ranking. type prefetchCandidate struct { Node *graph.Node `json:"-"` ID string `json:"id"` Kind string `json:"kind"` FilePath string `json:"file_path"` StartLine int `json:"start_line"` Reason string `json:"reason"` Confidence float64 `json:"confidence"` SearchRelevance float64 `json:"-"` GraphProximity float64 `json:"-"` CommunityBonus float64 `json:"-"` Source string `json:"source,omitempty"` } func (s *Server) handlePrefetchContext(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { task := req.GetString("task", "") recentStr := req.GetString("recent_symbols", "") includeSource := false if v, ok := req.GetArguments()["include_source"].(bool); ok { includeSource = v } // Gather recent symbols from parameter or session state. var recentIDs []string if recentStr != "" { for id := range strings.SplitSeq(recentStr, ",") { recentIDs = append(recentIDs, strings.TrimSpace(id)) } } if len(recentIDs) == 0 { sess := s.sessionFor(ctx) sess.mu.Lock() recentIDs = append(recentIDs, sess.viewedSymbols...) sess.mu.Unlock() } if task == "" && len(recentIDs) == 0 { return mcp.NewToolResultError("insufficient context for prefetch: provide a task description or recent_symbols"), nil } // Score map: symbolID → scores type scores struct { search float64 proximity float64 community float64 feedback float64 reason string node *graph.Node } scoreMap := make(map[string]*scores) getOrCreate := func(n *graph.Node) *scores { if sc, ok := scoreMap[n.ID]; ok { return sc } sc := &scores{node: n} scoreMap[n.ID] = sc return sc } // 1. BM25 search on task description (weight 0.4) if task != "" { searchResults := s.scopedNodeSlice(ctx, s.engineFor(ctx).SearchSymbols(task, 30)) maxScore := 1.0 for i, n := range searchResults { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } sc := getOrCreate(n) // Decay score by rank position relevance := 1.0 / float64(i+1) if relevance > maxScore { maxScore = relevance } sc.search = relevance if sc.reason == "" { sc.reason = "matches task keyword" } } // Normalize search scores if maxScore > 0 { for _, sc := range scoreMap { sc.search = sc.search / maxScore } } } // 2. Graph proximity from recent symbols (weight 0.4) communities := s.getCommunities() recentCommSet := make(map[string]bool) for _, rid := range recentIDs { if communities != nil { if cid, ok := communities.NodeToComm[rid]; ok { recentCommSet[cid] = true } } // Get neighbors at depth 1-2 sg := s.engineFor(ctx).GetDependencies(rid, query.QueryOptions{Depth: 2, Limit: 30, Detail: "brief"}) for _, n := range sg.Nodes { if n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } sc := getOrCreate(n) // Closer = higher score proximity := 0.5 // depth 2 // Check if depth 1 for _, e := range sg.Edges { if (e.From == rid && e.To == n.ID) || (e.To == rid && e.From == n.ID) { proximity = 1.0 break } } if proximity > sc.proximity { sc.proximity = proximity sc.reason = fmt.Sprintf("graph neighbor of %s", rid) } } // Also check dependents (callers) callers := s.engineFor(ctx).GetCallers(rid, query.QueryOptions{Depth: 1, Limit: 20, Detail: "brief"}) for _, n := range callers.Nodes { if n.ID == rid || n.Kind == graph.KindFile || n.Kind == graph.KindImport { continue } sc := getOrCreate(n) if 1.0 > sc.proximity { sc.proximity = 1.0 sc.reason = fmt.Sprintf("caller of %s", rid) } } } // 3. Community bonus (weight 0.2) if communities != nil && len(recentCommSet) > 0 { for _, sc := range scoreMap { if cid, ok := communities.NodeToComm[sc.node.ID]; ok { if recentCommSet[cid] { sc.community = 1.0 if sc.reason == "" { sc.reason = "same community as recent activity" } } } } } // 4. Feedback signal (weight 0.15 when data exists, else use original 3-signal weights). hasFeedback := s.feedback != nil && s.feedback.HasData() if hasFeedback { for _, sc := range scoreMap { fbScore := s.feedback.GetSymbolScore(sc.node.ID) // Normalize from [-1, 1] to [0, 1]. sc.feedback = (fbScore + 1.0) / 2.0 } } // Compute combined scores and build candidates var candidates []prefetchCandidate for id, sc := range scoreMap { // Exclude recently viewed symbols themselves if slices.Contains(recentIDs, id) { continue } var combined float64 if hasFeedback { combined = 0.35*sc.search + 0.35*sc.proximity + 0.15*sc.community + 0.15*sc.feedback } else { combined = 0.4*sc.search + 0.4*sc.proximity + 0.2*sc.community } if combined <= 0 { continue } // Clamp confidence to [0, 1] confidence := math.Min(combined, 1.0) candidates = append(candidates, prefetchCandidate{ Node: sc.node, ID: id, Kind: string(sc.node.Kind), FilePath: sc.node.FilePath, StartLine: sc.node.StartLine, Reason: sc.reason, Confidence: math.Round(confidence*1000) / 1000, SearchRelevance: sc.search, GraphProximity: sc.proximity, CommunityBonus: sc.community, }) } // Sort by confidence descending sort.Slice(candidates, func(i, j int) bool { return candidates[i].Confidence > candidates[j].Confidence }) // Default page size 10, capped at totalCount. The cursor opens the // rare "I want more than the top 10" path without making it the // default — agents that don't paginate get the same first page // they always got. totalCount := len(candidates) limit := req.GetInt("limit", 10) if limit <= 0 { limit = 10 } offset := min(decodeCursor(req.GetString("cursor", "")), totalCount) endIdx := min(offset+limit, totalCount) candidates = candidates[offset:endIdx] truncated := endIdx < totalCount nextCursor := "" if truncated { nextCursor = encodeCursor(endIdx) } // Include source for top 5 if requested if includeSource { for i := range candidates { if i >= 5 { break } n := candidates[i].Node if n.StartLine > 0 && n.EndLine > 0 { if absPath, err := s.resolveNodePath(n); err == nil { if source, _, _, err := readLines(absPath, n.StartLine, n.EndLine, 0); err == nil { candidates[i].Source = source } } } } } if isCompact(req) { var b strings.Builder for _, c := range candidates { fmt.Fprintf(&b, "%s %s %s:%d %.3f %s\n", c.Kind, c.ID, c.FilePath, c.StartLine, c.Confidence, c.Reason) } if truncated { fmt.Fprintf(&b, "... truncated (%d total)\n", totalCount) } return mcp.NewToolResultText(b.String()), nil } if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodePrefetchContext(candidates, totalCount, truncated, includeSource)) } result := map[string]any{ "candidates": candidates, "total": totalCount, "truncated": truncated, } if nextCursor != "" { result["next_cursor"] = nextCursor } if s.isTOON(ctx, req) { return returnTOON(result) } return s.respondJSONOrTOON(ctx, req, result) } // --------------------------------------------------------------------------- // handleAnalyze — unified dispatcher for graph analysis (replaces 4 tools) // --------------------------------------------------------------------------- func (s *Server) handleAnalyze(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { kind, err := req.RequireString("kind") if err != nil { return mcp.NewToolResultError("kind is required (one of: " + analyzeKindsCSV() + ")"), nil } // kind:"help" dumps the full per-kind reference (moved out of the tool // description to keep the cold schema lean). No graph needed. if strings.EqualFold(kind, "help") { return mcp.NewToolResultText(analyzeHelpResult()), nil } // Uniform repo/project/workspace/scope narrowing, clamped to the // session workspace. A few kinds own one of the uniform arg names as // a kind-specific filter, so strip those from the scope-resolution // view (the handlers still read their own arg off the untouched req): // - cycles owns `scope` as a path/package prefix — left in, it would // be looked up as a saved scope and hard-error. // - cross_repo owns `repo` as a boundary filter it reads directly. // - images owns `ref` as the image reference (e.g. "ghcr.io/acme") — // left in, resolveScope mis-reads it as a git ref / scope dimension // and errors ("configuration manager is not available") or narrows // to nothing. resolveReq := req switch kind { case "cycles": resolveReq = requestWithoutArgs(req, "scope") case "cross_repo": resolveReq = requestWithoutArgs(req, "repo") case "images": resolveReq = requestWithoutArgs(req, "ref") } resolved, errResult := s.resolveScope(ctx, resolveReq, IntentAnalyze) if errResult != nil { return errResult, nil } ctx = withRepoAllow(ctx, resolved.RepoAllow) // The dispatch switch is wrapped in a closure so every analyze // response is uniformly decorated with scope_applied below. It stays // inline in handleAnalyze on purpose — the AST anti-drift test // (TestAnalyzeKinds_MatchesSwitch) requires the kind switch to live // in this method's body. res, err := func() (*mcp.CallToolResult, error) { switch kind { case "dead_code": return s.handleFindDeadCode(ctx, req) case "hotspots": return s.handleFindHotspots(ctx, req) case "cycles": return s.handleFindCycles(ctx, req) case "would_create_cycle": return s.handleWouldCreateCycle(ctx, req) case "todos": return s.handleAnalyzeTodos(ctx, req) case "blame": return s.handleAnalyzeBlame(ctx, req) case "coverage": return s.handleAnalyzeCoverage(ctx, req) case "stale_code": return s.handleAnalyzeStaleCode(ctx, req) case "ownership": return s.handleAnalyzeOwnership(ctx, req) case "coverage_gaps": return s.handleAnalyzeCoverageGaps(ctx, req) case "stale_flags": return s.handleAnalyzeStaleFlags(ctx, req) case "doc_staleness": return s.handleAnalyzeDocStaleness(ctx, req) case "releases": return s.handleAnalyzeReleases(ctx, req) case "cgo_users": return s.handleAnalyzeInteropUsers(ctx, req, "uses_cgo", "cgo_users") case "wasm_users": return s.handleAnalyzeInteropUsers(ctx, req, "uses_wasm_bindgen", "wasm_users") case "orphan_tables": return s.handleAnalyzeOrphanTables(ctx, req) case "unreferenced_tables": return s.handleAnalyzeUnreferencedTables(ctx, req) case "coverage_summary": return s.handleAnalyzeCoverageSummary(ctx, req) case "channel_ops": return s.handleAnalyzeChannelOps(ctx, req) case "def_use": return s.handleAnalyzeDefUse(ctx, req) case "goroutine_spawns": return s.handleAnalyzeGoroutineSpawns(ctx, req) case "field_writers": return s.handleAnalyzeFieldWriters(ctx, req) case "indirect_mutations": return s.handleAnalyzeIndirectMutations(ctx, req) case "speculative": return s.handleAnalyzeSpeculative(ctx, req) case "ref_facts": return s.handleAnalyzeRefFacts(ctx, req) case "race_writes": return s.handleAnalyzeRaceWrites(ctx, req) case "unclosed_channels": return s.handleAnalyzeUnclosedChannels(ctx, req) case "unsafe_patterns": return s.handleAnalyzeUnsafePatterns(ctx, req) case "sast", "hygiene": return s.handleAnalyzeSAST(ctx, req, kind) case "review": return s.handleAnalyzeSAST(ctx, req, "review") case "domain": return s.handleAnalyzeSAST(ctx, req, "domain") case "health_score": return s.handleAnalyzeHealthScore(ctx, req) case "annotation_users": return s.handleAnalyzeAnnotationUsers(ctx, req) case "config_readers": return s.handleAnalyzeConfigReaders(ctx, req) case "env_var_users": return s.handleAnalyzeEnvVarUsers(ctx, req) case "sql_call_sites": return s.handleAnalyzeSQLCallSites(ctx, req) case "fixes_history": return s.handleAnalyzeFixesHistory(ctx, req) case "edge_audit": return s.handleAnalyzeEdgeAudit(ctx, req) case "event_emitters": return s.handleAnalyzeEventEmitters(ctx, req) case "pubsub": return s.handleAnalyzePubsub(ctx, req) case "string_emitters": return s.handleAnalyzeStringEmitters(ctx, req) case "error_surface": return s.handleAnalyzeErrorSurface(ctx, req) case "log_events": return s.handleAnalyzeLogEvents(ctx, req) case "sql_rebuild": return s.handleAnalyzeSQLRebuild(ctx, req) case "external_calls": return s.handleAnalyzeExternalCalls(ctx, req) case "synthesizers": return s.handleAnalyzeSynthesizers(ctx, req) case "temporal_orphans": return s.handleAnalyzeTemporalOrphans(ctx, req) case "resolution_outcomes": return s.handleAnalyzeResolutionOutcomes(ctx, req) case "temporal_verify": return s.handleAnalyzeTemporalVerify(ctx, req) case "retrieval_log": return s.handleAnalyzeRetrievalLog(ctx, req) case "routes": return s.handleAnalyzeRoutes(ctx, req) case "route_frameworks": return s.handleAnalyzeRouteFrameworks(ctx, req) case "drupal_hooks": return s.handleAnalyzeDrupalHooks(ctx, req) case "swiftui_views": return s.handleAnalyzeSwiftUIViews(ctx, req) case "uikit_classes": return s.handleAnalyzeUIKitClasses(ctx, req) case "models": return s.handleAnalyzeModels(ctx, req) case "components": return s.handleAnalyzeComponents(ctx, req) case "k8s_resources": return s.handleAnalyzeK8sResources(ctx, req) case "images": return s.handleAnalyzeImages(ctx, req) case "kustomize": return s.handleAnalyzeKustomize(ctx, req) case "cross_repo": return s.handleAnalyzeCrossRepo(ctx, req) case "dbt_models": return s.handleAnalyzeDbtModels(ctx, req) case "role": return s.handleAnalyzeRole(ctx, req) case "constructors_missing_fields": return s.handleAnalyzeConstructorsMissingFields(ctx, req) case "clusters": return s.handleAnalyzeClusters(ctx, req) case "suggest_boundaries": return s.handleSuggestBoundaries(ctx, req) case "concepts": return s.handleAnalyzeConcepts(ctx, req) case "impact": return s.handleAnalyzeImpactComposite(ctx, req) case "bottlenecks": return s.handleAnalyzeBottlenecks(ctx, req) case "named": return s.handleAnalyzeNamed(ctx, req) case "tests_as_edges": return s.handleAnalyzeTestsAsEdges(ctx, req) case "connectivity_health": return s.handleAnalyzeConnectivityHealth(ctx, req) case "pagerank": return s.handleAnalyzePageRank(ctx, req) case "louvain": return s.handleAnalyzeLouvain(ctx, req) case "wcc": return s.handleAnalyzeConnectedComponents(ctx, req, false) case "scc": return s.handleAnalyzeConnectedComponents(ctx, req, true) case "kcore": return s.handleAnalyzeKCore(ctx, req) default: return mcp.NewToolResultError("unknown analyze kind: " + kind + " (expected: " + analyzeKindsCSV() + ")"), nil } }() // Disclose when the caller asked to narrow (repo/project/scope) but // the chosen kind does not repo-narrow its rows in v1 — it enumerates // edges / scans files / mines git. scope_applied stays uniform and // truthful about the resolved scope; scope_note prevents it from // misleading a caller whose kind ignored the narrowing ("no silent // no-ops"). if err == nil && res != nil && resolved.RepoAllow != nil && !analyzeScopeAwareKinds[kind] && !analyzeWorkspaceClampedKinds[kind] { stampScopeNote(res, kind) } return withScopeResult(res, err, resolved) } // requestWithoutArgs returns a shallow copy of req with the named // argument keys removed, so resolveScope doesn't mistake a kind-specific // arg (cross_repo's `repo`, cycles' `scope`) for the uniform scope // dimension. The original req is untouched. func requestWithoutArgs(req mcp.CallToolRequest, keys ...string) mcp.CallToolRequest { src := req.GetArguments() dst := make(map[string]any, len(src)) for k, v := range src { dst[k] = v } for _, k := range keys { delete(dst, k) } out := req out.Params.Arguments = dst return out } // stampScopeNote records on the response that the caller asked to narrow // but the chosen kind does not repo-narrow its rows in v1, keeping // scope_applied uniform while disclosing the no-op. func stampScopeNote(res *mcp.CallToolResult, kind string) { if res == nil { return } note := "kind '" + kind + "' is not scope-narrowed in v1 (a community / git-mining / per-id / synthesizer kind); it reads the full graph directly, so results may span the entire index / all workspaces — not just the session workspace" if res.Meta == nil { res.Meta = mcp.NewMetaFromMap(map[string]any{"scope_note": note}) return } if res.Meta.AdditionalFields == nil { res.Meta.AdditionalFields = map[string]any{} } res.Meta.AdditionalFields["scope_note"] = note } // analyzeNodeVisible reports whether a node may appear in an analyze // result for the current request: inside the session workspace ceiling // AND inside the optional ctx RepoAllow narrowing. It reuses the same // gates as the scoped-node accessors so the bypass kinds that filter // through it (dead_code, hotspots, cycles) match the AUTO kinds and // simultaneously honour the workspace boundary they would otherwise // ignore (those kinds read s.graph directly). Returns true for an // unbound session with no RepoAllow, so an unconditional filter is a // strict no-op in that case. func (s *Server) analyzeNodeVisible(ctx context.Context, n *graph.Node) bool { if n == nil { return false } if !s.nodeInSessionScope(ctx, n) { return false } if allow := repoAllowFromContext(ctx); len(allow) > 0 && !allow[n.RepoPrefix] { return false } return true } // scopeFiltersActive reports whether the current request narrows analyze // output below the global graph — a workspace-bound session or a ctx // RepoAllow. When false, analyzeNodeVisible passes every node, so the // Tier-2 filters skip the work and preserve byte-for-byte output. func (s *Server) scopeFiltersActive(ctx context.Context) bool { if _, _, bound := s.sessionScope(ctx); bound { return true } return len(repoAllowFromContext(ctx)) > 0 } // --------------------------------------------------------------------------- // handleAnalyzeTodos — list KindTodo nodes with filters // --------------------------------------------------------------------------- // handleAnalyzeTodos enumerates the KindTodo nodes in the graph, // optionally filtering by tag (TODO/FIXME/HACK/XXX/NOTE), assignee, // or ticket. Designed for the cleanup-loop workflow: find every // TODO assigned to me, every FIXME without a ticket, every TODO // older than the v1.4 release, etc. The temporal filter is left // for a v2 refinement that consumes git-blame enrichment. // // Returns one row per matching todo with file, line, tag, // assignee, due, ticket, and the truncated text. func (s *Server) handleAnalyzeTodos(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() tagFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "tag"))) assigneeFilter := strings.TrimSpace(stringArg(args, "assignee")) ticketFilter := strings.TrimSpace(stringArg(args, "ticket")) requireAssignee, _ := args["has_assignee"].(bool) type todoRow struct { ID string `json:"id"` Tag string `json:"tag"` File string `json:"file"` Line int `json:"line"` Assignee string `json:"assignee,omitempty"` Due string `json:"due,omitempty"` Ticket string `json:"ticket,omitempty"` Text string `json:"text,omitempty"` } var rows []todoRow // Push the kind filter into the storage layer — todos are a // tiny slice of the node table, so the AllNodes scan was the // dominant cost on a disk backend. for _, n := range s.scopedNodesByKinds(ctx, []graph.NodeKind{graph.KindTodo}) { tag, _ := n.Meta["tag"].(string) assignee, _ := n.Meta["assignee"].(string) ticket, _ := n.Meta["ticket"].(string) due, _ := n.Meta["due"].(string) text, _ := n.Meta["text"].(string) if tagFilter != "" && strings.ToLower(tag) != tagFilter { continue } if assigneeFilter != "" && assignee != assigneeFilter { continue } if ticketFilter != "" && ticket != ticketFilter { continue } if requireAssignee && assignee == "" { continue } rows = append(rows, todoRow{ ID: n.ID, Tag: tag, File: n.FilePath, Line: n.StartLine, Assignee: assignee, Due: due, Ticket: ticket, Text: text, }) } // Stable order: file then line. Predictable diffs across calls // matter for cleanup workflows that compare results over time. sort.Slice(rows, func(i, j int) bool { if rows[i].File != rows[j].File { return rows[i].File < rows[j].File } return rows[i].Line < rows[j].Line }) if isCompact(req) { var b strings.Builder for _, r := range rows { fmt.Fprintf(&b, "%s %s:%d", r.Tag, r.File, r.Line) if r.Assignee != "" { fmt.Fprintf(&b, " @%s", r.Assignee) } if r.Ticket != "" { fmt.Fprintf(&b, " %s", r.Ticket) } if r.Text != "" { fmt.Fprintf(&b, " — %s", r.Text) } b.WriteByte('\n') } if len(rows) == 0 { b.WriteString("no todos matched\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "todos": rows, "total": len(rows), }) } // stringArg returns args[key] as a trimmed string, or "" when the // key is missing or the value isn't a string. func stringArg(args map[string]any, key string) string { if v, ok := args[key].(string); ok { return v } return "" } // handleAnalyzeCoverage parses a Go cover profile and stamps // meta.coverage_pct + meta.coverage on every executable symbol it // can map to a profile segment by line range. Requires a `profile` // argument with the path to the cover.out file (relative paths // resolve against the indexed repo root). // // Re-runnable: each call re-reads the profile and overwrites // existing meta — the desired behaviour after a fresh test run. func (s *Server) handleAnalyzeCoverage(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { profileArg := stringArg(req.GetArguments(), "profile") if profileArg == "" { return mcp.NewToolResultError("coverage enrichment requires a `profile` argument with the cover.out path"), nil } if s.indexer == nil { return mcp.NewToolResultError("coverage enrichment requires an active indexer"), nil } root := s.indexer.RootPath() if !filepath.IsAbs(profileArg) { profileArg = filepath.Join(root, profileArg) } segments, err := coverage.ParseFile(profileArg) if err != nil { return mcp.NewToolResultError("read profile: " + err.Error()), nil } modulePath := coverage.ReadModulePath(root) count := coverage.EnrichGraph(s.graph, segments, modulePath) return s.respondJSONOrTOON(ctx, req, map[string]any{ "enriched": count, "segments": len(segments), "profile": profileArg, "module_path": modulePath, }) } // handleAnalyzeStaleCode lists symbols whose meta.last_authored is // older than the threshold. Requires that blame enrichment has // already run (either through analyze kind=blame or `gortex enrich // blame`); symbols without authorship metadata are silently // skipped — they're either unenriched or hand-authored without git // history (test fixtures, generated code), and lumping them in // with "unchanged for ages" would be a lie. // // Filters: // // - older_than: days, default 365. Symbols with a last-author // timestamp older than now - older_than days are included. // - email: exact author email match — useful for "find code // authored by someone who has left the team." // - kinds: comma-separated list, default function,method. Pass // "all" to include every blame-eligible kind. // // Sorted oldest-first so the cleanup loop sees the staleness // gradient at a glance. func (s *Server) handleAnalyzeStaleCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() olderThanDays := 365.0 if v, ok := args["older_than"].(float64); ok && v > 0 { olderThanDays = v } emailFilter := strings.TrimSpace(stringArg(args, "email")) allowedKinds := map[graph.NodeKind]struct{}{ graph.KindFunction: {}, graph.KindMethod: {}, } if k := strings.TrimSpace(stringArg(args, "kinds")); k != "" { allowedKinds = parseAnalyzeKindsFilter(k) } cutoffSec := time.Now().Add(-time.Duration(olderThanDays*24) * time.Hour).Unix() type staleRow struct { ID string `json:"id"` File string `json:"file"` Line int `json:"line"` Email string `json:"email"` Commit string `json:"commit"` Timestamp int64 `json:"timestamp"` AgeDays int `json:"age_days"` } var rows []staleRow // Push the kind filter into the storage layer; the meta gate // (last_authored.timestamp) stays in Go since the meta column is // opaque to the query layer. blame := blameRowsByID(s.graph) for _, n := range s.scopedNodesByKinds(ctx, allowedKindsSlice(allowedKinds)) { la, ok := lastAuthoredFrom(blame, n) if !ok || la.Timestamp == 0 { continue } ts := la.Timestamp if ts > cutoffSec { continue } email := la.Email if emailFilter != "" && email != emailFilter { continue } commit := la.Commit ageSec := time.Now().Unix() - ts rows = append(rows, staleRow{ ID: n.ID, File: n.FilePath, Line: n.StartLine, Email: email, Commit: commit, Timestamp: ts, AgeDays: int(ageSec / (24 * 3600)), }) } sort.Slice(rows, func(i, j int) bool { return rows[i].Timestamp < rows[j].Timestamp }) if isCompact(req) { var b strings.Builder for _, r := range rows { fmt.Fprintf(&b, "%dd %s:%d", r.AgeDays, r.File, r.Line) if r.Email != "" { fmt.Fprintf(&b, " @%s", r.Email) } fmt.Fprintf(&b, " %s\n", r.ID) } if len(rows) == 0 { b.WriteString("no stale code matched\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "stale": rows, "total": len(rows), "older_than_day": olderThanDays, }) } // allowedKindsSlice returns the keys of an analyzer's allowedKinds // set so the caller can hand them to scopedNodesByKinds. Kept as a // helper rather than inlined at every call site so the order is // deterministic — not load-bearing for correctness (the capability // dedupes), but it keeps test expectations stable when the IN list // is logged. func allowedKindsSlice(allowed map[graph.NodeKind]struct{}) []graph.NodeKind { out := make([]graph.NodeKind, 0, len(allowed)) for k := range allowed { out = append(out, k) } slices.Sort(out) return out } // parseAnalyzeKindsFilter parses a comma-separated kinds argument // into the set used by handleAnalyzeStaleCode. The literal "all" // returns the broadest blame-eligible kind set so callers can drop // the default function/method scope when they want types and // fields included too. func parseAnalyzeKindsFilter(arg string) map[graph.NodeKind]struct{} { out := map[graph.NodeKind]struct{}{} for k := range strings.SplitSeq(arg, ",") { k = strings.TrimSpace(strings.ToLower(k)) if k == "" { continue } if k == "all" { return map[graph.NodeKind]struct{}{ graph.KindFunction: {}, graph.KindMethod: {}, graph.KindType: {}, graph.KindInterface: {}, graph.KindField: {}, graph.KindVariable: {}, graph.KindConstant: {}, graph.KindEnumMember: {}, } } out[graph.NodeKind(k)] = struct{}{} } return out } // handleAnalyzeOwnership groups blame metadata by author email and // returns one row per author with the symbol count, files // touched, and the oldest/newest last-authored timestamp seen. // Requires a blame-enriched graph (analyze kind=blame or `gortex // enrich blame`) — symbols without authorship metadata are // silently skipped, same as handleAnalyzeStaleCode. // // Filters: // // - min_symbols: drop authors below this symbol count (default 1). // Useful for excluding drive-by contributions on large repos. // - kinds: comma-separated kind list, default function,method. // Pass "all" to include every blame-eligible kind. // - path_prefix: scope to nodes under this file-path prefix — // e.g. "internal/auth/" to ask "who owns the auth package". // // Sorted descending by symbol count so the top owners appear // first. The combination (path_prefix + min_symbols + sorted // output) is the cleanup-loop's "who do I ping for review on // this area" query without needing a CODEOWNERS file. func (s *Server) handleAnalyzeOwnership(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() minSymbols := 1 if v, ok := args["min_symbols"].(float64); ok && v > 0 { minSymbols = int(v) } pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix")) allowedKinds := map[graph.NodeKind]struct{}{ graph.KindFunction: {}, graph.KindMethod: {}, } if k := strings.TrimSpace(stringArg(args, "kinds")); k != "" { allowedKinds = parseAnalyzeKindsFilter(k) } type ownerStats struct { Email string `json:"email"` Symbols int `json:"symbols"` Files int `json:"files"` OldestTS int64 `json:"oldest_timestamp"` NewestTS int64 `json:"newest_timestamp"` fileSet map[string]struct{} } byEmail := map[string]*ownerStats{} // Kind pushdown — owners are derived from the blame meta on // function/method (or wider) nodes; the analyzer scans tens of // thousands of irrelevant nodes without it on a disk backend. ownBlame := blameRowsByID(s.graph) for _, n := range s.scopedNodesByKinds(ctx, allowedKindsSlice(allowedKinds)) { if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) { continue } la, ok := lastAuthoredFrom(ownBlame, n) if !ok { continue } email := la.Email if email == "" { continue } ts := la.Timestamp if ts == 0 { continue } stats, ok := byEmail[email] if !ok { stats = &ownerStats{ Email: email, OldestTS: ts, NewestTS: ts, fileSet: map[string]struct{}{}, } byEmail[email] = stats } stats.Symbols++ stats.fileSet[n.FilePath] = struct{}{} if ts < stats.OldestTS { stats.OldestTS = ts } if ts > stats.NewestTS { stats.NewestTS = ts } } rows := make([]*ownerStats, 0, len(byEmail)) for _, s := range byEmail { s.Files = len(s.fileSet) s.fileSet = nil // hide from JSON output if s.Symbols < minSymbols { continue } rows = append(rows, s) } sort.Slice(rows, func(i, j int) bool { if rows[i].Symbols != rows[j].Symbols { return rows[i].Symbols > rows[j].Symbols } return rows[i].Email < rows[j].Email }) if isCompact(req) { var b strings.Builder for _, r := range rows { fmt.Fprintf(&b, "%-5d %-3d %s\n", r.Symbols, r.Files, r.Email) } if len(rows) == 0 { b.WriteString("no owners matched\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "owners": rows, "total": len(rows), }) } // tsFromMeta normalises the timestamp field across the int64 // (in-process enrichment) and float64 (gob-decoded snapshot) // shapes. Returns 0 when the value is missing or the wrong type // — callers treat 0 as "skip this node" since blame timestamps // are always positive. func tsFromMeta(raw any) int64 { switch v := raw.(type) { case int64: return v case float64: return int64(v) } return 0 } // handleAnalyzeCoverageGaps lists symbols whose meta.coverage_pct // falls inside [min_pct, max_pct) — the half-open interval lets // "everything below 50%" be expressed as max_pct=50 without // dragging in fully-uncovered nodes that callers might want to // distinguish via a separate query. Requires a coverage-enriched // graph (analyze kind=coverage or `gortex enrich coverage`). // // Symbols without coverage_pct are silently skipped — a node // could be unmeasured because the profile didn't cover it (real // gap) or because it's a non-executable kind (no signal at all). // Lumping the two together would be misleading, and the // distinction lives in the static dead_code analyzer rather than // here. // // Filters: // // - max_pct: upper exclusive bound (default 100 — i.e. anything // not fully covered). // - min_pct: lower inclusive bound (default 0 — i.e. include // fully-uncovered too). Combine with max_pct to scope to a // coverage band: "20-50% coverage" is min_pct=20 max_pct=50. // - kinds: same shared kind filter as stale_code/ownership; // default function/method. // - path_prefix: scope to a directory subtree. // // Sorted ascending by coverage_pct so the most-undertested // symbols surface first. func (s *Server) handleAnalyzeCoverageGaps(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() maxPct := 100.0 if v, ok := args["max_pct"].(float64); ok && v > 0 { maxPct = v } minPct := 0.0 if v, ok := args["min_pct"].(float64); ok && v >= 0 { minPct = v } pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix")) allowedKinds := map[graph.NodeKind]struct{}{ graph.KindFunction: {}, graph.KindMethod: {}, } if k := strings.TrimSpace(stringArg(args, "kinds")); k != "" { allowedKinds = parseAnalyzeKindsFilter(k) } type gapRow struct { ID string `json:"id"` File string `json:"file"` Line int `json:"line"` Pct float64 `json:"coverage_pct"` NumStmt int `json:"num_stmt"` Hit int `json:"hit"` } var rows []gapRow covRows := s.coverageByID() // Kind pushdown — coverage_pct only ever lands on executable // kinds, so the IN-list IS the candidate set. for _, n := range s.scopedNodesByKinds(ctx, allowedKindsSlice(allowedKinds)) { if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) { continue } pct, ok := coveragePctFrom(covRows, n) if !ok { continue } if pct < minPct || pct >= maxPct { continue } row := gapRow{ ID: n.ID, File: n.FilePath, Line: n.StartLine, Pct: pct, } if e, ok := covRows[n.ID]; ok { row.NumStmt = e.NumStmt row.Hit = e.Hit } else if cov, ok := n.Meta["coverage"].(map[string]any); ok { if v, ok := cov["num_stmt"].(int); ok { row.NumStmt = v } else if f, ok := cov["num_stmt"].(float64); ok { row.NumStmt = int(f) } if v, ok := cov["hit"].(int); ok { row.Hit = v } else if f, ok := cov["hit"].(float64); ok { row.Hit = int(f) } } rows = append(rows, row) } sort.Slice(rows, func(i, j int) bool { if rows[i].Pct != rows[j].Pct { return rows[i].Pct < rows[j].Pct } // Tie-break by symbol size — bigger gaps surface above // smaller ones at the same percentage. NumStmt may be 0 // when meta.coverage didn't decode cleanly; the secondary // fallback to file:line keeps the order stable. if rows[i].NumStmt != rows[j].NumStmt { return rows[i].NumStmt > rows[j].NumStmt } if rows[i].File != rows[j].File { return rows[i].File < rows[j].File } return rows[i].Line < rows[j].Line }) if isCompact(req) { var b strings.Builder for _, r := range rows { fmt.Fprintf(&b, "%5.1f%% %s:%d %s\n", r.Pct, r.File, r.Line, r.ID) } if len(rows) == 0 { b.WriteString("no coverage gaps matched\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "gaps": rows, "total": len(rows), "min_pct": minPct, "max_pct": maxPct, }) } // handleAnalyzeStaleFlags lists feature flags whose every toggling // call site was last touched more than `older_than` days ago. The // staleness signal is derived: for each KindFlag node we walk its // incoming EdgeTogglesFlag edges, look up each caller's // meta.last_authored.timestamp, and take the maximum. If even the // most-recently-touched check site is older than the cutoff, the // flag is stale — every check is in code nobody's edited in a // while, which is the operational signal that the rollout is // done. // // Requires both flag detection (analyze kind=blame is enough to // populate KindFlag nodes if the repo enables index.coverage.flags) // AND blame enrichment (analyze kind=blame). Flags whose callers // don't have blame metadata are silently skipped — without // authorship data we can't compute the staleness — and reported // in the response's `unscored` count so the agent can tell the // difference between "no flags found" and "flags found but // unscored." // // Filters: // // - older_than: days, default 365. // - provider: filter to a single provider (launchdarkly, // growthbook, unleash, internal). // // Sorted oldest-first so cleanup priorities surface at the top. func (s *Server) handleAnalyzeStaleFlags(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() olderThanDays := 365.0 if v, ok := args["older_than"].(float64); ok && v > 0 { olderThanDays = v } providerFilter := strings.TrimSpace(stringArg(args, "provider")) cutoffSec := time.Now().Add(-time.Duration(olderThanDays*24) * time.Hour).Unix() type staleFlag struct { ID string `json:"id"` Name string `json:"name"` Provider string `json:"provider"` Callers int `json:"callers"` NewestCallTS int64 `json:"newest_call_timestamp"` AgeDays int `json:"age_days"` } var rows []staleFlag unscored := 0 // Kind pushdown — KindFlag is a few hundred nodes max even on // the biggest workspaces, so pulling AllNodes() to find them // was pure overhead. The caller batch below still does per- // flag GetInEdges; pushing that into a single query join is a // separate follow-up since the join semantics differ per flag. flagBlame := blameRowsByID(s.graph) for _, n := range s.scopedNodesByKinds(ctx, []graph.NodeKind{graph.KindFlag}) { provider, _ := n.Meta["provider"].(string) if providerFilter != "" && provider != providerFilter { continue } // Walk incoming EdgeTogglesFlag edges to collect callers. var callerIDs []string for _, e := range s.graph.GetInEdges(n.ID) { if e.Kind != graph.EdgeTogglesFlag { continue } callerIDs = append(callerIDs, e.From) } if len(callerIDs) == 0 { // Orphan flag — declared but never checked. Treat as // stale: a flag with zero call sites is tautologically // safe to delete. rows = append(rows, staleFlag{ ID: n.ID, Name: stringFromMeta(n.Meta, "name"), Provider: provider, Callers: 0, AgeDays: -1, }) continue } var newestTS int64 hasBlame := false for _, callerID := range callerIDs { caller := s.graph.GetNode(callerID) if caller == nil { continue } la, ok := lastAuthoredFrom(flagBlame, caller) if !ok { continue } ts := la.Timestamp if ts == 0 { continue } hasBlame = true if ts > newestTS { newestTS = ts } } if !hasBlame { unscored++ continue } if newestTS > cutoffSec { continue // some caller is fresh } ageSec := time.Now().Unix() - newestTS rows = append(rows, staleFlag{ ID: n.ID, Name: stringFromMeta(n.Meta, "name"), Provider: provider, Callers: len(callerIDs), NewestCallTS: newestTS, AgeDays: int(ageSec / (24 * 3600)), }) } sort.Slice(rows, func(i, j int) bool { // Orphans (AgeDays = -1) first, then oldest by timestamp. if rows[i].AgeDays < 0 && rows[j].AgeDays >= 0 { return true } if rows[j].AgeDays < 0 && rows[i].AgeDays >= 0 { return false } return rows[i].NewestCallTS < rows[j].NewestCallTS }) if isCompact(req) { var b strings.Builder for _, r := range rows { if r.AgeDays < 0 { fmt.Fprintf(&b, "ORPHAN %s (%s)\n", r.Name, r.Provider) continue } fmt.Fprintf(&b, "%4dd %s (%s) — %d callers\n", r.AgeDays, r.Name, r.Provider, r.Callers) } if len(rows) == 0 { b.WriteString("no stale flags matched\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "flags": rows, "total": len(rows), "unscored": unscored, "older_than_day": olderThanDays, }) } // stringFromMeta is a tiny helper for safe meta string extraction. func stringFromMeta(meta map[string]any, key string) string { if v, ok := meta[key].(string); ok { return v } return "" } // handleAnalyzeOrphanTables lists tables that are referenced by // at least one EdgeQueries call site but have no incoming // EdgeProvides from a migration. Combines the two SQL extraction // paths (query-string detection + migration-file declaration) // into a single signal: tables likely missing a migration, or // pointing at an external/legacy schema the agent should flag. // // Returns one row per orphan with the canonical id, table name, // schema, dialect, and the count of EdgeQueries call sites // pointing at it. Sorted by query count descending so the most- // used orphans surface first — those are the highest-priority // "we should declare this" candidates. // // Tables reachable via both EdgeProvides AND EdgeQueries are not // orphans by definition. Tables with no EdgeQueries either (pure // declaration with no users) aren't included either — they're // the inverse problem ("orphan migration") which is a separate // future analyzer. func (s *Server) handleAnalyzeOrphanTables(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { type orphanRow struct { ID string `json:"id"` Table string `json:"table"` Schema string `json:"schema,omitempty"` Dialect string `json:"dialect"` QueryCount int `json:"query_count"` } var rows []orphanRow // Kind pushdown — only KindTable carries the providers/queries // fan-in we care about; the rest of the node table is noise. for _, n := range s.scopedNodesByKinds(ctx, []graph.NodeKind{graph.KindTable}) { // Walk incoming edges to detect both providers (migrations) // and consumers (query call sites). hasProvider := false queryCount := 0 for _, e := range s.graph.GetInEdges(n.ID) { switch e.Kind { case graph.EdgeProvides: hasProvider = true case graph.EdgeQueries: queryCount++ } } if hasProvider { continue } if queryCount == 0 { continue } dialect, _ := n.Meta["dialect"].(string) schema, _ := n.Meta["schema"].(string) table, _ := n.Meta["table"].(string) if table == "" { table = n.Name } rows = append(rows, orphanRow{ ID: n.ID, Table: table, Schema: schema, Dialect: dialect, QueryCount: queryCount, }) } sort.Slice(rows, func(i, j int) bool { if rows[i].QueryCount != rows[j].QueryCount { return rows[i].QueryCount > rows[j].QueryCount } return rows[i].ID < rows[j].ID }) if isCompact(req) { var b strings.Builder for _, r := range rows { fmt.Fprintf(&b, "%-3d %s\n", r.QueryCount, r.ID) } if len(rows) == 0 { b.WriteString("no orphan tables\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "orphans": rows, "total": len(rows), }) } // handleAnalyzeUnreferencedTables is the inverse of // orphan_tables: tables that have an incoming EdgeProvides from // a migration but zero EdgeQueries call sites. Useful for // "which migrations created tables we don't read or write" — // dead schema candidates, cleanup signals after a feature // removal, or tables that exist only for downstream replication. // // Returns one row per unreferenced table with the canonical id, // table name, schema, dialect, and the count of providers // (typically 1, but a table can appear in multiple migrations). // Sorted alphabetically by id for diff-able output — there's no // natural priority ordering for this list the way query_count // gives orphan_tables. func (s *Server) handleAnalyzeUnreferencedTables(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { type unrefRow struct { ID string `json:"id"` Table string `json:"table"` Schema string `json:"schema,omitempty"` Dialect string `json:"dialect"` ProviderCount int `json:"provider_count"` } var rows []unrefRow // Kind pushdown — same story as orphan_tables. for _, n := range s.scopedNodesByKinds(ctx, []graph.NodeKind{graph.KindTable}) { providerCount := 0 queryCount := 0 for _, e := range s.graph.GetInEdges(n.ID) { switch e.Kind { case graph.EdgeProvides: providerCount++ case graph.EdgeQueries: queryCount++ } } if providerCount == 0 || queryCount > 0 { continue } dialect, _ := n.Meta["dialect"].(string) schema, _ := n.Meta["schema"].(string) table, _ := n.Meta["table"].(string) if table == "" { table = n.Name } rows = append(rows, unrefRow{ ID: n.ID, Table: table, Schema: schema, Dialect: dialect, ProviderCount: providerCount, }) } sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) if isCompact(req) { var b strings.Builder for _, r := range rows { fmt.Fprintln(&b, r.ID) } if len(rows) == 0 { b.WriteString("no unreferenced tables\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "unreferenced": rows, "total": len(rows), }) } // handleAnalyzeCoverageSummary aggregates meta.coverage_pct per // directory. Complements coverage_gaps (per-symbol view) with a // package-level rollup useful for cleanup planning ("which // directory needs the most test attention"). Each row carries // the directory path, total measured symbols, average coverage, // fully-covered count, fully-uncovered count, and partial count // — the breakdown helps distinguish "package needs more // branches tested" from "package has no tests at all". // // Sorted ascending by avg_pct so worst packages surface first. // Filters mirror coverage_gaps: kinds (default function/method), // path_prefix scoping. func (s *Server) handleAnalyzeCoverageSummary(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix")) allowedKinds := map[graph.NodeKind]struct{}{ graph.KindFunction: {}, graph.KindMethod: {}, } if k := strings.TrimSpace(stringArg(args, "kinds")); k != "" { allowedKinds = parseAnalyzeKindsFilter(k) } type dirStats struct { Dir string `json:"dir"` Symbols int `json:"symbols"` AvgPct float64 `json:"avg_pct"` Covered int `json:"covered"` Partial int `json:"partial"` Uncovered int `json:"uncovered"` sumPct float64 // running sum, hidden from JSON } byDir := map[string]*dirStats{} covRows := s.coverageByID() // Kind pushdown — coverage_pct only lives on executable kinds. for _, n := range s.scopedNodesByKinds(ctx, allowedKindsSlice(allowedKinds)) { if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) { continue } pct, ok := coveragePctFrom(covRows, n) if !ok { continue } dir := filepath.Dir(n.FilePath) ds, ok := byDir[dir] if !ok { ds = &dirStats{Dir: dir} byDir[dir] = ds } ds.Symbols++ ds.sumPct += pct switch { case pct >= 100: ds.Covered++ case pct == 0: ds.Uncovered++ default: ds.Partial++ } } rows := make([]*dirStats, 0, len(byDir)) for _, s := range byDir { if s.Symbols == 0 { continue } s.AvgPct = roundTwoDecimal(s.sumPct / float64(s.Symbols)) s.sumPct = 0 rows = append(rows, s) } sort.Slice(rows, func(i, j int) bool { if rows[i].AvgPct != rows[j].AvgPct { return rows[i].AvgPct < rows[j].AvgPct } return rows[i].Dir < rows[j].Dir }) if isCompact(req) { var b strings.Builder for _, r := range rows { fmt.Fprintf(&b, "%5.1f%% %3d sym (%d cov / %d part / %d unc) %s\n", r.AvgPct, r.Symbols, r.Covered, r.Partial, r.Uncovered, r.Dir) } if len(rows) == 0 { b.WriteString("no coverage data\n") } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "directories": rows, "total": len(rows), }) } // roundTwoDecimal rounds to 2 decimal places, mirroring the // coverage package's roundTwo. Local helper rather than an import // dependency on internal/coverage so the mcp tool stays // self-contained. func roundTwoDecimal(v float64) float64 { if v < 0 { return v } return float64(int64(v*100+0.5)) / 100 } // handleAnalyzeInteropUsers lists every file with the named // cross-language interop meta flag set. Currently used for two // sentinels: meta.uses_cgo (Go files that `import "C"`) and // meta.uses_wasm_bindgen (Rust files with `#[wasm_bindgen]`). // Each routes through the same handler with a different // metaKey + resultKey pair — adding future interop kinds // (jni, napi, ffi-style imports) is one switch case in the // dispatcher. // // Useful for porting surveys ("how much surface uses cgo?"), // CI gate questions ("did this PR add a new wasm-bindgen // boundary?"), and non-interop build planning. Files are // reported in path order so the result is diff-able across // runs. func (s *Server) handleAnalyzeInteropUsers(ctx context.Context, req mcp.CallToolRequest, metaKey, resultKey string) (*mcp.CallToolResult, error) { type interopFile struct { File string `json:"file"` ID string `json:"id"` } var rows []interopFile // Kind pushdown — uses_cgo / uses_wasm_bindgen sentinels only // live on file nodes; pulling AllNodes() to find them was pure // overhead on a disk backend. for _, n := range s.scopedNodesByKinds(ctx, []graph.NodeKind{graph.KindFile}) { if v, _ := n.Meta[metaKey].(bool); !v { continue } rows = append(rows, interopFile{ File: n.FilePath, ID: n.ID, }) } sort.Slice(rows, func(i, j int) bool { return rows[i].File < rows[j].File }) if isCompact(req) { var b strings.Builder for _, r := range rows { b.WriteString(r.File) b.WriteByte('\n') } if len(rows) == 0 { fmt.Fprintf(&b, "no %s\n", resultKey) } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ resultKey: rows, "total": len(rows), }) } // handleAnalyzeReleases reads the pre-computed release timeline from // the graph. Inputs come from meta.added_in (stamped on KindFile // nodes) and the KindRelease nodes the enricher materialises — one // per tag, ordered, carrying file_count metadata. No git subprocess // at read time. // // When nothing in scope carries release metadata the tool returns a // structured error pointing the agent at `enrich_releases` (or the // `gortex enrich releases` CLI) rather than silently returning an // empty result; the latter would look like "this repo has no // releases" even when the cause is "you haven't enriched yet". // // Optional filter `tag` returns only the named release with the list // of files whose meta.added_in matches it — answers "what shipped in // v1.4?" with a single graph scan. func (s *Server) handleAnalyzeReleases(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { if s.graph == nil { return mcp.NewToolResultError("graph not initialized"), nil } repoFilter := strings.TrimSpace(req.GetString("repo", "")) tagFilter := strings.TrimSpace(req.GetString("tag", "")) type releaseRow struct { ID string `json:"id"` Tag string `json:"tag"` RepoPrefix string `json:"repo_prefix,omitempty"` FileCount int `json:"file_count"` Order int `json:"order"` Files []string `json:"files,omitempty"` } releaseByTag := map[string]*releaseRow{} for _, n := range s.graph.AllNodes() { if n.Kind != graph.KindRelease { continue } // Gate every release node on the session workspace ceiling + // resolved RepoAllow narrowing (stamped into ctx by handleAnalyze). // Strict no-op for an unbound session with no RepoAllow. if !s.analyzeNodeVisible(ctx, n) { continue } if repoFilter != "" && n.RepoPrefix != repoFilter { continue } row := &releaseRow{ ID: n.ID, Tag: n.Name, RepoPrefix: n.RepoPrefix, } if n.Meta != nil { row.FileCount = intFromAny(n.Meta["file_count"]) row.Order = intFromAny(n.Meta["order"]) } key := releaseKey(n.RepoPrefix, n.Name) releaseByTag[key] = row } if tagFilter != "" { // Caller wants the file list for one release. We surface it // from meta.added_in rather than a tree walk, so the answer // is whatever the last enrich pass observed. row, ok := releaseByTag[releaseKey(repoFilter, tagFilter)] if !ok { // Tolerate the no-prefix form: agents pass "v1.4" without // realising the graph stores multi-repo tags as // "/v1.4". Fall back to a tag-name-only match. for k, r := range releaseByTag { if r.Tag == tagFilter { row = r _ = k break } } } if row == nil { return s.respondJSONOrTOON(ctx, req, map[string]any{ "error": fmt.Sprintf("no KindRelease node for tag %q; run `enrich_releases` first", tagFilter), "suggestion": "enrich_releases", "releases": []releaseRow{}, "total": 0, }) } relByID := s.releaseByID() for _, n := range s.graph.AllNodes() { if n.Kind != graph.KindFile || n.FilePath == "" { continue } if !s.analyzeNodeVisible(ctx, n) { continue } if repoFilter != "" && n.RepoPrefix != repoFilter { continue } added, ok := addedInFrom(relByID, n) if !ok || added != row.Tag { continue } row.Files = append(row.Files, n.FilePath) } sort.Strings(row.Files) return s.respondJSONOrTOON(ctx, req, map[string]any{ "releases": []releaseRow{*row}, "total": 1, "tag": tagFilter, "file_hits": len(row.Files), }) } // No tag filter: return the timeline. Use `order` (oldest=0) so // callers can flip to newest-first via reverse. if len(releaseByTag) == 0 { // Distinguish "no enrichment yet" from "repo has no tags" by // peeking at any file's meta.added_in. If even one file has // the field set the enrichment ran and produced no releases // (an unlikely combination; surface as an empty timeline); // otherwise return the structured error. hasAnyAddedIn := false if relByID := s.releaseByID(); len(relByID) > 0 { hasAnyAddedIn = true } else { for _, n := range s.graph.AllNodes() { if !s.analyzeNodeVisible(ctx, n) { continue } if n.Kind == graph.KindFile && n.Meta != nil { if _, ok := n.Meta["added_in"].(string); ok { hasAnyAddedIn = true break } } } } if !hasAnyAddedIn { return s.respondJSONOrTOON(ctx, req, map[string]any{ "error": "no release timeline in scope; run `enrich_releases` (or `gortex enrich releases`) to populate KindRelease nodes and meta.added_in", "suggestion": "enrich_releases", "releases": []releaseRow{}, "total": 0, }) } } rows := make([]releaseRow, 0, len(releaseByTag)) for _, r := range releaseByTag { rows = append(rows, *r) } sort.Slice(rows, func(i, j int) bool { if rows[i].Order != rows[j].Order { return rows[i].Order < rows[j].Order } return rows[i].Tag < rows[j].Tag }) return s.respondJSONOrTOON(ctx, req, map[string]any{ "releases": rows, "total": len(rows), }) } // releaseKey builds the lookup key from a (repoPrefix, tag) pair so // the tag-filtered path can compare scoped IDs against the bare // agent input. func releaseKey(repoPrefix, tag string) string { if repoPrefix == "" { return tag } return repoPrefix + "/" + tag } // handleAnalyzeBlame runs `git blame -p` against the indexed // repository and stamps meta.last_authored on each function / // method / type / interface / field / variable / constant / // enum_member node it can map to a real source line. Returns the // number of nodes enriched. // // Blocking — large repos can take seconds — but explicit (the // agent invoked it). Repeat invocations re-run blame and overwrite // existing meta.last_authored, which is the desired behaviour for // post-commit refresh. func (s *Server) handleAnalyzeBlame(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { roots := s.collectRepoRoots(req.GetString("repo", "")) if len(roots) == 0 { return mcp.NewToolResultError("blame enrichment requires at least one indexed repo with a root path"), nil } total := 0 perRepo := make(map[string]any, len(roots)) for prefix, root := range roots { count, err := blame.EnrichGraph(s.graph, root) if err != nil { perRepo[prefix] = map[string]any{"root": root, "error": err.Error()} continue } total += count perRepo[prefix] = map[string]any{"root": root, "enriched": count} } return s.respondJSONOrTOON(ctx, req, map[string]any{ "enriched": total, "per_repo": perRepo, }) } // collectRepoRoots returns the set of repo prefix → root paths to enrich. // In multi-repo mode iterates every tracked repo (or just the one matching // `scope` when set). In single-repo mode returns the lone indexer's root // keyed by an empty prefix. Empty roots are skipped so callers don't have // to filter them downstream. func (s *Server) collectRepoRoots(scope string) map[string]string { out := make(map[string]string) if s.multiIndexer != nil { if scope != "" { if root, ok := s.multiIndexer.RepoRoot(scope); ok { out[scope] = root } return out } for prefix, meta := range s.multiIndexer.AllMetadata() { if meta == nil || meta.RootPath == "" { continue } out[prefix] = meta.RootPath } return out } if s.indexer != nil { if root := s.indexer.RootPath(); root != "" { out[s.indexer.RepoPrefix()] = root } } return out } // --------------------------------------------------------------------------- // 10.5 handleFindDeadCode and handleFindHotspots // --------------------------------------------------------------------------- func (s *Server) handleFindDeadCode(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { opts := analysis.FindDeadCodeOptions{} args := req.GetArguments() if v, ok := args["include_variables"].(bool); ok && v { opts.IncludeVariables = true } if v, ok := args["include_fields"].(bool); ok && v { opts.IncludeFields = true } if v, ok := args["include_constants"].(bool); ok && v { opts.IncludeConstants = true } if v, ok := args["include_cgo_exports"].(bool); ok && v { opts.IncludeCgoExports = true } if v, ok := args["include_linkname_targets"].(bool); ok && v { opts.IncludeLinknameTargets = true } if v, ok := args["skip_cross_repo_nodes"].(bool); ok && v { opts.SkipCrossRepoNodes = true } entries := analysis.FindDeadCode(s.graph, s.getProcesses(), nil, opts) // dead_code reads s.graph directly, bypassing the scoped-node // accessors, so narrow its rows to the session workspace + optional // repo allow-set here. This also closes the latent cross-workspace // leak for this kind. Strict no-op for an unbound session with no // RepoAllow. Counts below are computed after this filter. if s.scopeFiltersActive(ctx) { kept := make([]analysis.DeadCodeEntry, 0, len(entries)) for _, e := range entries { if s.analyzeNodeVisible(ctx, s.graph.GetNode(e.ID)) { kept = append(kept, e) } } entries = kept } // Cap response size — large repos surface thousands of dead-code // candidates and the default JSON encoding spills past the MCP // per-response token cap. Callers that need the full list can // raise the limit explicitly. limit := 200 if v, ok := args["limit"].(float64); ok && v > 0 { limit = int(v) } totalEntries := len(entries) deadTruncated := false if len(entries) > limit { entries = entries[:limit] deadTruncated = true } variablesNote := buildDeadCodeNote(opts) if s.isGCX(ctx, req) { items := make([]deadCodeItem, 0, len(entries)) for _, e := range entries { items = append(items, deadCodeItem{ ID: e.ID, Kind: e.Kind, Name: e.Name, Path: e.FilePath, Line: e.Line, }) } return s.gcxResponseWithBudget(req)(encodeAnalyze("dead_code", items)) } if isCompact(req) { var b strings.Builder for _, e := range entries { fmt.Fprintf(&b, "%s %s %s:%d\n", e.Kind, e.ID, e.FilePath, e.Line) } if len(entries) == 0 { b.WriteString("no dead code found\n") } if variablesNote != "" { fmt.Fprintf(&b, "\nnote: %s\n", variablesNote) } return mcp.NewToolResultText(b.String()), nil } result := map[string]any{ "dead_code": entries, "total": totalEntries, "truncated": deadTruncated, } if deadTruncated { result["limit"] = limit } if variablesNote != "" { result["note"] = variablesNote } return s.respondJSONOrTOON(ctx, req, result) } // buildDeadCodeNote summarises which low-signal kinds the analyzer // dropped by default, so callers know which include_* flag to flip // if they want to broaden the scan. Returns the empty string when // every opt-in flag is already set. func buildDeadCodeNote(opts analysis.FindDeadCodeOptions) string { var off []string if !opts.IncludeVariables { off = append(off, "variables (include_variables=true)") } if !opts.IncludeFields { off = append(off, "fields (include_fields=true)") } if !opts.IncludeConstants { off = append(off, "constants (include_constants=true)") } if len(off) == 0 { return "" } return "Excluded by default — graph lacks intra-function data flow, so these always look dead: " + strings.Join(off, ", ") + "." } func (s *Server) handleFindHotspots(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Check minimum graph size if s.graph.NodeCount() < 10 { return mcp.NewToolResultError("codebase too small for meaningful hotspot analysis (need at least 10 symbols)"), nil } threshold := 0.0 if v, ok := req.GetArguments()["threshold"].(float64); ok { threshold = v } var entries []analysis.HotspotEntry if threshold == 0 { entries = s.getHotspots() } else { entries = analysis.FindHotspots(s.graph, s.getCommunities(), threshold) } // K17: optional novelty / directional reranking modes. Default // "complexity" preserves the legacy ranking. mode := strings.TrimSpace(stringArg(req.GetArguments(), "mode")) if mode == "" { mode = "complexity" } if mode != "complexity" { windowDays := 30 if v, ok := req.GetArguments()["window_days"].(float64); ok && v > 0 { windowDays = int(v) } direction := strings.TrimSpace(stringArg(req.GetArguments(), "direction")) if direction == "" { direction = "adds" } entries = rerankHotspots(entries, s.graph, mode, direction, windowDays) } // hotspots reads s.graph directly, bypassing the scoped-node // accessors, so narrow its rows to the session workspace + optional // repo allow-set here (also closing the latent cross-workspace leak). // Strict no-op for an unbound session with no RepoAllow. if s.scopeFiltersActive(ctx) { kept := make([]analysis.HotspotEntry, 0, len(entries)) for _, e := range entries { if s.analyzeNodeVisible(ctx, s.graph.GetNode(e.ID)) { kept = append(kept, e) } } entries = kept } // Truncate to top 20 totalCount := len(entries) truncated := false if len(entries) > 20 { entries = entries[:20] truncated = true } if s.isGCX(ctx, req) { items := make([]hotspotItem, 0, len(entries)) for _, e := range entries { items = append(items, hotspotItem{ ID: e.ID, Name: e.Name, Path: e.FilePath, Line: e.Line, FanIn: e.FanIn, FanOut: e.FanOut, CrossCommunity: e.CommunityCrossings, Betweenness: e.Betweenness, Score: e.ComplexityScore, }) } return s.gcxResponseWithBudget(req)(encodeAnalyze("hotspots", items)) } if isCompact(req) { var b strings.Builder for _, e := range entries { fmt.Fprintf(&b, "%s %s %s:%d score=%.1f fan_in=%d fan_out=%d crossings=%d betweenness=%.1f\n", e.Kind, e.ID, e.FilePath, e.Line, e.ComplexityScore, e.FanIn, e.FanOut, e.CommunityCrossings, e.Betweenness) } if truncated { fmt.Fprintf(&b, "... truncated (%d total)\n", totalCount) } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "hotspots": entries, "total": totalCount, "truncated": truncated, }) } // --------------------------------------------------------------------------- // 10.6 handleScaffold // --------------------------------------------------------------------------- // scaffoldReader bridges Server to analysis.SourceReader so scaffolding can // resolve file paths through the multi-repo aware Server.resolveGraphPath // instead of relying on a single Indexer.RootPath which is empty in // multi-repo mode. type scaffoldReader struct{ s *Server } func (r scaffoldReader) Graph() graph.Store { return r.s.graph } func (r scaffoldReader) ResolveFilePath(graphPath string) string { abs, err := r.s.resolveGraphPath(graphPath) if err != nil { return "" } return abs } func (s *Server) handleScaffold(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { exampleID, err := req.RequireString("id") 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 } // dry_run defaults to true (scaffold never writes by default) dryRun := true if v, ok := req.GetArguments()["dry_run"].(bool); ok { dryRun = v } result, err := analysis.GenerateScaffold(s.engine, scaffoldReader{s}, exampleID, newName) if err != nil { return mcp.NewToolResultError(err.Error()), nil } resp := map[string]any{ "edits": result.Edits, "notes": result.Notes, "dry_run": dryRun, } if !dryRun && s.indexer != nil { // Apply edits by writing files for _, edit := range result.Edits { absPath := edit.FilePath if root := s.indexer.RootPath(); root != "" { absPath = filepath.Join(root, edit.FilePath) } content, readErr := os.ReadFile(absPath) if readErr != nil { return mcp.NewToolResultError(fmt.Sprintf("could not read %s: %v", edit.FilePath, readErr)), nil } lines := strings.Split(string(content), "\n") insertIdx := max(edit.InsertionLine-1, 0) insertIdx = min(insertIdx, len(lines)) newLines := make([]string, 0, len(lines)+strings.Count(edit.Code, "\n")+2) newLines = append(newLines, lines[:insertIdx]...) newLines = append(newLines, "") newLines = append(newLines, edit.Code) newLines = append(newLines, lines[insertIdx:]...) if writeErr := os.WriteFile(absPath, []byte(strings.Join(newLines, "\n")), 0o644); writeErr != nil { return mcp.NewToolResultError(fmt.Sprintf("could not write %s: %v", edit.FilePath, writeErr)), nil } } resp["applied"] = true } return s.respondJSONOrTOON(ctx, req, resp) } // --------------------------------------------------------------------------- // 10.7 handleFindCycles and handleWouldCreateCycle // --------------------------------------------------------------------------- // cycleVisible reports whether every node on a cycle's path is visible // to the current request (workspace ceiling + optional repo allow-set). // A cycle is surfaced only when it is entirely in scope, so a chain that // crosses the boundary is dropped rather than leaking its out-of-scope // members. func (s *Server) cycleVisible(ctx context.Context, c analysis.Cycle) bool { for _, id := range c.Path { if !s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) { return false } } return true } func (s *Server) handleFindCycles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { scope := req.GetString("scope", "") cycles := analysis.DetectCycles(s.graph, s.getCommunities(), scope) // cycles reads s.graph directly, bypassing the scoped-node accessors, // so narrow here to the session workspace + optional repo allow-set. // A cycle is kept only when EVERY node on its path is visible, so a // chain that crosses the boundary is dropped rather than leaking its // cross-repo / cross-workspace members. Strict no-op for an unbound // session with no RepoAllow. Runs before the GCX / empty / total // blocks so all of them observe the filtered set. if s.scopeFiltersActive(ctx) { kept := make([]analysis.Cycle, 0, len(cycles)) for _, c := range cycles { if s.cycleVisible(ctx, c) { kept = append(kept, c) } } cycles = kept } if s.isGCX(ctx, req) { items := make([]cycleItem, 0, len(cycles)) for _, c := range cycles { items = append(items, cycleItem{ Size: len(c.Path), Severity: c.Kind, Nodes: c.Path, }) } return s.gcxResponseWithBudget(req)(encodeAnalyze("cycles", items)) } if len(cycles) == 0 { return s.respondJSONOrTOON(ctx, req, map[string]any{ "cycles": []any{}, "message": "no dependency cycles detected", }) } // Truncate to 20 highest-severity (already sorted by severity desc) totalCount := len(cycles) truncated := false if len(cycles) > 20 { cycles = cycles[:20] truncated = true } if isCompact(req) { var b strings.Builder for _, c := range cycles { fmt.Fprintf(&b, "%s severity=%d %s\n", c.Kind, c.Severity, strings.Join(c.Path, " → ")) } if truncated { fmt.Fprintf(&b, "... truncated (%d total)\n", totalCount) } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "cycles": cycles, "total": totalCount, "truncated": truncated, }) } func (s *Server) handleWouldCreateCycle(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { fromID, err := req.RequireString("from_id") if err != nil { return mcp.NewToolResultError("from_id is required"), nil } toID, err := req.RequireString("to_id") if err != nil { return mcp.NewToolResultError("to_id is required"), nil } // Validate both symbols exist if s.graph.GetNode(fromID) == nil { return mcp.NewToolResultError("symbol not found: " + fromID), nil } if s.graph.GetNode(toID) == nil { return mcp.NewToolResultError("symbol not found: " + toID), nil } wouldCycle, path := analysis.WouldCreateCycle(s.graph, fromID, toID) if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeAnalyze("would_create_cycle", map[string]any{ "would_cycle": wouldCycle, "path": path, })) } if isCompact(req) { if wouldCycle { return mcp.NewToolResultText(fmt.Sprintf("would_cycle=true %s\n", strings.Join(path, " → "))), nil } return mcp.NewToolResultText("would_cycle=false\n"), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "would_cycle": wouldCycle, "path": path, }) } // --------------------------------------------------------------------------- // 10.8 handleDiffContext // --------------------------------------------------------------------------- // diffFileGroup groups changed symbols by file with risk assessment. type diffFileGroup struct { FilePath string `json:"file_path"` Risk string `json:"risk"` Symbols []diffSymbolInfo `json:"symbols"` } // diffSymbolInfo holds enriched context for a single changed symbol. type diffSymbolInfo struct { ID string `json:"id"` Name string `json:"name"` Kind string `json:"kind"` StartLine int `json:"start_line"` Signature string `json:"signature,omitempty"` Source string `json:"source,omitempty"` Callers []string `json:"callers,omitempty"` Callees []string `json:"callees,omitempty"` Community string `json:"community,omitempty"` Processes []string `json:"processes,omitempty"` } func (s *Server) handleDiffContext(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { scope := req.GetString("scope", "unstaged") baseRef := req.GetString("base_ref", "main") // Resolve the working tree: explicit repo selector, lone tracked repo, // or the session's cwd-bound repo. The "." fallback keeps the standalone // (indexer-less) server working from its own cwd. repoRoot, repoPrefix := s.diffRepoScope(ctx, strings.TrimSpace(req.GetString("repo", ""))) if repoRoot == "" { repoRoot = "." } diff, err := analysis.MapGitDiff(s.graph, repoRoot, repoPrefix, scope, baseRef) if err != nil { return mcp.NewToolResultError(err.Error()), nil } if len(diff.ChangedSymbols) == 0 { return s.respondJSONOrTOON(ctx, req, map[string]any{ "files": []any{}, "message": "no changes detected", }) } communities := s.getCommunities() processes := s.getProcesses() // Build enriched symbol info var allSymbols []diffSymbolInfo for _, cs := range diff.ChangedSymbols { node := s.graph.GetNode(cs.ID) if node == nil { continue } info := diffSymbolInfo{ ID: cs.ID, Name: cs.Name, Kind: cs.Kind, StartLine: cs.Line, } // Signature if sig, ok := node.Meta["signature"].(string); ok { info.Signature = sig } // Source if node.StartLine > 0 && node.EndLine > 0 { if absPath, err := s.resolveNodePath(node); err == nil { if source, _, _, readErr := readLines(absPath, node.StartLine, node.EndLine, 0); readErr == nil { info.Source = source } } } // Callers (depth 1) callers := s.engineFor(ctx).GetCallers(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) for _, cn := range callers.Nodes { if cn.ID != cs.ID { info.Callers = append(info.Callers, cn.ID) } } // Callees (depth 1) callees := s.engineFor(ctx).GetCallChain(cs.ID, query.QueryOptions{Depth: 1, Limit: 10, Detail: "brief"}) for _, cn := range callees.Nodes { if cn.ID != cs.ID { info.Callees = append(info.Callees, cn.ID) } } // Community if communities != nil { if cid, ok := communities.NodeToComm[cs.ID]; ok { info.Community = cid } } // Processes if processes != nil { info.Processes = processes.NodeToProcs[cs.ID] } allSymbols = append(allSymbols, info) } // Group by file fileMap := make(map[string][]diffSymbolInfo) for _, sym := range allSymbols { fp := "" if n := s.graph.GetNode(sym.ID); n != nil { fp = n.FilePath } if fp == "" { continue } fileMap[fp] = append(fileMap[fp], sym) } // Compute per-file risk var groups []diffFileGroup for fp, syms := range fileMap { // Compute risk based on blast radius of symbols in this file symbolIDs := make([]string, len(syms)) for i, sym := range syms { symbolIDs[i] = sym.ID } impact := analysis.AnalyzeImpact(s.graph, symbolIDs, communities, processes) groups = append(groups, diffFileGroup{ FilePath: fp, Risk: string(impact.Risk), Symbols: syms, }) } // Sort by risk (CRITICAL > HIGH > MEDIUM > LOW) riskOrder := map[string]int{"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3} sort.Slice(groups, func(i, j int) bool { ri := riskOrder[groups[i].Risk] rj := riskOrder[groups[j].Risk] if ri != rj { return ri < rj } return groups[i].FilePath < groups[j].FilePath }) // Truncate to 50 symbols total totalSymbols := 0 for _, g := range groups { totalSymbols += len(g.Symbols) } truncated := false if totalSymbols > 50 { truncated = true count := 0 var truncGroups []diffFileGroup for _, g := range groups { if count >= 50 { break } remaining := 50 - count if len(g.Symbols) > remaining { g.Symbols = g.Symbols[:remaining] } truncGroups = append(truncGroups, g) count += len(g.Symbols) } groups = truncGroups } if isCompact(req) { var b strings.Builder for _, g := range groups { for _, sym := range g.Symbols { fmt.Fprintf(&b, "%s %s %s callers=%d callees=%d\n", sym.ID, sym.Kind, g.Risk, len(sym.Callers), len(sym.Callees)) } } if truncated { fmt.Fprintf(&b, "... truncated (%d total symbols)\n", totalSymbols) } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "files": groups, "total_symbols": totalSymbols, "total_files": len(groups), "truncated": truncated, }) } // --------------------------------------------------------------------------- // 10.9 handleIndexHealth // --------------------------------------------------------------------------- func (s *Server) handleIndexHealth(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { if s.indexer == nil { return mcp.NewToolResultError("no indexer available"), nil } result := s.buildIndexHealthPayload() if isCompact(req) { stale, _ := result["stale_files"].([]string) failures, _ := result["parse_failures"].(map[string]string) line := fmt.Sprintf("health=%.1f%% nodes=%d stale=%d failures=%d", result["health_score"], result["node_count"], len(stale), len(failures)) if _, ok := result["recommendation"]; ok { line += " [needs re-index]" } return mcp.NewToolResultText(line + "\n"), nil } return s.respondJSONOrTOON(ctx, req, result) } // buildIndexHealthPayload returns the same data the `index_health` // tool emits. Shared with the `gortex://index-health` resource. // Returns nil when no indexer is wired. func (s *Server) buildIndexHealthPayload() map[string]any { if s.indexer == nil { return nil } totalDetected := s.indexer.TotalDetected() parseErrors := s.indexer.ParseErrors() // When totalDetected is 0 (e.g., graph restored from cache without a full re-index), // fall back to counting file nodes in the graph. if totalDetected == 0 { stats := s.graph.Stats() if fileCount, ok := stats.ByKind[string(graph.KindFile)]; ok { totalDetected = fileCount } } successfullyIndexed := max(totalDetected-len(parseErrors), 0) var healthScore float64 if totalDetected > 0 { healthScore = math.Round(float64(successfullyIndexed)/float64(totalDetected)*1000) / 10 } var staleFiles []string mtimes := s.indexer.FileMtimes() for relPath := range mtimes { if s.indexer.IsStale(relPath) { staleFiles = append(staleFiles, relPath) } } stats := s.graph.Stats() langCoverage := make(map[string]bool) for lang := range stats.ByLanguage { langCoverage[lang] = true } // Skip rollup: count synthetic file nodes by skip_reason so an agent // can see WHY a file is missing from the graph (size / timeout / // minified / parse_failed / parse_panic) instead of guessing. skipped := map[string]int{} repoPrefixes := map[string]struct{}{} for n := range s.graph.NodesByKind(graph.KindFile) { if n == nil { continue } repoPrefixes[n.RepoPrefix] = struct{}{} if n.Meta == nil { continue } if reason, _ := n.Meta["skip_reason"].(string); reason != "" { skipped[reason]++ } } // Per-file rollup from the files sidecar (when the backend records it): // the files that failed to parse, with their error locations + node // counts. Bounded so a pathological repo can't bloat the payload. filesWithErrors, indexedFileCount := s.buildIndexHealthFileRollup(repoPrefixes) // Density plausibility: even a trivial source file yields a file node // plus at least one symbol, so a populated graph averaging barely more // than one node per file means extraction produced little beyond the // file shells — a broken grammar or an aborted reindex. A soft warning, // never a reject. var nodesPerFile float64 fileNodes := stats.ByKind[string(graph.KindFile)] if fileNodes > 0 { nodesPerFile = math.Round(float64(stats.TotalNodes)/float64(fileNodes)*100) / 100 } densityDegenerate := fileNodes >= 5 && nodesPerFile > 0 && nodesPerFile < 1.2 lastIndexTime := s.indexer.LastIndexTime() lastIndexStr := "" if !lastIndexTime.IsZero() { lastIndexStr = lastIndexTime.Format("2006-01-02T15:04:05Z07:00") } var recommendation string if healthScore < 80 { recommendation = "Health score below 80%. Run index_repository with path \".\" to re-index the codebase." } // Edgeless-index sanity check: a populated graph with files and // symbol nodes but zero edges means edge extraction failed // wholesale (a broken grammar, an aborted reindex). Even a single // one-function file yields containment edges, so this only trips on // a real regression. edgesOK := totalDetected <= 0 || stats.TotalNodes <= 0 || stats.TotalEdges != 0 if !edgesOK { msg := "Index has files and symbol nodes but zero edges — edge extraction failed. Re-index with index_repository path \".\"; if it persists the language grammar may be broken." if recommendation == "" { recommendation = msg } else { recommendation = msg + " " + recommendation } } if densityDegenerate { msg := "Index has files but almost no symbol nodes (nodes_per_file < 1.2) — extraction produced little beyond file shells. Re-index with index_repository path \".\"; if it persists the language grammar may be broken or the files unsupported." if recommendation == "" { recommendation = msg } else { recommendation = msg + " " + recommendation } } // Semantic-enrichment lifecycle per (repo, provider): a graph can be // 100% parsed yet carry zero lsp_resolved edges when the enrichment // pass was cut (partial) or discarded (abandoned) at its deadline. // Surfacing the state here is what lets an agent — or a benchmark — // distinguish "fully enriched" from "looks green, tiers missing". var enrichStatuses []semantic.EnrichmentStatus if s.semanticMgr != nil { enrichStatuses = s.semanticMgr.EnrichmentStatuses() } enrichmentIncomplete := false for _, st := range enrichStatuses { if st.State == semantic.EnrichStatePartial || st.State == semantic.EnrichStateAbandoned || st.State == semantic.EnrichStateFailed { enrichmentIncomplete = true break } } if enrichmentIncomplete { msg := "Semantic enrichment did not complete for at least one repo/provider (see semantic_enrichment) — LSP-tier edges may be missing and tier-filtered queries (e.g. find_usages min_tier=lsp_resolved) may under-report. Re-run enrichment (reindex_repository) or raise GORTEX_LSP_ENRICH_TIMEOUT." if recommendation == "" { recommendation = msg } else { recommendation = msg + " " + recommendation } } // Compile-database-degraded providers: a clangd pass with no // compile_commands.json intentionally runs reference confirmation only (no // hover / hierarchy sweep). This is not a failure — semantic_enrichment_ok // stays true — but the missing tiers are worth flagging with the remediation. var degradedProviders []string for _, st := range enrichStatuses { if st.Degraded { degradedProviders = append(degradedProviders, st.Provider+" in "+st.Repo) } } if len(degradedProviders) > 0 { msg := "Semantic enrichment ran in degraded (reference-confirmation-only) mode for " + strings.Join(degradedProviders, ", ") + " because no compilation database was found — hover types and call/type-hierarchy edges were skipped. Generate compile_commands.json (cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON, bear -- make, or meson) at the repo root, then reindex_repository." if recommendation == "" { recommendation = msg } else { recommendation = msg + " " + recommendation } } result := map[string]any{ "health_score": healthScore, "total_detected": totalDetected, "successfully_indexed": successfullyIndexed, "language_coverage": langCoverage, "last_index_time": lastIndexStr, "node_count": stats.TotalNodes, "edge_count": stats.TotalEdges, "edges_ok": edgesOK, "nodes_per_file": nodesPerFile, // Shape-degradation guard firings since process start. Nonzero means // the daemon caught (and self-healed) a live-patch or boot-reload // resolution regression rather than silently serving a shrunken graph. "resolution_regressions": indexer.ResolutionRegressions(), } // Tool-surface state: the active global preset (the per-session default // may differ by client) and the per-workspace learned surface size, so // the tool policy is inspectable without a separate call. preset, presetMode := s.ActivePreset() result["tool_preset"] = preset result["tool_preset_mode"] = presetMode if n := s.LearnedToolCount(); n > 0 { result["learned_tools"] = n result["learned_tool_names"] = s.LearnedToolNames() } if indexedFileCount > 0 { result["indexed_file_count"] = indexedFileCount } if len(filesWithErrors) > 0 { result["files_with_parse_errors"] = filesWithErrors } if len(skipped) > 0 { result["skipped"] = skipped } if len(parseErrors) > 0 { result["parse_failures"] = parseErrors } if len(staleFiles) > 0 { result["stale_files"] = staleFiles } if len(enrichStatuses) > 0 { result["semantic_enrichment"] = enrichStatuses result["semantic_enrichment_ok"] = !enrichmentIncomplete // Per-language lsp-tier edge rollup from the enrichment statuses — a // cheap, graph-pass-free signal for whether min_tier=lsp_resolved is // usable on a given language. Near-zero for a language means // tier-filtered queries will under-report (the find_usages // tier_filtered caveat explains it per query; this shows it up front). lspEdgesByLang := map[string]int{} for _, st := range enrichStatuses { if st.Language == "" { continue } lspEdgesByLang[st.Language] += st.EdgesAdded + st.EdgesConfirmed } if len(lspEdgesByLang) > 0 { result["lsp_resolved_edges_by_language"] = lspEdgesByLang } } if recommendation != "" { result["recommendation"] = recommendation } return result } // buildIndexHealthFileRollup reads the per-file metadata sidecar (when the // backend implements graph.FileMetaReader) across the supplied repo prefixes // and returns the files that recorded parse errors — each with its error // locations + node count — plus the total indexed-file count. The error list // is bounded so a badly-broken repo can't bloat the index_health payload. func (s *Server) buildIndexHealthFileRollup(repoPrefixes map[string]struct{}) (filesWithErrors []map[string]any, indexedFileCount int) { reader, ok := s.graph.(graph.FileMetaReader) if !ok { return nil, 0 } const maxErrorFiles = 100 for prefix := range repoPrefixes { rows, err := reader.FileMetasForRepo(prefix) if err != nil { continue } indexedFileCount += len(rows) for _, r := range rows { if r.Errors == "" || len(filesWithErrors) >= maxErrorFiles { continue } entry := map[string]any{ "file": r.FilePath, "node_count": r.NodeCount, } var locs []string if json.Unmarshal([]byte(r.Errors), &locs) == nil && len(locs) > 0 { entry["errors"] = locs } filesWithErrors = append(filesWithErrors, entry) } } return filesWithErrors, indexedFileCount } // --------------------------------------------------------------------------- // 10.10 handleGetSymbolHistory // --------------------------------------------------------------------------- func (s *Server) handleGetSymbolHistory(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { symbolID := req.GetString("id", "") symbolID = s.resolveSymbolID(ctx, symbolID) if symbolID != "" { // Single symbol history mods := s.symHistory.Get(symbolID) if len(mods) == 0 { return s.respondJSONOrTOON(ctx, req, map[string]any{ "symbol_id": symbolID, "modifications": []any{}, "message": "no modifications recorded for this symbol", }) } churning := len(mods) >= 3 if isCompact(req) { churnFlag := "" if churning { churnFlag = " [churning]" } return mcp.NewToolResultText(fmt.Sprintf("%s count=%d%s\n", symbolID, len(mods), churnFlag)), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "symbol_id": symbolID, "count": len(mods), "modifications": mods, "churning": churning, }) } // All symbols, sorted by count descending all := s.symHistory.All() if len(all) == 0 { return s.respondJSONOrTOON(ctx, req, map[string]any{ "symbols": []any{}, "message": "no modifications recorded this session", }) } type symbolEntry struct { SymbolID string `json:"symbol_id"` Count int `json:"count"` Churning bool `json:"churning"` Modifications []SymbolModification `json:"modifications"` } var entries []symbolEntry for id, mods := range all { entries = append(entries, symbolEntry{ SymbolID: id, Count: len(mods), Churning: len(mods) >= 3, Modifications: mods, }) } // Sort by count descending sort.Slice(entries, func(i, j int) bool { return entries[i].Count > entries[j].Count }) if isCompact(req) { var b strings.Builder for _, e := range entries { churnFlag := "" if e.Churning { churnFlag = " [churning]" } fmt.Fprintf(&b, "%s count=%d%s\n", e.SymbolID, e.Count, churnFlag) } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "symbols": entries, "total": len(entries), }) } // --------------------------------------------------------------------------- // 10.11 handleBatchEdit // --------------------------------------------------------------------------- // batchEditItem represents a single edit in a batch. // batchEditItem is one operation in a batch_edit call. It is a discriminated // union over `op`: an edit_symbol op carries {id, old_source, new_source}; an // edit_file op carries {path, old_string, new_string, replace_all?}. When `op` // is omitted it is inferred (edit_file when a path is present, else edit_symbol) // so the legacy homogeneous {id, old_source, new_source} payload still works. type batchEditItem struct { Op string `json:"op,omitempty"` // edit_symbol SymbolID string `json:"id,omitempty"` OldSource string `json:"old_source,omitempty"` NewSource string `json:"new_source,omitempty"` // edit_file Path string `json:"path,omitempty"` OldString string `json:"old_string,omitempty"` NewString string `json:"new_string,omitempty"` ReplaceAll bool `json:"replace_all,omitempty"` } // kind resolves the operation kind for an item: an explicit, recognised `op` // wins; otherwise edit_file is inferred when a path is present, else // edit_symbol. func (it batchEditItem) kind() string { switch it.Op { case "edit_file", "edit_symbol": return it.Op } if it.Path != "" { return "edit_file" } return "edit_symbol" } // batchEditResult represents the outcome of a single edit in the batch. type batchEditResult struct { Op string `json:"op,omitempty"` SymbolID string `json:"id,omitempty"` FilePath string `json:"path"` Status string `json:"status"` // "applied", "failed", "skipped" Error string `json:"error,omitempty"` // EOLNormalized is true when the fragment only matched through the // CRLF<->LF-tolerant fallback and the replacement was written with the // file's own line terminators. EOLNormalized bool `json:"eol_normalized,omitempty"` } // batchEditItemsSchema is the JSON Schema for one batch_edit item: a // discriminated union over the `op` field. Declaring the branches as a oneOf // gives the model per-operation field validation instead of a single // permissive, stringly-typed payload — the dominant source of malformed // batch_edit calls. func batchEditItemsSchema() map[string]any { return map[string]any{ "oneOf": []any{ map[string]any{ "type": "object", "description": "Replace a fragment inside a symbol's body.", "properties": map[string]any{ "op": map[string]any{"const": "edit_symbol", "description": "Operation kind (optional; inferred as edit_symbol when omitted and `id` is present)."}, "id": map[string]any{"type": "string", "description": "Symbol ID, e.g. pkg/foo.go::Bar."}, "old_source": map[string]any{"type": "string", "description": "Exact fragment to replace within the symbol's source. CRLF/LF line-ending differences against the file are tolerated."}, "new_source": map[string]any{"type": "string", "description": "Replacement fragment."}, }, "required": []any{"id", "old_source", "new_source"}, }, map[string]any{ "type": "object", "description": "Replace a string in any file (imports, config, comments — non-symbol edits).", "properties": map[string]any{ "op": map[string]any{"const": "edit_file", "description": "Operation kind. Required to select a file edit."}, "path": map[string]any{"type": "string", "description": "File path (repo-relative or absolute)."}, "old_string": map[string]any{"type": "string", "description": "Exact text to replace; must be unique unless replace_all is set. CRLF/LF line-ending differences against the file are tolerated."}, "new_string": map[string]any{"type": "string", "description": "Replacement text."}, "replace_all": map[string]any{"type": "boolean", "description": "Replace every occurrence instead of requiring uniqueness."}, }, "required": []any{"op", "path", "old_string", "new_string"}, }, }, } } // parseBatchEdits accepts the `edits` argument as either a structured JSON // array (the typed-schema path) or a JSON-encoded string of the same array // (the legacy path), and decodes it into batch edit items. func parseBatchEdits(raw any) ([]batchEditItem, error) { var data []byte switch v := raw.(type) { case nil: return nil, fmt.Errorf("edits is required") case string: data = []byte(v) default: b, err := json.Marshal(v) if err != nil { return nil, fmt.Errorf("invalid edits: %v", err) } data = b } var edits []batchEditItem if err := json.Unmarshal(data, &edits); err != nil { return nil, fmt.Errorf("invalid edits JSON: %v", err) } return edits, nil } func (s *Server) handleBatchEdit(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { edits, perr := parseBatchEdits(req.GetArguments()["edits"]) if perr != nil { return mcp.NewToolResultError(perr.Error()), nil } if len(edits) == 0 { return mcp.NewToolResultError("edits array is empty"), nil } dryRun := false if v, ok := req.GetArguments()["dry_run"].(bool); ok { dryRun = v } // Sort edits in dependency order using get_edit_plan logic: // Definitions first, then implementations, then callers. type editWithOrder struct { edit batchEditItem op string order int file string idx int } var ordered []editWithOrder for i, edit := range edits { op := edit.kind() if op == "edit_file" { // File edits carry no graph dependency; apply them after all // symbol edits (order 1000), preserving input order via idx. ordered = append(ordered, editWithOrder{edit: edit, op: op, order: 1000, file: edit.Path, idx: i}) continue } node := s.engineFor(ctx).GetSymbol(edit.SymbolID) order := 50 // default middle priority filePath := "" if node != nil { filePath = node.FilePath // Definitions/interfaces get lowest order (edited first) if node.Kind == graph.KindInterface || node.Kind == graph.KindType { order = 0 } else if node.Kind == graph.KindFunction || node.Kind == graph.KindMethod { // Check if this symbol is depended on by other edits for _, other := range edits { if other.SymbolID == edit.SymbolID { continue } // Check if other calls this symbol callers := s.engineFor(ctx).GetCallers(edit.SymbolID, query.QueryOptions{Depth: 1, Limit: 100, Detail: "brief"}) for _, cn := range callers.Nodes { if cn.ID == other.SymbolID { order = 10 // this is a dependency — edit first break } } } if order == 50 { order = 20 // regular function } } } ordered = append(ordered, editWithOrder{edit: edit, op: op, order: order, file: filePath, idx: i}) } // Sort by order ascending (lowest = edit first), tie-broken by file then // by original index so same-bucket edits stay in a deterministic order. sort.SliceStable(ordered, func(i, j int) bool { if ordered[i].order != ordered[j].order { return ordered[i].order < ordered[j].order } if ordered[i].file != ordered[j].file { return ordered[i].file < ordered[j].file } return ordered[i].idx < ordered[j].idx }) if dryRun { var plan []map[string]any for i, o := range ordered { entry := map[string]any{ "order": i + 1, "op": o.op, "id": o.edit.SymbolID, "path": o.file, "status": "planned", } plan = append(plan, entry) } if isCompact(req) { var b strings.Builder for _, p := range plan { fmt.Fprintf(&b, "%s %s planned\n", p["id"], p["path"]) } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "plan": plan, "dry_run": true, "total": len(plan), }) } // Apply edits sequentially, dispatching per operation kind. Stop on the // first failure and mark the remainder skipped. var results []batchEditResult failed := false for _, o := range ordered { if failed { results = append(results, batchEditResult{ Op: o.op, SymbolID: o.edit.SymbolID, FilePath: o.file, Status: "skipped", }) continue } var r batchEditResult switch o.op { case "edit_file": r = s.applyBatchFileEdit(o.edit) default: r = s.applyBatchSymbolEdit(ctx, o.edit) } results = append(results, r) if r.Status == "failed" { failed = true } } if isCompact(req) { var b strings.Builder for _, r := range results { target := r.SymbolID if target == "" { target = r.FilePath } fmt.Fprintf(&b, "%s %s %s\n", r.Op, target, r.Status) } return mcp.NewToolResultText(b.String()), nil } // Count statuses applied, failedCount, skipped := 0, 0, 0 for _, r := range results { switch r.Status { case "applied": applied++ case "failed": failedCount++ case "skipped": skipped++ } } return s.respondJSONOrTOON(ctx, req, map[string]any{ "results": results, "summary": map[string]int{ "applied": applied, "failed": failedCount, "skipped": skipped, "total": len(results), }, }) } // applyBatchSymbolEdit applies one edit_symbol operation: it locates the // symbol's source range, replaces old_source with new_source inside it, writes // the file, and re-indexes. Semantics match the legacy single-op batch_edit. func (s *Server) applyBatchSymbolEdit(ctx context.Context, edit batchEditItem) batchEditResult { res := batchEditResult{Op: "edit_symbol", SymbolID: edit.SymbolID} if edit.OldSource == edit.NewSource { res.Status, res.Error = "failed", "old_source and new_source are identical" return res } node := s.engineFor(ctx).GetSymbol(edit.SymbolID) if node == nil { res.Status, res.Error = "failed", "symbol not found: "+edit.SymbolID return res } res.FilePath = node.FilePath if node.StartLine == 0 || node.EndLine == 0 { res.Status, res.Error = "failed", "symbol has no line range" return res } absPath, resolveErr := s.resolveNodePath(node) if resolveErr != nil { res.Status, res.Error = "failed", resolveErr.Error() return res } content, readErr := os.ReadFile(absPath) if readErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) return res } fileStr := string(content) lines := strings.Split(fileStr, "\n") if node.StartLine > len(lines) || node.EndLine > len(lines) { res.Status, res.Error = "failed", "symbol line range exceeds file length" return res } symbolSource := strings.Join(lines[node.StartLine-1:node.EndLine], "\n") effectiveStart := node.StartLine if findEOLMatches(symbolSource, edit.OldSource).count == 0 { // Expand the window upward over preceding doc comments and blank // lines — mirrors handleEditSymbol (agents often include the doc // comment because get_symbol_source returns context 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 { expanded := strings.Join(lines[expandedStart:node.EndLine], "\n") if findEOLMatches(expanded, edit.OldSource).count > 0 { symbolSource = expanded effectiveStart = expandedStart + 1 } } } if findEOLMatches(symbolSource, edit.OldSource).count == 0 { res.Status, res.Error = "failed", "old_source not found within symbol" return res } symbolStart := 0 for i := 0; i < effectiveStart-1 && i < len(lines); i++ { symbolStart += len(lines[i]) + 1 } symbolEnd := min(symbolStart+len(symbolSource), len(fileStr)) // EOL-tolerant region match: 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], edit.OldSource) if len(regionMatches.spans) == 0 { res.Status, res.Error = "failed", "old_source not found in symbol region" return res } span := regionMatches.spans[0] editStart := symbolStart + span.start editEnd := symbolStart + span.end effectiveNew := edit.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(edit.NewSource, fileStr[editStart:editEnd]) res.EOLNormalized = true } newContent := fileStr[:editStart] + effectiveNew + fileStr[editEnd:] if regionMatches.normalized && newContent == fileStr { res.Status, res.Error = "failed", "old_source and new_source are identical after line-ending normalization" return res } if writeErr := os.WriteFile(absPath, []byte(newContent), 0o644); writeErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) return res } if s.indexer != nil { _ = s.indexer.IndexFile(absPath) } res.Status = "applied" return res } // applyBatchFileEdit applies one edit_file operation: it replaces old_string // with new_string in the file at path, mirroring edit_file's uniqueness and // replace_all semantics, then re-indexes. func (s *Server) applyBatchFileEdit(edit batchEditItem) batchEditResult { res := batchEditResult{Op: "edit_file", FilePath: edit.Path} if edit.Path == "" { res.Status, res.Error = "failed", "edit_file op requires path" return res } if edit.OldString == edit.NewString { res.Status, res.Error = "failed", "old_string and new_string are identical" return res } absPath, relPath, resolveErr := s.resolveFilePath(edit.Path) if resolveErr != nil { res.Status, res.Error = "failed", resolveErr.Error() return res } res.FilePath = relPath content, readErr := os.ReadFile(absPath) if readErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not read file: %v", readErr) return res } fileStr := string(content) matches := findEOLMatches(fileStr, edit.OldString) count := matches.count if count == 0 { res.Status, res.Error = "failed", "old_string not found in file" return res } if count > 1 && !edit.ReplaceAll { res.Status, res.Error = "failed", fmt.Sprintf( "old_string matches %d locations%s. Provide a larger fragment for uniqueness or set replace_all=true.", count, matchSpansHint(fileStr, matches.spans)) return res } var newContent string switch { case matches.normalized: // The CRLF<->LF fallback matched: splice the real byte spans and // write new_string with each region's own line terminators so the // edit never introduces mixed endings. limit := 1 if edit.ReplaceAll { limit = -1 } newContent = spliceSpansEOL(fileStr, matches.spans, edit.NewString, limit) if newContent == fileStr { res.Status, res.Error = "failed", "old_string and new_string are identical after line-ending normalization" return res } res.EOLNormalized = true case edit.ReplaceAll: newContent = strings.ReplaceAll(fileStr, edit.OldString, edit.NewString) default: newContent = strings.Replace(fileStr, edit.OldString, edit.NewString, 1) } perm := os.FileMode(0o644) if info, e := os.Stat(absPath); e == nil { perm = info.Mode().Perm() } if writeErr := os.WriteFile(absPath, []byte(newContent), perm); writeErr != nil { res.Status, res.Error = "failed", fmt.Sprintf("could not write file: %v", writeErr) return res } s.reindexFile(absPath) res.Status = "applied" return res } // --------------------------------------------------------------------------- // handleContracts — unified dispatcher for contracts (replaces 2 tools) // --------------------------------------------------------------------------- func (s *Server) handleContracts(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { action := req.GetString("action", "list") switch action { case "list", "": return s.handleGetContracts(ctx, req) case "check": return s.handleCheckContracts(ctx, req) case "validate": return s.handleValidateContracts(ctx, req) case "bridge": return s.handleContractBridges(ctx, req) default: return mcp.NewToolResultError("unknown contracts action: " + action + " (expected: list, check, validate, or bridge)"), nil } } // --------------------------------------------------------------------------- // handleGetContracts // --------------------------------------------------------------------------- func (s *Server) handleGetContracts(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { registry := s.effectiveContractRegistry() if registry == nil { return mcp.NewToolResultError("no contract registry available — index a repository first"), nil } contractType := req.GetString("type", "") role := req.GetString("role", "") args := req.GetArguments() allRepos := false if v, ok := args["all_repos"].(bool); ok { allRepos = v } includeDeps := false if v, ok := args["include_deps"].(bool); ok { includeDeps = v } var resolved ResolvedScope var contractRepoAllow map[string]bool if !allRepos { var errResult *mcp.CallToolResult resolved, errResult = s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } contractRepoAllow = s.contractRepoAllowForRequest(ctx, req, resolved) } all := registry.All() // Apply filters. var filtered []contracts.Contract otherRepos := make(map[string]int) depsSkipped := 0 for _, c := range all { isDep := c.Type == contracts.ContractDependency || excludes.IsVendored(c.FilePath) if !allRepos && !contractInResolvedScope(c, resolved, contractRepoAllow) { if includeDeps || !isDep { otherRepos[c.RepoPrefix]++ } continue } if contractType != "" && string(c.Type) != contractType { continue } if role != "" && string(c.Role) != role { continue } if !includeDeps && isDep { depsSkipped++ continue } filtered = append(filtered, c) } otherReposTotal := 0 for _, n := range otherRepos { otherReposTotal += n } // Cap response — every per-contract row carries handler trails and // schema metadata, so a few hundred contracts blows past the MCP // per-response token cap. Default 200 surfaces enough to be useful; // callers that need every contract pass `limit` explicitly. With // pagination on, the contract list is sliced [offset, offset+limit) // with a `next_cursor` returned when the tail is unread. contractsLimit := 200 if v, ok := args["limit"].(float64); ok && v > 0 { contractsLimit = int(v) } contractsOffset := decodeCursor(req.GetString("cursor", "")) contractsTotal := len(filtered) if contractsOffset > contractsTotal { contractsOffset = contractsTotal } contractsEnd := min(contractsOffset+contractsLimit, contractsTotal) filtered = filtered[contractsOffset:contractsEnd] contractsTruncated := contractsEnd < contractsTotal contractsNextCursor := "" if contractsTruncated { contractsNextCursor = encodeCursor(contractsEnd) } if isCompact(req) { var b strings.Builder // Group by repo for readability in multi-repo mode. byRepo := make(map[string][]contracts.Contract) for _, c := range filtered { repo := c.RepoPrefix if repo == "" { repo = "(default)" } byRepo[repo] = append(byRepo[repo], c) } for repoName, items := range byRepo { if len(byRepo) > 1 { fmt.Fprintf(&b, "\n[%s] (%d contracts)\n", repoName, len(items)) } for _, c := range items { fmt.Fprintf(&b, "%s %s %s %s:%d\n", c.Type, c.Role, c.ID, c.FilePath, c.Line) } } if len(filtered) == 0 { b.WriteString("no contracts found\n") } fmt.Fprintf(&b, "total: %d contracts\n", len(filtered)) if otherReposTotal > 0 { fmt.Fprintf(&b, "other_repos: %d contracts in %d repo(s) (pass all_repos=true or repo= to include)\n", otherReposTotal, len(otherRepos)) } if depsSkipped > 0 { fmt.Fprintf(&b, "dependencies_skipped: %d (pass include_deps=true to include)\n", depsSkipped) } res := mcp.NewToolResultText(b.String()) if !allRepos { res = decorateResultWithScope(res, resolved) } return res, nil } if s.isGCX(ctx, req) { extra := []string{} if otherReposTotal > 0 { extra = append(extra, "other_repos_contracts", fmt.Sprintf("%d", otherReposTotal), "other_repos", fmt.Sprintf("%d", len(otherRepos))) } if depsSkipped > 0 { extra = append(extra, "dependencies_skipped", fmt.Sprintf("%d", depsSkipped)) } res, err := s.gcxResponseWithBudget(req)(encodeContractsList(filtered, len(filtered), extra...)) if !allRepos { return withScopeResult(res, err, resolved) } return res, err } // Group by repo, then by type for structured output. type repoGroup struct { Contracts map[string][]contracts.Contract `json:"contracts"` Total int `json:"total"` } byRepo := make(map[string]*repoGroup) for _, c := range filtered { repo := c.RepoPrefix if repo == "" { repo = "(default)" } if byRepo[repo] == nil { byRepo[repo] = &repoGroup{Contracts: make(map[string][]contracts.Contract)} } byRepo[repo].Contracts[string(c.Type)] = append(byRepo[repo].Contracts[string(c.Type)], c) byRepo[repo].Total++ } payload := map[string]any{ "by_repo": byRepo, "total": contractsTotal, "truncated": contractsTruncated, } if contractsTruncated { payload["limit"] = contractsLimit } if contractsNextCursor != "" { payload["next_cursor"] = contractsNextCursor } if otherReposTotal > 0 { payload["other_repos"] = map[string]any{ "total": otherReposTotal, "repo_count": len(otherRepos), "by_repo": otherRepos, "hint": "pass all_repos=true or repo=/project= to include these", } } if depsSkipped > 0 { payload["dependencies_skipped"] = map[string]any{ "total": depsSkipped, "hint": "pass include_deps=true to include type=dependency and vendor-pathed contracts", } } if !allRepos { return s.respondScopedJSONOrTOON(ctx, req, payload, resolved) } return s.respondJSONOrTOON(ctx, req, payload) } func (s *Server) contractRepoAllowForRequest(ctx context.Context, req mcp.CallToolRequest, resolved ResolvedScope) map[string]bool { if resolved.RepoAllow != nil { return resolved.RepoAllow } if strings.TrimSpace(req.GetString("repo", "")) == "" && strings.TrimSpace(req.GetString("project", "")) == "" && strings.TrimSpace(req.GetString("ref", "")) == "" && strings.TrimSpace(req.GetString("scope", "")) == "" { return nil } allowed, err := s.resolveRepoFilter(ctx, req) if err != nil { return nil } return allowed } func contractInResolvedScope(c contracts.Contract, resolved ResolvedScope, repoAllow map[string]bool) bool { if resolved.WorkspaceID != "" && c.EffectiveWorkspace() != resolved.WorkspaceID { return false } if len(repoAllow) > 0 && c.RepoPrefix != "" && !repoAllow[c.RepoPrefix] { return false } if len(repoAllow) == 0 && resolved.ProjectID != "" && c.EffectiveProject() != resolved.ProjectID { return false } return true } // --------------------------------------------------------------------------- // handleCheckContracts // --------------------------------------------------------------------------- func (s *Server) handleCheckContracts(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { registry := s.effectiveContractRegistry() if registry == nil { return mcp.NewToolResultError("no contract registry available — index a repository first"), nil } resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } contractRepoAllow := s.contractRepoAllowForRequest(ctx, req, resolved) reg := contracts.NewRegistry() for _, c := range registry.All() { if contractInResolvedScope(c, resolved, contractRepoAllow) { reg.Add(c) } } result := contracts.Match(reg) if isCompact(req) { var b strings.Builder fmt.Fprintf(&b, "matched: %d pairs\n", len(result.Matched)) for _, m := range result.Matched { cross := "" if m.CrossRepo { cross = " [cross-repo]" } provRepo := m.Provider.RepoPrefix consRepo := m.Consumer.RepoPrefix if provRepo == "" { provRepo = "(default)" } if consRepo == "" { consRepo = "(default)" } fmt.Fprintf(&b, " %s: [%s] %s:%d -> [%s] %s:%d%s\n", m.ContractID, provRepo, m.Provider.FilePath, m.Provider.Line, consRepo, m.Consumer.FilePath, m.Consumer.Line, cross) } fmt.Fprintf(&b, "orphan providers: %d\n", len(result.OrphanProviders)) for _, o := range result.OrphanProviders { repoLabel := o.RepoPrefix if repoLabel == "" { repoLabel = "(default)" } fmt.Fprintf(&b, " [%s] %s %s:%d\n", repoLabel, o.ID, o.FilePath, o.Line) } fmt.Fprintf(&b, "orphan consumers: %d\n", len(result.OrphanConsumers)) for _, o := range result.OrphanConsumers { repoLabel := o.RepoPrefix if repoLabel == "" { repoLabel = "(default)" } fmt.Fprintf(&b, " [%s] %s %s:%d\n", repoLabel, o.ID, o.FilePath, o.Line) } return decorateResultWithScope(mcp.NewToolResultText(b.String()), resolved), nil } if s.isGCX(ctx, req) { res, err := s.gcxResponseWithBudget(req)(encodeContractsCheck(result)) return withScopeResult(res, err, resolved) } payload := map[string]any{ "matched": result.Matched, "orphan_providers": result.OrphanProviders, "orphan_consumers": result.OrphanConsumers, "summary": map[string]int{ "matched_pairs": len(result.Matched), "orphan_providers": len(result.OrphanProviders), "orphan_consumers": len(result.OrphanConsumers), }, } return s.respondScopedJSONOrTOON(ctx, req, payload, resolved) } // --------------------------------------------------------------------------- // handleValidateContracts // --------------------------------------------------------------------------- // // Pairs each contract's provider and consumer sides, diffs their // request/response shapes (populated by the Stage 2 snapshotting // pass), and returns a list of issues classified as breaking, // warning, or info. Accepts the same repo/project/ref scoping // parameters as `check` so callers can limit the diff to one project. func (s *Server) handleValidateContracts(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { registry := s.effectiveContractRegistry() if registry == nil { return mcp.NewToolResultError("no contract registry available — index a repository first"), nil } resolved, errResult := s.resolveScope(ctx, req, IntentReach) if errResult != nil { return errResult, nil } contractRepoAllow := s.contractRepoAllowForRequest(ctx, req, resolved) reg := contracts.NewRegistry() for _, c := range registry.All() { if contractInResolvedScope(c, resolved, contractRepoAllow) { reg.Add(c) } } // Shape lookup pulls Shape out of the type node's meta — the // indexer attaches it during commitContracts (see // snapshotContractShapes in internal/indexer/indexer.go). lookup := contracts.ShapeLookup(func(symbolID string) *contracts.Shape { n := s.graph.GetNode(symbolID) if n == nil || n.Meta == nil { return nil } switch v := n.Meta["shape"].(type) { case *contracts.Shape: return v case contracts.Shape: return &v } return nil }) issues := contracts.Validate(reg, lookup) // Severity rollup for easy at-a-glance counts. summary := map[string]int{"breaking": 0, "warning": 0, "info": 0, "total": len(issues)} for _, is := range issues { switch is.Severity { case contracts.SeverityBreaking: summary["breaking"]++ case contracts.SeverityWarning: summary["warning"]++ case contracts.SeverityInfo: summary["info"]++ } } if isCompact(req) { var b strings.Builder fmt.Fprintf(&b, "issues: %d (breaking=%d warning=%d info=%d)\n", summary["total"], summary["breaking"], summary["warning"], summary["info"]) for _, is := range issues { field := is.Field if field == "" { field = "-" } fmt.Fprintf(&b, " [%s] %s %s field=%s prov=%s cons=%s %s\n", is.Severity, is.ContractID, is.Kind, field, is.Provider, is.Consumer, is.Details) } return decorateResultWithScope(mcp.NewToolResultText(b.String()), resolved), nil } payload := map[string]any{ "issues": issues, "summary": summary, } return s.respondScopedJSONOrTOON(ctx, req, payload, resolved) } // --------------------------------------------------------------------------- // handleFeedback — unified dispatcher for feedback (replaces 2 tools) // --------------------------------------------------------------------------- func (s *Server) handleFeedback(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { action, err := req.RequireString("action") if err != nil { return mcp.NewToolResultError("action is required (one of: record, query)"), nil } switch action { case "record": return s.handleRecordFeedback(ctx, req) case "query": return s.handleQueryFeedback(ctx, req) default: return mcp.NewToolResultError("unknown feedback action: " + action + " (expected: record or query)"), nil } } // --------------------------------------------------------------------------- // 12.1 handleRecordFeedback / handleQueryFeedback // --------------------------------------------------------------------------- func (s *Server) handleRecordFeedback(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { task := req.GetString("task", "") if task == "" { return mcp.NewToolResultError("task is required"), nil } useful := splitCSV(req.GetString("useful", "")) notNeeded := splitCSV(req.GetString("not_needed", "")) missing := splitCSV(req.GetString("missing", "")) if len(useful) == 0 && len(notNeeded) == 0 && len(missing) == 0 { return mcp.NewToolResultError("at least one of useful, not_needed, or missing must be provided"), nil } source := req.GetString("tool_source", "smart_context") entry := persistence.FeedbackEntry{ Task: task, Useful: useful, NotNeeded: notNeeded, Missing: missing, Source: source, } if s.feedback == nil { return mcp.NewToolResultError("feedback storage not initialized (no cache directory)"), nil } if err := s.feedback.Record(entry); err != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to record feedback: %v", err)), nil } return s.respondJSONOrTOON(ctx, req, map[string]any{ "recorded": true, "useful_count": len(useful), "not_needed_count": len(notNeeded), "missing_count": len(missing), }) } func (s *Server) handleQueryFeedback(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { if s.feedback == nil || !s.feedback.HasData() { empty := map[string]any{ "total_entries": 0, "accuracy": 0.0, "most_useful": []any{}, "most_missed": []any{}, "most_demoted": []any{}, } if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeFeedbackQuery(empty)) } if s.isTOON(ctx, req) { return returnTOON(empty) } return s.respondJSONOrTOON(ctx, req, empty) } topN := 10 if n := req.GetInt("top_n", 0); n > 0 { topN = n } toolSource := req.GetString("tool_source", "all") stats := s.feedback.AggregatedStats(toolSource, topN) if isCompact(req) { var sb strings.Builder fmt.Fprintf(&sb, "Feedback: %v entries, %.0f%% accuracy\n", stats["total_entries"], stats["accuracy"].(float64)*100) return mcp.NewToolResultText(sb.String()), nil } if s.isGCX(ctx, req) { return s.gcxResponseWithBudget(req)(encodeFeedbackQuery(stats)) } if s.isTOON(ctx, req) { return returnTOON(stats) } return s.respondJSONOrTOON(ctx, req, stats) } // splitCSV splits a comma-separated string into trimmed, non-empty parts. func splitCSV(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") var result []string for _, p := range parts { p = strings.TrimSpace(p) if p != "" { result = append(result, p) } } return result } // --------------------------------------------------------------------------- // 12.2 handleExportContext // --------------------------------------------------------------------------- func (s *Server) handleExportContext(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Delegate to smart_context for the raw data. Force `format: json` // on the inner call so the unmarshal below always sees JSON, // regardless of the caller's outer format preference or the // server's client-aware default (which auto-selects GCX1 for // known clients and would otherwise blow up our json.Unmarshal // with "invalid character 'G'"). smartReq := req args, _ := smartReq.Params.Arguments.(map[string]any) innerArgs := make(map[string]any, len(args)+1) maps.Copy(innerArgs, args) innerArgs["format"] = "json" smartReq.Params.Arguments = innerArgs smartResult, err := s.handleSmartContext(ctx, smartReq) if err != nil { return nil, err } // If smart_context returned an error result, pass it through. if smartResult.IsError { return smartResult, nil } format := req.GetString("format", "markdown") tokenBudget := req.GetInt("token_budget", 2000) if tokenBudget <= 0 { tokenBudget = 2000 } if tokenBudget > 8000 { tokenBudget = 8000 } // Extract the JSON data from smart_context result. var data map[string]any for _, content := range smartResult.Content { if textContent, ok := content.(mcp.TextContent); ok { if jsonErr := json.Unmarshal([]byte(textContent.Text), &data); jsonErr != nil { return mcp.NewToolResultError(fmt.Sprintf("failed to parse smart_context output: %v", jsonErr)), nil } break } } if data == nil { return mcp.NewToolResultError("no data from smart_context"), nil } if format == "json" { return s.respondJSONOrTOON(ctx, req, data) } // Render as markdown briefing. md := renderContextMarkdown(data, tokenBudget) return mcp.NewToolResultText(md), nil } // markdownFenceLang picks the Markdown code-fence info string for an embedded // source snippet. It prefers the symbol's indexed language (authoritative — the // graph already resolved it during indexing) and falls back to deriving one // from the file extension. An unrecognised language yields an empty info string // (a plain fence) rather than a wrong one, so a snippet is never mislabelled. func markdownFenceLang(language, filePath string) string { if lang := strings.TrimSpace(strings.ToLower(language)); lang != "" { return lang } return languageForExtension(filePath) } // contextSymbolEntries returns the symbol entries the briefing should render // and how many the source pipeline omitted for budget. The graded-fidelity // context_manifest is preferred when present — it carries the tiered, // budget-packed source — otherwise the flat relevant_symbols list is used. In // graded mode relevant_symbols is built without source, so falling back to it // blindly would render an empty briefing. func contextSymbolEntries(data map[string]any) (entries []any, omitted int) { if mani, ok := data["context_manifest"].(map[string]any); ok { if me, ok := mani["entries"].([]any); ok && len(me) > 0 { if o, ok := mani["omitted"].(float64); ok { omitted = int(o) } return me, omitted } } entries, _ = data["relevant_symbols"].([]any) return entries, 0 } // renderSymbolEntries writes the "Key Symbols" body for a list of symbol // entries. The entry shape is shared by the flat relevant_symbols list and the // graded manifest, so manifest-only fields (tier, compressed) are rendered when // present and skipped otherwise. Each source snippet is fenced with its own // language rather than a hardcoded "go". func renderSymbolEntries(sb *strings.Builder, entries []any, charBudget int) { for _, raw := range entries { em, ok := raw.(map[string]any) if !ok { continue } name, _ := em["name"].(string) kind, _ := em["kind"].(string) id, _ := em["id"].(string) filePath, _ := em["file_path"].(string) language, _ := em["language"].(string) startLine, _ := em["start_line"].(float64) fmt.Fprintf(sb, "### `%s` (%s)\n\n", name, kind) fmt.Fprintf(sb, "- **ID:** `%s`\n", id) fmt.Fprintf(sb, "- **File:** `%s:%d`\n", filePath, int(startLine)) if tier, ok := em["tier"].(string); ok && tier != "" { fmt.Fprintf(sb, "- **Tier:** %s\n", tier) } if sig, ok := em["signature"].(string); ok && sig != "" { fmt.Fprintf(sb, "- **Signature:** `%s`\n", sig) } // Include source if within budget. The fence language tracks the // symbol's own language rather than a hardcoded "go" so a snippet from // any indexed language is highlighted correctly. if source, ok := em["source"].(string); ok && source != "" { if sb.Len()+len(source) < charBudget { if compressed, _ := em["compressed"].(bool); compressed { sb.WriteString("- *(source compressed — bodies elided)*\n") } fmt.Fprintf(sb, "\n```%s\n", markdownFenceLang(language, filePath)) sb.WriteString(source) sb.WriteString("\n```\n") } else { sb.WriteString("- *(source omitted — token budget exceeded)*\n") } } sb.WriteString("\n") } } // renderContextMarkdown converts smart_context JSON output into a self-contained // markdown briefing suitable for sharing outside MCP. func renderContextMarkdown(data map[string]any, tokenBudget int) string { var sb strings.Builder // Conservative char budget calibrated for cl100k_base on code-heavy input. charBudget := tokens.TokensToChars(tokenBudget) // Header. task, _ := data["task"].(string) sb.WriteString("# Context Briefing\n\n") fmt.Fprintf(&sb, "**Task:** %s\n\n", task) // Keywords. if kws, ok := data["keywords"].([]any); ok && len(kws) > 0 { sb.WriteString("**Keywords:** ") for i, kw := range kws { if i > 0 { sb.WriteString(", ") } fmt.Fprintf(&sb, "`%v`", kw) } sb.WriteString("\n\n") } // Key symbols. The graded-fidelity manifest carries the richest // (tiered, budget-packed) source set; prefer it when present and fall // back to the flat relevant_symbols list otherwise. Both share the same // entry shape, so one renderer handles either — without this, a graded // briefing showed no source at all, because the flat list it falls back // to is built source-less in graded mode. if symbols, omitted := contextSymbolEntries(data); len(symbols) > 0 { sb.WriteString("## Key Symbols\n\n") renderSymbolEntries(&sb, symbols, charBudget) if omitted > 0 { fmt.Fprintf(&sb, "*(%d more symbol(s) omitted — token budget)*\n\n", omitted) } } // Callers and callees. if callers, ok := data["callers"].([]any); ok && len(callers) > 0 { sb.WriteString("## Callers\n\n") for _, c := range callers { fmt.Fprintf(&sb, "- `%v`\n", c) } sb.WriteString("\n") } if callees, ok := data["callees"].([]any); ok && len(callees) > 0 { sb.WriteString("## Callees\n\n") for _, c := range callees { fmt.Fprintf(&sb, "- `%v`\n", c) } sb.WriteString("\n") } // Cross-repo dependencies. if crossDeps, ok := data["cross_repo_dependencies"].([]any); ok && len(crossDeps) > 0 { sb.WriteString("## Cross-Repo Dependencies\n\n") for _, dep := range crossDeps { depMap, ok := dep.(map[string]any) if !ok { continue } name, _ := depMap["name"].(string) repo, _ := depMap["repo_prefix"].(string) edgeKind, _ := depMap["edge_kind"].(string) fmt.Fprintf(&sb, "- `%s` (repo: %s, %s)\n", name, repo, edgeKind) } sb.WriteString("\n") } // Test files. if tests, ok := data["related_test_files"].([]any); ok && len(tests) > 0 { sb.WriteString("## Related Tests\n\n") for _, t := range tests { fmt.Fprintf(&sb, "- `%v`\n", t) } sb.WriteString("\n") } // Files to edit. if files, ok := data["files_to_edit"].([]any); ok && len(files) > 0 { sb.WriteString("## Files to Edit\n\n") for _, f := range files { fmt.Fprintf(&sb, "- `%v`\n", f) } sb.WriteString("\n") } // Footer. sb.WriteString("---\n*Generated by `gortex export_context`*\n") return sb.String() } // --------------------------------------------------------------------------- // 13.2 handleAuditAgentConfig — scans CLAUDE.md / AGENTS.md / Cursor rules / // Copilot instructions for stale symbol refs, dead file paths, and bloat. // --------------------------------------------------------------------------- func (s *Server) handleAuditAgentConfig(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { root := req.GetString("root", "") if root == "" { if s.indexer != nil { root = s.indexer.RootPath() } } if root == "" { if cwd, err := os.Getwd(); err == nil { root = cwd } } if root == "" { return mcp.NewToolResultError("could not determine repo root — pass 'root' argument"), nil } var files []string if filesArg := req.GetString("files", ""); filesArg != "" { for _, f := range strings.Split(filesArg, ",") { f = strings.TrimSpace(f) if f != "" { files = append(files, f) } } } else { files = audit.DiscoverConfigFiles(root) } if len(files) == 0 { return s.respondJSONOrTOON(ctx, req, map[string]any{ "files_scanned": 0, "message": "no agent config files found", }) } report := audit.Audit(s.graph, root, files) if isCompact(req) { var b strings.Builder fmt.Fprintf(&b, "scanned=%d stale=%d dead=%d bloat=%d\n", report.FilesScanned, len(report.StaleRefs), len(report.DeadPaths), report.BloatScore) for _, r := range report.StaleRefs { fmt.Fprintf(&b, "stale %s:%d `%s`\n", r.File, r.Line, r.Token) } for _, d := range report.DeadPaths { fmt.Fprintf(&b, "dead %s:%d `%s`\n", d.File, d.Line, d.Path) } for _, f := range report.Files { if f.Bloat.Score >= 40 { fmt.Fprintf(&b, "bloat %s score=%d lines=%d dup=%d\n", f.File, f.Bloat.Score, f.Bloat.Lines, f.Bloat.Duplicates) } } return mcp.NewToolResultText(b.String()), nil } return s.respondJSONOrTOON(ctx, req, report) } // coverageByID batch-loads the coverage sidecar (change A) into an // id->row map; nil when the backend lacks the capability (callers then // fall back to Node.Meta). One read per handler call, not per-node. func (s *Server) coverageByID() map[string]graph.CoverageEnrichment { r, ok := s.graph.(graph.CoverageEnrichmentReader) if !ok { return nil } rows := r.CoverageRows("") m := make(map[string]graph.CoverageEnrichment, len(rows)) for _, e := range rows { m[e.NodeID] = e } return m } // coveragePctFrom returns a node's coverage %, preferring the sidecar map // and falling back to Meta["coverage_pct"] for un-migrated DBs. func coveragePctFrom(cov map[string]graph.CoverageEnrichment, n *graph.Node) (float64, bool) { if e, ok := cov[n.ID]; ok { return e.CoveragePct, true } if pct, ok := n.Meta["coverage_pct"].(float64); ok { return pct, true } return 0, false } // releaseByID batch-loads the release sidecar (change A) into an // id->tag map; nil when the backend lacks the capability. func (s *Server) releaseByID() map[string]string { r, ok := s.graph.(graph.ReleaseEnrichmentReader) if !ok { return nil } rows := r.ReleaseRows("") m := make(map[string]string, len(rows)) for _, e := range rows { m[e.NodeID] = e.AddedIn } return m } // addedInFrom returns a node's "added_in" tag, preferring the sidecar // map and falling back to Meta["added_in"] for un-migrated DBs. func addedInFrom(rel map[string]string, n *graph.Node) (string, bool) { if tag, ok := rel[n.ID]; ok { return tag, true } if n.Meta != nil { if tag, ok := n.Meta["added_in"].(string); ok { return tag, true } } return "", false } // blameRowsByID batch-loads the blame sidecar (change A) into an // id->row map; nil when the backend lacks the capability. func blameRowsByID(g graph.Store) map[string]graph.BlameEnrichment { r, ok := g.(graph.BlameEnrichmentReader) if !ok { return nil } rows := r.BlameRows("") m := make(map[string]graph.BlameEnrichment, len(rows)) for _, e := range rows { m[e.NodeID] = e } return m } // lastAuthoredFrom returns a node's blame, preferring the sidecar map and // falling back to Meta["last_authored"] for un-migrated DBs. func lastAuthoredFrom(blame map[string]graph.BlameEnrichment, n *graph.Node) (graph.BlameEnrichment, bool) { if e, ok := blame[n.ID]; ok { return e, true } if n.Meta != nil { if la, ok := n.Meta["last_authored"].(map[string]any); ok { e := graph.BlameEnrichment{NodeID: n.ID} e.Commit, _ = la["commit"].(string) e.Email, _ = la["email"].(string) e.Timestamp = tsFromMeta(la["timestamp"]) return e, true } } return graph.BlameEnrichment{}, false } // lastAuthoredTSFrom is the timestamp-only convenience over lastAuthoredFrom. func lastAuthoredTSFrom(blame map[string]graph.BlameEnrichment, n *graph.Node) (int64, bool) { if e, ok := lastAuthoredFrom(blame, n); ok && e.Timestamp != 0 { return e.Timestamp, true } return 0, false }