package mcp import ( "context" "math" "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/contracts" "github.com/zzet/gortex/internal/graph" ) func (s *Server) registerAnalysisTools() { s.addTool( mcp.NewTool("get_communities", mcp.WithDescription("Returns functional clusters discovered by community detection. Without id: list all communities with summaries. With id: full details of a specific community (members, files, cohesion)."), mcp.WithString("id", mcp.Description("Optional community ID (e.g. community-0). When set, returns full details of that community instead of the list.")), ), s.handleGetCommunities, ) s.addTool( mcp.NewTool("get_processes", mcp.WithDescription("Returns discovered execution flows — named chains of function calls starting from entry points. Without id: list all processes. With id: full step-by-step call chain for that process."), mcp.WithString("id", mcp.Description("Optional process ID (e.g. process-0). When set, returns the full step-by-step call chain for that process instead of the list.")), 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.handleGetProcesses, ) s.addTool( mcp.NewTool("detect_changes", mcp.WithDescription("Maps uncommitted git changes to symbols in the graph and runs blast radius analysis. The key pre-commit review tool."), 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.WithString("repo", mcp.Description("Repository prefix or path (multi-repo mode); defaults to the lone tracked repo or the session's cwd-bound repo")), mcp.WithBoolean("summary_only", mcp.Description("Return only by_depth_counts and drop the per-depth row lists — the cheapest blast-radius shape.")), mcp.WithNumber("offset", mcp.Description("Skip this many affected rows (depth order) before returning by_depth — pairs with limit to page a large blast radius.")), mcp.WithNumber("limit", mcp.Description("Max affected rows to return in by_depth (default 100). by_depth_counts always reports the full per-depth totals.")), ), s.handleDetectChanges, ) s.addTool( mcp.NewTool("suggest_queries", mcp.WithDescription("Cold-start helper: returns 5-10 starter exploration queries for an unfamiliar repository, derived from its entry points, load-bearing hubs, community bridges, and largest subsystems. Run at session start to orient before reaching for search_symbols / smart_context."), mcp.WithNumber("limit", mcp.Description("Max suggestions to return (default 8, capped at 20).")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), ), s.handleSuggestQueries, ) s.addTool( mcp.NewTool("search_text", mcp.WithDescription("Trigram-accelerated literal (or regexp) code search across the indexed repository — the alt grep backbone. Each hit carries the enclosing graph symbol (symbol_id / symbol_name) so you see which function or method a match landed in without a follow-up call. A trigram index narrows the candidate files, so a repo-wide search costs roughly the size of the matching files, not the whole tree. Use for literal-string / regexp lookups; use search_symbols for symbol-name / concept queries."), mcp.WithString("query", mcp.Description("Literal substring (case-sensitive) to search for — or a regular expression when regexp=true.")), mcp.WithBoolean("regexp", mcp.Description("Treat query as a regular expression instead of a literal substring. An invalid pattern is returned as a tool error. Default false.")), mcp.WithNumber("limit", mcp.Description("Max matching lines to return (default 100, capped at 1000).")), mcp.WithString("path", mcp.Description("Restrict matches to one or more sub-paths (comma-separated) -- a monorepo-service slice. Anchored, slash-segment-boundary prefixes relative to the repo root.")), mcp.WithString("repo", mcp.Description("Restrict matches to a single repository prefix.")), mcp.WithString("project", mcp.Description("Restrict matches to repositories in a specific project.")), mcp.WithString("workspace", mcp.Description("Restrict matches 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 and paths narrow the matches. Ignored for repositories when an explicit repo / project / ref is also given.")), mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon")), ), s.handleSearchText, ) } func (s *Server) handleGetCommunities(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { comms := s.getCommunities() // If id is provided, return the single community in detail. if id := req.GetString("id", ""); id != "" { if comms == nil { return mcp.NewToolResultError("no communities detected yet"), nil } for _, c := range comms.Communities { if c.ID == id { return s.respondJSONOrTOON(ctx, req, c) } } return mcp.NewToolResultError("community not found: " + id), nil } // Otherwise return the list of summaries. if comms == nil || len(comms.Communities) == 0 { return s.respondJSONOrTOON(ctx, req, map[string]any{ "communities": []any{}, "message": "no communities detected yet — run index_repository first", }) } // List mode deliberately omits per-community `files` (can be hundreds // of paths each). Callers who want that drill into a specific // community via `id`; the detail response includes the full member // set. `file_count` preserves size signal without the string array. // `repo_prefix` is the majority repo of the community's members so // UIs can render a badge without paging through every member id. type summary struct { ID string `json:"id"` Label string `json:"label"` Size int `json:"size"` FileCount int `json:"file_count"` Cohesion float64 `json:"cohesion"` RepoPrefix string `json:"repo_prefix"` ParentID string `json:"parent_id,omitempty"` } var summaries []summary for _, c := range comms.Communities { summaries = append(summaries, summary{ ID: c.ID, Label: c.Label, Size: c.Size, FileCount: len(c.Files), Cohesion: c.Cohesion, RepoPrefix: majorityRepoPrefix(c.Members), ParentID: c.ParentID, }) } return s.respondJSONOrTOON(ctx, req, map[string]any{ "communities": summaries, "total": len(summaries), "modularity": comms.Modularity, }) } func (s *Server) handleGetProcesses(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { procs := s.getProcesses() // If id is provided, return the single process in detail. if id := req.GetString("id", ""); id != "" { if procs == nil { return mcp.NewToolResultError("no processes discovered yet"), nil } for _, p := range procs.Processes { if p.ID == id { return s.respondJSONOrTOON(ctx, req, p) } } return mcp.NewToolResultError("process not found: " + id), nil } // Otherwise return the list of summaries. if procs == nil || len(procs.Processes) == 0 { return s.respondJSONOrTOON(ctx, req, map[string]any{ "processes": []any{}, "message": "no processes discovered yet — run index_repository first", }) } // `repo_prefixes` is the ordered set of distinct "owner/repo" prefixes // the flow's steps cross — the UI renders these as trail badges // without needing the full step id list. type summary struct { ID string `json:"id"` Name string `json:"name"` EntryPoint string `json:"entry_point"` StepCount int `json:"step_count"` FileCount int `json:"file_count"` Score float64 `json:"score"` RepoPrefixes []string `json:"repo_prefixes"` } var summaries []summary for _, p := range procs.Processes { summaries = append(summaries, summary{ ID: p.ID, Name: p.Name, EntryPoint: p.EntryPoint, StepCount: p.StepCount, FileCount: len(p.Files), Score: p.Score, RepoPrefixes: uniqueRepoPrefixesFromSteps(p.Steps), }) } return s.respondJSONOrTOON(ctx, req, map[string]any{ "processes": summaries, "total": len(summaries), }) } // repoPrefixOf extracts the repo prefix from a node ID of the form // "/::". The first `/` separates the // repo name from the file path, and `::` separates the file from the // symbol. IDs that don't contain `/` before the `::` (e.g. // "unresolved::OSTRACE") have no repo prefix and return empty. func repoPrefixOf(id string) string { pathPart := id if i := strings.Index(id, "::"); i >= 0 { pathPart = id[:i] } if j := strings.Index(pathPart, "/"); j >= 0 { return pathPart[:j] } return "" } // majorityRepoPrefix returns the most common repo prefix from a list of // node IDs. Empty when no ID carries a prefix. func majorityRepoPrefix(ids []string) string { counts := make(map[string]int, 4) for _, id := range ids { if p := repoPrefixOf(id); p != "" { counts[p]++ } } best := "" bestN := 0 for k, n := range counts { if n > bestN { best = k bestN = n } } return best } // uniqueRepoPrefixesFromSteps returns the ordered set of distinct repo // prefixes touched by a process flow, preserving DFS order so the UI // renders "crosses" badges in call sequence rather than alphabetical. func uniqueRepoPrefixesFromSteps(steps []analysis.Step) []string { seen := make(map[string]struct{}, 4) out := make([]string, 0, 4) for _, s := range steps { p := repoPrefixOf(s.ID) if p == "" { continue } if _, ok := seen[p]; ok { continue } seen[p] = struct{}{} out = append(out, p) } return out } func (s *Server) handleDetectChanges(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{ "changed_symbols": []any{}, "changed_files": diff.ChangedFiles, "risk": "NONE", "summary": "no indexed symbols affected by current changes", }) } // Run impact analysis on the changed symbols symbolIDs := make([]string, len(diff.ChangedSymbols)) for i, cs := range diff.ChangedSymbols { symbolIDs[i] = cs.ID } impact := analysis.AnalyzeImpact(s.graph, symbolIDs, s.getCommunities(), s.getProcesses()) detectResult := map[string]any{ "changed_symbols": diff.ChangedSymbols, "changed_files": diff.ChangedFiles, "risk": impact.Risk, "summary": impact.Summary, "by_depth": impact.ByDepth, "affected_processes": impact.AffectedProcesses, "affected_communities": impact.AffectedCommunities, "test_files": impact.TestFiles, "total_affected": impact.TotalAffected, } applyImpactDepthPaging(detectResult, impact.ByDepth, req.GetBool("summary_only", false), req.GetInt("offset", 0), req.GetInt("limit", 100)) return s.respondJSONOrTOON(ctx, req, detectResult) } // handleEnhancedChangeImpact replaces the original explain_change_impact with risk tiering // and cross-community warnings. func (s *Server) handleEnhancedChangeImpact(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]) } impact := analysis.AnalyzeImpact(s.graph, ids, s.getCommunities(), s.getProcesses()) result := map[string]any{ "risk": impact.Risk, "summary": impact.Summary, "by_depth": impact.ByDepth, "affected_processes": impact.AffectedProcesses, "affected_communities": impact.AffectedCommunities, "test_files": impact.TestFiles, "total_affected": impact.TotalAffected, "cross_repo_impact": impact.CrossRepoImpact, } // GNX-3: by_depth_counts is the headline; the heavy by_depth rows are // paged (offset / limit) or dropped (summary_only) so the agent gets the // "47 affected, 3 at depth-1" summary by default and the rows on demand. applyImpactDepthPaging(result, impact.ByDepth, req.GetBool("summary_only", false), req.GetInt("offset", 0), req.GetInt("limit", 100)) // Include per-repo grouping when cross-repo impact is detected. if impact.CrossRepoImpact { result["by_repo"] = impact.ByRepo } // Epistemic lower bound: the affected count is a floor when the blast // radius crosses a dynamic-dispatch / interface site the resolver could // not bind. Surface the flag + the boundary list so an agent knows // ">=N, could be more" and can act on each named site. if impact.LowerBound { result["lower_bound"] = true } if len(impact.Boundaries) > 0 { result["boundaries"] = impact.Boundaries } // When the blast radius is empty, an agent cannot tell genuinely // safe-to-change symbols apart from symbols the extractor never // wired up. Classify each input so a safety gate is not disarmed // by a false "0 affected". if impact.TotalAffected == 0 { var caveats []graph.ZeroImpactCaveat for _, id := range ids { if id == "" { continue } if c := graph.CaveatForZeroEdge(s.graph, id); c != nil { caveats = append(caveats, graph.ZeroImpactCaveat{ ID: id, Class: c.Class, Message: c.Message, }) } } if len(caveats) > 0 { result["zero_impact_caveat"] = caveats } } // Cross-community warning if len(impact.AffectedCommunities) >= 2 { communities := s.getCommunities() warning := s.computeCrossCommunityWarning(impact.AffectedCommunities, communities) result["cross_community_warning"] = warning } else { result["cross_community_warning"] = nil if len(impact.AffectedCommunities) == 1 { result["community_note"] = "change is community-local" } } // Contract impact — if any of the changed symbols is referenced // as a request/response body by a declared contract, surface the // full list so the reviewer sees "this struct backs N routes" // before the edit lands. Live validate pass runs on the affected // contracts so existing breaking drift is reported alongside the // pending-change blast radius. if ci := s.computeContractImpact(ids); ci != nil { result["contract_impact"] = ci if impact.Risk == analysis.RiskLow && ci.Breaking > 0 { result["risk"] = analysis.RiskHigh result["contract_risk_upgrade"] = "risk raised to HIGH — type is a contract boundary with breaking drift" } } if s.isGCX(ctx, req) { // encodeChangeImpact reads the same map shape we'd return as // JSON; routing through it keeps a single source of truth for // field names and avoids divergence on the next analyzer // addition. return s.gcxResponseWithBudget(req)(encodeChangeImpact(result)) } if s.isTOON(ctx, req) { return returnTOON(result) } return s.respondJSONOrTOON(ctx, req, result) } // ----------------------------------------------------------------------------- // Contract impact helper // ----------------------------------------------------------------------------- // contractImpact enumerates the contracts that reference one of the // input type IDs as a request or response body, and rolls up the // current validation issues for that subset so change-review sees // breaking drift in the same payload as community / risk info. type contractImpact struct { Affected []contractImpactEntry `json:"affected"` Breaking int `json:"breaking"` Warning int `json:"warning"` Info int `json:"info"` SampleIssues []contracts.ContractIssue `json:"sample_issues,omitempty"` } type contractImpactEntry struct { ContractID string `json:"contract_id"` Position string `json:"position"` // request | response Role string `json:"role"` // provider | consumer Repo string `json:"repo"` TypeID string `json:"type_id"` } // computeContractImpact walks every contract in the effective // registry and returns the ones whose request_type or response_type // matches any of the changed symbol IDs. Returns nil when nothing // matches so the JSON payload stays compact. func (s *Server) computeContractImpact(changedIDs []string) *contractImpact { reg := s.effectiveContractRegistry() if reg == nil { return nil } changed := make(map[string]struct{}, len(changedIDs)) for _, id := range changedIDs { changed[id] = struct{}{} } var entries []contractImpactEntry affectedIDs := make(map[string]struct{}) for _, c := range reg.All() { reqType := impactMetaString(c.Meta, "request_type") respType := impactMetaString(c.Meta, "response_type") if _, hit := changed[reqType]; hit && reqType != "" { entries = append(entries, contractImpactEntry{ ContractID: c.ID, Position: "request", Role: string(c.Role), Repo: c.RepoPrefix, TypeID: reqType, }) affectedIDs[c.ID] = struct{}{} } if _, hit := changed[respType]; hit && respType != "" { entries = append(entries, contractImpactEntry{ ContractID: c.ID, Position: "response", Role: string(c.Role), Repo: c.RepoPrefix, TypeID: respType, }) affectedIDs[c.ID] = struct{}{} } } if len(entries) == 0 { return nil } // Validate the affected subset only — Validate on the full // registry would drown the payload in unrelated drift. sub := contracts.NewRegistry() for _, c := range reg.All() { if _, ok := affectedIDs[c.ID]; ok { sub.Add(c) } } lookup := contracts.ShapeLookup(func(id string) *contracts.Shape { n := s.graph.GetNode(id) 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(sub, lookup) out := &contractImpact{Affected: entries} for _, is := range issues { switch is.Severity { case contracts.SeverityBreaking: out.Breaking++ case contracts.SeverityWarning: out.Warning++ case contracts.SeverityInfo: out.Info++ } } // Keep the first 10 issues inline; full list is always one // `contracts validate` call away. if len(issues) > 10 { out.SampleIssues = issues[:10] } else { out.SampleIssues = issues } return out } func impactMetaString(m map[string]any, key string) string { v, _ := m[key].(string) return v } // CommunityCoupling describes the coupling between two communities. type CommunityCoupling struct { CommunityA string `json:"community_a"` CommunityB string `json:"community_b"` LabelA string `json:"label_a"` LabelB string `json:"label_b"` CouplingScore float64 `json:"coupling_score"` TightlyCoupled bool `json:"tightly_coupled"` } // CrossCommunityWarning describes cross-community impact. type CrossCommunityWarning struct { AffectedCommunities []string `json:"affected_communities"` Couplings []CommunityCoupling `json:"couplings,omitempty"` } func (s *Server) computeCrossCommunityWarning(affectedCommunities []string, communities *analysis.CommunityResult) *CrossCommunityWarning { warning := &CrossCommunityWarning{ AffectedCommunities: affectedCommunities, } if communities == nil { return warning } // Build community label lookup commLabels := make(map[string]string) commMembers := make(map[string]map[string]bool) for _, c := range communities.Communities { commLabels[c.ID] = c.Label memberSet := make(map[string]bool, len(c.Members)) for _, m := range c.Members { memberSet[m] = true } commMembers[c.ID] = memberSet } // For each pair of affected communities, compute coupling score for i := 0; i < len(affectedCommunities); i++ { for j := i + 1; j < len(affectedCommunities); j++ { cA := affectedCommunities[i] cB := affectedCommunities[j] membersA := commMembers[cA] membersB := commMembers[cB] if len(membersA) == 0 || len(membersB) == 0 { continue } // Count edges crossing the boundary and total edges in both communities crossBoundary := 0 totalEdges := 0 edges := s.graph.AllEdges() for _, e := range edges { inA := membersA[e.From] || membersA[e.To] inB := membersB[e.From] || membersB[e.To] if inA || inB { totalEdges++ } // Cross-boundary: one end in A, other in B if (membersA[e.From] && membersB[e.To]) || (membersB[e.From] && membersA[e.To]) { crossBoundary++ } } var couplingScore float64 if totalEdges > 0 { couplingScore = math.Round(float64(crossBoundary)/float64(totalEdges)*10000) / 100 } warning.Couplings = append(warning.Couplings, CommunityCoupling{ CommunityA: cA, CommunityB: cB, LabelA: commLabels[cA], LabelB: commLabels[cB], CouplingScore: couplingScore, TightlyCoupled: couplingScore > 15, }) } } return warning }