Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

4657 lines
164 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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=<prefix>, project=<name>, ref=<tag>, 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
// "<prefix>/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=<prefix> 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=<prefix>/project=<name> 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
}