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

390 lines
13 KiB
Go

package mcp
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// navCursor is the per-session stateful navigation cursor. It tracks a
// current symbol and a back-history so the nav tool can move through the
// graph one hop at a time without the caller re-passing the symbol ID.
type navCursor struct {
mu sync.Mutex
current string // current symbol ID; "" when the cursor is unset
history []string // previous positions, oldest first; back pops the tail
}
// cursorFor returns the calling session's navigation cursor, allocating
// it on first use. Mirrors responseBufferFor — the cursor is bound to
// the MCP session lifecycle and freed with the rest of sessionState on
// disconnect.
func (s *Server) cursorFor(ctx context.Context) *navCursor {
sess := s.sessionFor(ctx)
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.cursor == nil {
sess.cursor = &navCursor{}
}
return sess.cursor
}
// registerNavTool wires nav — a verb-dispatched stateful navigation
// tool. One tool with an `action` selector (the same shape as analyze's
// `kind` dispatcher) moves a per-session cursor through the graph:
// goto / into / up / sibling / back / where / read.
func (s *Server) registerNavTool() {
s.addTool(
mcp.NewTool("nav",
mcp.WithDescription("Cursor-based stateful graph navigation. Holds a per-session cursor so you can move one hop at a time without re-passing the symbol ID. Actions:\n"+
" goto — set the cursor to `id`\n"+
" into — move to a callee of the current symbol\n"+
" up — move to a caller of the current symbol\n"+
" sibling — move to another member of the current symbol's parent\n"+
" back — pop the previous position off the history\n"+
" where — report the current symbol without moving\n"+
" read — return the current symbol's source\n"+
"Every response echoes the new cursor and an adjacency preview (callee / caller / sibling counts). For into / up / sibling, `select` picks which neighbour (numeric index or a name substring; default first)."),
mcp.WithString("action", mcp.Required(), mcp.Description("One of: goto, into, up, sibling, back, where, read.")),
mcp.WithString("id", mcp.Description("Target symbol node ID — required for action=goto.")),
mcp.WithString("select", mcp.Description("For into / up / sibling: which neighbour to move to — a 0-based numeric index or a name substring. Defaults to the first neighbour.")),
mcp.WithString("format", mcp.Description("Output format: json (default) or toon.")),
),
s.handleNav,
)
}
// handleNav dispatches a nav action against the session cursor.
func (s *Server) handleNav(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
action := strings.ToLower(strings.TrimSpace(req.GetString("action", "")))
if action == "" {
return mcp.NewToolResultError("nav: action is required (goto, into, up, sibling, back, where, read)"), nil
}
eng := s.engineFor(ctx)
cur := s.cursorFor(ctx)
cur.mu.Lock()
defer cur.mu.Unlock()
// Stale-cursor guard: a re-index may have removed the node the
// cursor points at. Detect it once, up front, and reset gracefully
// so no action operates on a dangling ID.
staleReset := false
if cur.current != "" && eng.GetSymbol(cur.current) == nil {
cur.current = ""
cur.history = nil
staleReset = true
}
switch action {
case "goto":
id, err := req.RequireString("id")
if err != nil || strings.TrimSpace(id) == "" {
return mcp.NewToolResultError("nav goto: id is required"), nil
}
if eng.GetSymbol(id) == nil {
return mcp.NewToolResultError("nav goto: symbol not found: " + id), nil
}
if cur.current != "" {
cur.history = append(cur.history, cur.current)
}
cur.current = id
return s.navRespond(ctx, req, eng, cur, "goto", staleReset, "")
case "into":
return s.navMove(ctx, req, eng, cur, "into", staleReset)
case "up":
return s.navMove(ctx, req, eng, cur, "up", staleReset)
case "sibling":
return s.navMove(ctx, req, eng, cur, "sibling", staleReset)
case "back":
if len(cur.history) == 0 {
return mcp.NewToolResultError("nav back: history is empty"), nil
}
prev := cur.history[len(cur.history)-1]
cur.history = cur.history[:len(cur.history)-1]
// Skip history entries that a re-index has since removed.
for eng.GetSymbol(prev) == nil && len(cur.history) > 0 {
prev = cur.history[len(cur.history)-1]
cur.history = cur.history[:len(cur.history)-1]
}
if eng.GetSymbol(prev) == nil {
cur.current = ""
return mcp.NewToolResultError("nav back: no live position left in history"), nil
}
cur.current = prev
return s.navRespond(ctx, req, eng, cur, "back", staleReset, "")
case "where":
if cur.current == "" {
return mcp.NewToolResultError("nav where: cursor is unset — use action=goto first"), nil
}
return s.navRespond(ctx, req, eng, cur, "where", staleReset, "")
case "read":
if cur.current == "" {
return mcp.NewToolResultError("nav read: cursor is unset — use action=goto first"), nil
}
return s.navRead(ctx, req, eng, cur, staleReset)
default:
return mcp.NewToolResultError("nav: unknown action " + action +
" (expected goto, into, up, sibling, back, where, read)"), nil
}
}
// navMove handles the into / up / sibling actions: it builds the
// candidate neighbour list, picks one via the `select` disambiguator,
// and advances the cursor.
func (s *Server) navMove(ctx context.Context, req mcp.CallToolRequest, eng engineLike, cur *navCursor, action string, staleReset bool) (*mcp.CallToolResult, error) {
if cur.current == "" {
return mcp.NewToolResultError("nav " + action + ": cursor is unset — use action=goto first"), nil
}
var candidates []*graph.Node
switch action {
case "into":
candidates = navCallees(eng, cur.current)
case "up":
candidates = navCallers(eng, cur.current)
case "sibling":
candidates = navSiblings(eng, cur.current)
}
if len(candidates) == 0 {
return mcp.NewToolResultError(fmt.Sprintf("nav %s: no %s available from %s",
action, navNeighbourNoun(action), cur.current)), nil
}
pick, perr := navSelect(candidates, req.GetString("select", ""))
if perr != nil {
return mcp.NewToolResultError("nav " + action + ": " + perr.Error()), nil
}
cur.history = append(cur.history, cur.current)
cur.current = pick.ID
return s.navRespond(ctx, req, eng, cur, action, staleReset, "")
}
// navSelect resolves the `select` disambiguator against a candidate
// list. An empty selector picks the first candidate. A numeric selector
// is a 0-based index. Any other value is a case-insensitive name
// substring; the first match wins (candidates are already deterministically
// ordered by their producers).
func navSelect(candidates []*graph.Node, selector string) (*graph.Node, error) {
selector = strings.TrimSpace(selector)
if selector == "" {
return candidates[0], nil
}
if idx, err := strconv.Atoi(selector); err == nil {
if idx < 0 || idx >= len(candidates) {
return nil, fmt.Errorf("select index %d out of range (have %d)", idx, len(candidates))
}
return candidates[idx], nil
}
lower := strings.ToLower(selector)
for _, c := range candidates {
if strings.Contains(strings.ToLower(c.Name), lower) {
return c, nil
}
}
return nil, fmt.Errorf("select %q matched none of the %d candidates", selector, len(candidates))
}
// navNeighbourNoun maps an action to the noun used in its error text.
func navNeighbourNoun(action string) string {
switch action {
case "into":
return "callees"
case "up":
return "callers"
case "sibling":
return "siblings"
}
return "neighbours"
}
// engineLike is the slice of *query.Engine the nav helpers need. Keeping
// it as an interface lets the helpers stay decoupled from the concrete
// engine and keeps them trivially unit-testable.
type engineLike interface {
GetSymbol(id string) *graph.Node
GetOutEdges(id string) []*graph.Edge
GetInEdges(id string) []*graph.Edge
}
// navCallees returns the distinct call targets of id, ordered by edge
// line so the list is deterministic across calls.
func navCallees(eng engineLike, id string) []*graph.Node {
return navNeighbours(eng, eng.GetOutEdges(id), graph.EdgeCalls, true)
}
// navCallers returns the distinct callers of id.
func navCallers(eng engineLike, id string) []*graph.Node {
callers := navNeighbours(eng, eng.GetInEdges(id), graph.EdgeCalls, false)
// A function registered as a callback is invoked through its registrar,
// which static call extraction can't see — so include the registrars as
// callers (via the resolver's callback-registration reference edges).
seen := make(map[string]bool, len(callers))
for _, n := range callers {
seen[n.ID] = true
}
for _, e := range eng.GetInEdges(id) {
if e.Kind != graph.EdgeReferences || e.Meta == nil || e.From == "" {
continue
}
if v, _ := e.Meta["via"].(string); v != "callback_registration" {
continue
}
if seen[e.From] {
continue
}
if n := eng.GetSymbol(e.From); n != nil {
seen[e.From] = true
callers = append(callers, n)
}
}
return callers
}
// navSiblings returns the other members of id's parent. The parent is
// reached via id's own EdgeMemberOf out-edge; the siblings are the
// parent's EdgeMemberOf in-edges, with id itself removed.
func navSiblings(eng engineLike, id string) []*graph.Node {
var parentID string
for _, e := range eng.GetOutEdges(id) {
if e.Kind == graph.EdgeMemberOf {
parentID = e.To
break
}
}
if parentID == "" {
return nil
}
all := navNeighbours(eng, eng.GetInEdges(parentID), graph.EdgeMemberOf, false)
out := all[:0]
for _, n := range all {
if n.ID != id {
out = append(out, n)
}
}
return out
}
// navNeighbours walks edges of the given kind and returns the distinct
// nodes on the far end. forward selects the To endpoint (out-edges);
// otherwise the From endpoint (in-edges). Unresolved / external targets
// are skipped.
func navNeighbours(eng engineLike, edges []*graph.Edge, kind graph.EdgeKind, forward bool) []*graph.Node {
seen := make(map[string]bool)
var out []*graph.Node
for _, e := range edges {
if e.Kind != kind {
continue
}
var id string
if forward {
id = e.To
} else {
id = e.From
}
if seen[id] || graph.IsUnresolvedTarget(id) || strings.HasPrefix(id, "external::") {
continue
}
n := eng.GetSymbol(id)
if n == nil {
continue
}
seen[id] = true
out = append(out, n)
}
return out
}
// navAdjacency is the short neighbour-count preview attached to every
// nav response so the caller knows which moves are available next.
type navAdjacency struct {
Callees int `json:"callees"`
Callers int `json:"callers"`
Siblings int `json:"siblings"`
}
// navCursorView is the JSON shape of the cursor echoed on every response.
type navCursorView struct {
Current string `json:"current"`
Kind string `json:"kind,omitempty"`
Name string `json:"name,omitempty"`
FilePath string `json:"file_path,omitempty"`
StartLine int `json:"start_line,omitempty"`
HistorySize int `json:"history_size"`
}
// navRespond builds the standard nav response: the action taken, the new
// cursor, and an adjacency preview.
func (s *Server) navRespond(ctx context.Context, req mcp.CallToolRequest, eng engineLike, cur *navCursor, action string, staleReset bool, _ string) (*mcp.CallToolResult, error) {
payload := s.navBasePayload(eng, cur, action, staleReset)
return s.respondJSONOrTOON(ctx, req, payload)
}
// navBasePayload assembles the cursor + adjacency fields shared by every
// nav response.
func (s *Server) navBasePayload(eng engineLike, cur *navCursor, action string, staleReset bool) map[string]any {
node := eng.GetSymbol(cur.current)
view := navCursorView{Current: cur.current, HistorySize: len(cur.history)}
if node != nil {
view.Kind = string(node.Kind)
view.Name = node.Name
view.FilePath = node.FilePath
view.StartLine = node.StartLine
}
adj := navAdjacency{
Callees: len(navCallees(eng, cur.current)),
Callers: len(navCallers(eng, cur.current)),
Siblings: len(navSiblings(eng, cur.current)),
}
payload := map[string]any{
"action": action,
"cursor": view,
"adjacency": adj,
}
if staleReset {
payload["stale_cursor_reset"] = true
}
return payload
}
// navRead returns the current symbol's source alongside the standard
// cursor + adjacency fields.
func (s *Server) navRead(ctx context.Context, req mcp.CallToolRequest, eng engineLike, cur *navCursor, staleReset bool) (*mcp.CallToolResult, error) {
node := eng.GetSymbol(cur.current)
if node == nil {
return mcp.NewToolResultError("nav read: cursor symbol not found: " + cur.current), nil
}
payload := s.navBasePayload(eng, cur, "read", staleReset)
if node.StartLine == 0 || node.EndLine == 0 {
payload["source"] = ""
payload["note"] = "symbol has no line range"
return s.respondJSONOrTOON(ctx, req, payload)
}
absPath, resolveErr := s.resolveNodePath(node)
if resolveErr != nil {
return mcp.NewToolResultError(resolveErr.Error()), nil
}
source, startLine, _, err := s.readLinesForCtx(ctx, absPath, node.StartLine, node.EndLine, 0)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("nav read: could not read source: %v", err)), nil
}
payload["source"] = source
payload["start_line"] = startLine
payload["end_line"] = node.EndLine
return s.respondJSONOrTOON(ctx, req, payload)
}