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

298 lines
9.5 KiB
Go

package mcp
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/graph"
)
// registerWakeupTool wires gortex_wakeup — a ~500-token markdown
// codebase digest assembled from the same substrate get_repo_outline
// already exposes (language mix, top communities, hotspots, entry
// points), formatted as paste-ready markdown for users who *can't*
// run MCP at all (web ChatGPT, hosted Codex, raw API).
//
// Same builder also feeds the `gortex wakeup` CLI subcommand so the
// MCP and CLI outputs stay byte-identical.
func (s *Server) registerWakeupTool() {
s.addTool(
mcp.NewTool("gortex_wakeup",
mcp.WithDescription("Paste-ready ~500-token codebase digest. Composes language mix + top communities + load-bearing hotspots + entry points into a single markdown blob the agent can paste into a chat session at startup. Targets users without an MCP transport (web ChatGPT, hosted Codex, raw API). Token cap is approximate — under 600 in typical use."),
mcp.WithNumber("max_tokens", mcp.Description("Approximate output cap (default: 500). Bytes-per-token heuristic is 4; we trim to that budget after rendering.")),
mcp.WithNumber("top_communities", mcp.Description("Communities to include (default: 4).")),
mcp.WithNumber("top_hotspots", mcp.Description("Hotspots to include (default: 5).")),
mcp.WithNumber("top_entry_points", mcp.Description("Entry points to include (default: 5).")),
mcp.WithString("format", mcp.Description("Output format: markdown (default — primary use case) or json. JSON wraps the markdown in a {markdown, tokens_est, sections} envelope for callers that want to introspect.")),
),
s.handleGortexWakeup,
)
}
// WakeupOptions controls BuildWakeup output. Exposed so the
// `gortex wakeup` CLI subcommand can reuse the identical renderer
// without duplicating defaults.
type WakeupOptions struct {
MaxTokens int
TopCommunities int
TopHotspots int
TopEntryPoints int
// PrecomputedHotspots, when non-nil, is the default-threshold
// hotspot ranking the caller has already paid for. Threaded by
// the MCP handler from the server-wide cache so the wakeup turn
// skips a redundant FindHotspots (and its ComputeBetweenness
// pass). nil means BuildWakeup computes it fresh — the CLI
// `gortex wakeup` path.
PrecomputedHotspots []analysis.HotspotEntry
}
// DefaultWakeupOptions returns the defaults the MCP handler uses.
// Pulled out so the CLI subcommand renders the same output.
func DefaultWakeupOptions() WakeupOptions {
return WakeupOptions{
MaxTokens: 500,
TopCommunities: 4,
TopHotspots: 5,
TopEntryPoints: 5,
}
}
// BuildWakeup renders the wakeup digest from a graph + cached
// communities. Returns the markdown body and an approximate token
// count (bytes / 4). Exposed so CLI and MCP paths share one
// implementation.
func BuildWakeup(g graph.Store, communities *analysis.CommunityResult, opts WakeupOptions) (markdown string, tokensEst int) {
if opts.MaxTokens <= 0 {
opts.MaxTokens = 500
}
if opts.TopCommunities <= 0 {
opts.TopCommunities = 4
}
if opts.TopHotspots <= 0 {
opts.TopHotspots = 5
}
if opts.TopEntryPoints <= 0 {
opts.TopEntryPoints = 5
}
// Wakeup is a whole-repo digest — language tally + hotspot list +
// entry-point list, with no session scoping. The lang count can
// come from Stats() (one indexed groupby on disk backends);
// hotspots and entry points already iterate the function/method
// subset via the analyzers / NodesByKindsScanner path, so the
// AllNodes() pull the legacy build used to feed the lang summary
// just adds a redundant 107k-row trip on a disk backend.
stats := g.Stats()
var b strings.Builder
b.WriteString("# Codebase wakeup\n\n")
langCounts := map[string]int{}
for lang, c := range stats.ByLanguage {
if lang == "" {
continue
}
langCounts[lang] = c
}
type langRow struct {
name string
count int
}
langs := make([]langRow, 0, len(langCounts))
for k, v := range langCounts {
langs = append(langs, langRow{k, v})
}
sort.Slice(langs, func(i, j int) bool {
if langs[i].count != langs[j].count {
return langs[i].count > langs[j].count
}
return langs[i].name < langs[j].name
})
topLangs := langs
if len(topLangs) > 3 {
topLangs = topLangs[:3]
}
langSummary := []string{}
for _, l := range topLangs {
langSummary = append(langSummary, fmt.Sprintf("%s (%d)", l.name, l.count))
}
fileCount := stats.ByKind[string(graph.KindFile)]
fmt.Fprintf(&b, "**Scale.** %d indexed symbols across %d files. Primary: %s.\n\n",
stats.TotalNodes, fileCount, strings.Join(langSummary, ", "))
// Communities.
if communities != nil && len(communities.Communities) > 0 {
comms := append([]analysis.Community(nil), communities.Communities...)
sort.Slice(comms, func(i, j int) bool { return comms[i].Size > comms[j].Size })
if len(comms) > opts.TopCommunities {
comms = comms[:opts.TopCommunities]
}
b.WriteString("**Communities.**\n")
for _, c := range comms {
label := c.Label
if label == "" {
label = c.ID
}
hub := ""
if c.Hub != "" {
hub = " · hub " + c.Hub
}
fmt.Fprintf(&b, "- %s (%d members%s)\n", label, c.Size, hub)
}
b.WriteString("\n")
}
// Hotspots.
var hotspots []analysis.HotspotEntry
if opts.PrecomputedHotspots != nil {
hotspots = opts.PrecomputedHotspots
} else {
hotspots = analysis.FindHotspots(g, communities, 0)
}
if len(hotspots) > opts.TopHotspots {
hotspots = hotspots[:opts.TopHotspots]
}
if len(hotspots) > 0 {
b.WriteString("**Load-bearing symbols.**\n")
for _, h := range hotspots {
fmt.Fprintf(&b, "- `%s` (in:%d, out:%d) — %s\n", h.Name, h.FanIn, h.FanOut, h.FilePath)
}
b.WriteString("\n")
}
// Entry points.
entries := wakeupEntryPoints(g, opts.TopEntryPoints)
if len(entries) > 0 {
b.WriteString("**Entry points.**\n")
for _, e := range entries {
fmt.Fprintf(&b, "- `%s` — %s\n", e.Name, e.FilePath)
}
b.WriteString("\n")
}
out := b.String()
out = trimToTokens(out, opts.MaxTokens)
return out, len(out) / 4
}
// wakeupEntryPoints returns functions/methods with zero incoming
// edges and at least one outgoing edge, ranked by out-degree.
//
// Uses NodeDegreeAggregator when the backend implements it (one
// batched in/out count instead of up to 3N GetInEdges/GetOutEdges
// round-trips on a disk backend — the sort path called GetOutEdges
// twice per candidate, the worst single hot spot in this file). We
// stash the fan-out alongside each node so the sort never has to
// re-query.
func wakeupEntryPoints(g graph.Store, top int) []*graph.Node {
type entry struct {
node *graph.Node
fanOut int
}
// Pull only the callable subset via NodesByKindsScanner so disk
// backends never materialise the whole node table for an entry-
// point candidate set that only ranges across function + method.
var pool []*graph.Node
if scan, ok := g.(graph.NodesByKindsScanner); ok {
pool = scan.NodesByKinds([]graph.NodeKind{graph.KindFunction, graph.KindMethod})
} else {
all := g.AllNodes()
pool = make([]*graph.Node, 0, len(all))
for _, n := range all {
if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod {
pool = append(pool, n)
}
}
}
entries := make([]entry, 0, len(pool))
if agg, ok := g.(graph.NodeDegreeAggregator); ok && len(pool) > 0 {
ids := make([]string, 0, len(pool))
byID := make(map[string]*graph.Node, len(pool))
for _, n := range pool {
ids = append(ids, n.ID)
byID[n.ID] = n
}
for _, r := range agg.NodeDegreeCounts(ids, nil) {
if r.InCount > 0 || r.OutCount == 0 {
continue
}
n := byID[r.NodeID]
if n == nil {
continue
}
entries = append(entries, entry{node: n, fanOut: r.OutCount})
}
} else {
for _, n := range pool {
if len(g.GetInEdges(n.ID)) > 0 {
continue
}
out := len(g.GetOutEdges(n.ID))
if out == 0 {
continue
}
entries = append(entries, entry{node: n, fanOut: out})
}
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].fanOut != entries[j].fanOut {
return entries[i].fanOut > entries[j].fanOut
}
return entries[i].node.ID < entries[j].node.ID
})
if len(entries) > top {
entries = entries[:top]
}
out := make([]*graph.Node, 0, len(entries))
for _, e := range entries {
out = append(out, e.node)
}
return out
}
// trimToTokens caps the markdown to the requested approximate token
// budget. Heuristic: 4 bytes per token. Trims at a line boundary so
// the cut is visually clean.
func trimToTokens(s string, maxTokens int) string {
limitBytes := maxTokens * 4
if len(s) <= limitBytes {
return s
}
cut := s[:limitBytes]
if idx := strings.LastIndex(cut, "\n"); idx > limitBytes/2 {
cut = cut[:idx]
}
return cut + "\n\n_… digest truncated to fit token budget …_\n"
}
func (s *Server) handleGortexWakeup(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
opts := DefaultWakeupOptions()
if v := req.GetInt("max_tokens", 0); v > 0 {
opts.MaxTokens = v
}
if v := req.GetInt("top_communities", 0); v > 0 {
opts.TopCommunities = v
}
if v := req.GetInt("top_hotspots", 0); v > 0 {
opts.TopHotspots = v
}
if v := req.GetInt("top_entry_points", 0); v > 0 {
opts.TopEntryPoints = v
}
opts.PrecomputedHotspots = s.getHotspots()
md, est := BuildWakeup(s.graph, s.getCommunities(), opts)
format := strings.ToLower(strings.TrimSpace(req.GetString("format", "markdown")))
if format == "markdown" || format == "" {
return mcp.NewToolResultText(md), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"markdown": md,
"tokens_est": est,
})
}