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
298 lines
9.5 KiB
Go
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,
|
|
})
|
|
}
|