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
116 lines
4.1 KiB
Go
116 lines
4.1 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// whyEntry is one knowledge source that motivates the queried symbol.
|
|
type whyEntry struct {
|
|
SourceID string `json:"source_id"`
|
|
Kind string `json:"kind"` // rationale | content
|
|
RationaleKind string `json:"rationale_kind,omitempty"`
|
|
AssetKind string `json:"asset_kind,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Signal string `json:"signal,omitempty"`
|
|
}
|
|
|
|
// whyResult is the why-query response: the rationale that motivates a symbol.
|
|
type whyResult struct {
|
|
Symbol string `json:"symbol"`
|
|
Count int `json:"count"`
|
|
Rationale []whyEntry `json:"rationale"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
func (s *Server) registerWhyTool() {
|
|
s.addTool(
|
|
mcp.NewTool("why",
|
|
mcp.WithDescription("Explain WHY a code symbol exists: a graph-first, one-hop walk over the rationale that motivates it — projected store_memory decisions / incidents and the content documents (ADRs, specs, slides) whose text names it. Use to recover the reasoning behind code before changing it. Curated rationale ranks before lexical content links."),
|
|
mcp.WithString("id", mcp.Required(), mcp.Description("Symbol node ID or name to explain.")),
|
|
mcp.WithNumber("limit", mcp.Description("Max rationale sources (default: 20).")),
|
|
mcp.WithString("scope", mcp.Description("Memory scope to reconcile on read: workspace (default) or global.")),
|
|
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
|
|
),
|
|
s.handleWhy,
|
|
)
|
|
}
|
|
|
|
func (s *Server) handleWhy(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
target, err := req.RequireString("id")
|
|
if err != nil {
|
|
return mcp.NewToolResultError("id is required"), nil
|
|
}
|
|
id, candidates := s.resolveSymbolTarget(ctx, target)
|
|
if len(candidates) > 0 {
|
|
return s.symbolDisambiguationResult(ctx, req, "why", target, candidates)
|
|
}
|
|
// Reconcile-on-read: project any memories that predate the daemon so the
|
|
// graph-first traversal below sees them.
|
|
s.reconcileRationale(strings.TrimSpace(req.GetString("scope", "")))
|
|
|
|
entries := s.whyEntriesFor(ctx, id)
|
|
if limit := req.GetInt("limit", 20); limit > 0 && len(entries) > limit {
|
|
entries = entries[:limit]
|
|
}
|
|
res := whyResult{Symbol: id, Count: len(entries), Rationale: entries}
|
|
if len(entries) == 0 {
|
|
res.Note = "No recorded rationale links this symbol. Try search_symbols corpus:content for related documents, or store_memory to record the decision."
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, res)
|
|
}
|
|
|
|
// whyEntriesFor walks the incoming EdgeMotivates edges of a symbol and returns
|
|
// the knowledge sources that motivate it — projected rationale nodes and
|
|
// content chunks — ranked curated-rationale-first. One graph hop, no fan-out.
|
|
func (s *Server) whyEntriesFor(ctx context.Context, id string) []whyEntry {
|
|
var entries []whyEntry
|
|
seen := map[string]bool{}
|
|
for _, e := range s.engineFor(ctx).GetInEdges(id) {
|
|
if e == nil || e.Kind != graph.EdgeMotivates || seen[e.From] {
|
|
continue
|
|
}
|
|
seen[e.From] = true
|
|
n := s.graph.GetNode(e.From)
|
|
if n == nil {
|
|
continue
|
|
}
|
|
entry := whyEntry{SourceID: e.From, Name: n.Name, Signal: whyMetaStr(e.Meta, "signal")}
|
|
switch n.Kind {
|
|
case graph.KindRationale:
|
|
entry.Kind = "rationale"
|
|
entry.RationaleKind = whyMetaStr(n.Meta, "rationale_kind")
|
|
entry.Text = whyMetaStr(n.Meta, "section_text")
|
|
case graph.KindDoc:
|
|
entry.Kind = "content"
|
|
entry.AssetKind = whyMetaStr(n.Meta, "asset_kind")
|
|
entry.Text = whyMetaStr(n.Meta, "section_text")
|
|
default:
|
|
entry.Kind = string(n.Kind)
|
|
}
|
|
entries = append(entries, entry)
|
|
}
|
|
// Curated rationale (a projected memory) outranks a lexical content match.
|
|
sort.SliceStable(entries, func(i, j int) bool { return whyRank(entries[i]) < whyRank(entries[j]) })
|
|
return entries
|
|
}
|
|
|
|
// whyRank orders rationale (curated memory) before content (lexical match).
|
|
func whyRank(e whyEntry) int {
|
|
if e.Kind == "rationale" {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
|
|
func whyMetaStr(m map[string]any, k string) string {
|
|
v, _ := m[k].(string)
|
|
return v
|
|
}
|