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

346 lines
9.2 KiB
Go

package mcp
import (
"context"
"os"
"sort"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/audit"
"github.com/zzet/gortex/internal/graph"
)
// registerAnalyzerResources surfaces long-form rollups whose only
// "argument" is the current state of the indexed code. Each is a
// read-only rollup the agent fetches by URI; on graph re-warm the
// resource broadcaster pushes `notifications/resources/updated` so
// subscribed clients can refresh without polling.
//
// The rollups intentionally pre-bake a small, opinionated slice
// (top-N, summary counts) — agents that need the full analyzer output
// fall back to the corresponding `analyze` tool invocation.
func (s *Server) registerAnalyzerResources() {
s.mcpServer.AddResource(
mcp.NewResource(
"gortex://report",
"Orientation Report",
mcp.WithResourceDescription("High-level codebase rollup: graph size, top languages, hotspot count, community count, dead-code count, todo count. Read at session start as a single fetch instead of N separate tool calls."),
mcp.WithMIMEType("application/json"),
),
s.handleResourceReport,
)
s.mcpServer.AddResource(
mcp.NewResource(
"gortex://god-nodes",
"God Nodes",
mcp.WithResourceDescription("Top symbols by complexity score (fan-in*2 + fan-out*1.5 + community-crossings*3, normalized 0-100). Use to spot over-coupled functions that need decomposition. Subset of `analyze kind:hotspots`."),
mcp.WithMIMEType("application/json"),
),
s.handleResourceGodNodes,
)
s.mcpServer.AddResource(
mcp.NewResource(
"gortex://surprises",
"Codebase Surprises",
mcp.WithResourceDescription("Anomalies worth investigating: import cycles, unreachable symbols, and the largest cross-community call hubs. Synthesises `analyze cycles`, `analyze dead_code`, and `analyze hotspots` into one rollup."),
mcp.WithMIMEType("application/json"),
),
s.handleResourceSurprises,
)
s.mcpServer.AddResource(
mcp.NewResource(
"gortex://audit",
"Agent Config Audit",
mcp.WithResourceDescription("Stale symbol refs, dead file paths, and bloat scores in CLAUDE.md / AGENTS.md / Cursor rules / Copilot / Windsurf instruction files. Same payload as the `audit_agent_config` tool with discovery defaults."),
mcp.WithMIMEType("application/json"),
),
s.handleResourceAudit,
)
s.mcpServer.AddResource(
mcp.NewResource(
"gortex://questions",
"Open Questions",
mcp.WithResourceDescription("Aggregated TODO / FIXME / XXX / HACK / QUESTION comments grouped by tag and assignee. Same payload as `analyze kind:todos` with stable file:line ordering."),
mcp.WithMIMEType("application/json"),
),
s.handleResourceQuestions,
)
}
func (s *Server) handleResourceReport(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// scopedNodes confines the report to the session's workspace; for
// an unbound session it is every node, so the rollup matches the
// legacy global view. inScope bounds the analyzer- and edge-driven
// counts; nil for an unbound session.
scoped := s.scopedNodes(ctx)
_, _, bound := s.sessionScope(ctx)
var inScope map[string]bool
if bound {
inScope = make(map[string]bool, len(scoped))
for _, n := range scoped {
inScope[n.ID] = true
}
}
byLang := make(map[string]int)
byKind := make(map[string]int)
var todoCount int
for _, n := range scoped {
if n.Language != "" {
byLang[n.Language]++
}
byKind[string(n.Kind)]++
if n.Kind == graph.KindTodo {
todoCount++
}
}
totalEdges := 0
for _, e := range s.graph.AllEdges() {
if inScope != nil && (!inScope[e.From] || !inScope[e.To]) {
continue
}
totalEdges++
}
topLangs := topNFromCountMap(byLang, 5)
topKinds := topNFromCountMap(byKind, 5)
var hotspotCount int
if len(scoped) >= 10 {
for _, h := range s.getHotspots() {
if inScope == nil || inScope[h.ID] {
hotspotCount++
}
}
}
deadCount := 0
for _, d := range analysis.FindDeadCode(s.graph, s.getProcesses(), nil) {
if inScope == nil || inScope[d.ID] {
deadCount++
}
}
commCount := 0
if c := s.getCommunities(); c != nil {
for _, com := range c.Communities {
if inScope == nil {
commCount++
continue
}
for _, m := range com.Members {
if inScope[m] {
commCount++
break
}
}
}
}
procCount := 0
if p := s.getProcesses(); p != nil {
for _, proc := range p.Processes {
if inScope == nil || inScope[proc.EntryPoint] {
procCount++
}
}
}
return jsonResource(req.Params.URI, map[string]any{
"total_nodes": len(scoped),
"total_edges": totalEdges,
"top_languages": topLangs,
"top_kinds": topKinds,
"communities": commCount,
"processes": procCount,
"hotspots": hotspotCount,
"dead_code": deadCount,
"open_todos": todoCount,
})
}
func (s *Server) handleResourceGodNodes(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
if s.graph.NodeCount() < 10 {
return jsonResource(req.Params.URI, map[string]any{
"god_nodes": []any{},
"message": "codebase too small for meaningful hotspot analysis (need at least 10 symbols)",
})
}
entries := s.getHotspots()
totalCount := len(entries)
truncated := false
if len(entries) > 20 {
entries = entries[:20]
truncated = true
}
return jsonResource(req.Params.URI, map[string]any{
"god_nodes": entries,
"total": totalCount,
"truncated": truncated,
})
}
func (s *Server) handleResourceSurprises(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
communities := s.getCommunities()
cycles := analysis.DetectCycles(s.graph, communities, "")
if len(cycles) > 10 {
cycles = cycles[:10]
}
deadAll := analysis.FindDeadCode(s.graph, s.getProcesses(), nil)
deadTrunc := false
if len(deadAll) > 20 {
deadAll = deadAll[:20]
deadTrunc = true
}
var topHubs []analysis.HotspotEntry
if s.graph.NodeCount() >= 10 {
hot := s.getHotspots()
// Top hubs == hotspots with at least one community crossing.
for _, h := range hot {
if h.CommunityCrossings > 0 {
topHubs = append(topHubs, h)
}
if len(topHubs) == 10 {
break
}
}
}
return jsonResource(req.Params.URI, map[string]any{
"cycles": cycles,
"dead_code": deadAll,
"dead_code_truncated": deadTrunc,
"cross_community_hubs": topHubs,
})
}
func (s *Server) handleResourceAudit(_ context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
// Match the tool's fallback chain. Resources can't take args, so
// skip the explicit-arg step and start from the indexer root.
root := ""
if s.indexer != nil {
root = s.indexer.RootPath()
}
if root == "" {
if cwd, err := os.Getwd(); err == nil {
root = cwd
}
}
if root == "" {
return jsonResource(req.Params.URI, map[string]any{
"error": "could not determine repo root for audit",
})
}
files := audit.DiscoverConfigFiles(root)
if len(files) == 0 {
return jsonResource(req.Params.URI, map[string]any{
"files_scanned": 0,
"message": "no agent config files found",
"root": root,
})
}
return jsonResource(req.Params.URI, audit.Audit(s.graph, root, files))
}
func (s *Server) handleResourceQuestions(ctx context.Context, req mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
type questionRow 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 []questionRow
byTag := make(map[string]int)
withAssignee := 0
// scopedNodes confines the TODO rollup to the session's workspace.
for _, n := range s.scopedNodes(ctx) {
if n.Kind != graph.KindTodo {
continue
}
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)
rows = append(rows, questionRow{
ID: n.ID,
Tag: tag,
File: n.FilePath,
Line: n.StartLine,
Assignee: assignee,
Due: due,
Ticket: ticket,
Text: text,
})
byTag[tag]++
if assignee != "" {
withAssignee++
}
}
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
})
return jsonResource(req.Params.URI, map[string]any{
"questions": rows,
"total": len(rows),
"by_tag": byTag,
"with_assignee": withAssignee,
})
}
// topNFromCountMap returns the largest n entries from a string→int
// counter, sorted by count descending then key ascending so the report
// is deterministic across calls.
func topNFromCountMap(m map[string]int, n int) []map[string]any {
type kv struct {
k string
v int
}
all := make([]kv, 0, len(m))
for k, v := range m {
all = append(all, kv{k, v})
}
sort.Slice(all, func(i, j int) bool {
if all[i].v != all[j].v {
return all[i].v > all[j].v
}
return all[i].k < all[j].k
})
if len(all) > n {
all = all[:n]
}
out := make([]map[string]any, 0, len(all))
for _, p := range all {
out = append(out, map[string]any{
"name": p.k,
"count": p.v,
})
}
return out
}