项目文件夹

文件
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 行
164 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}