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

469 lines
13 KiB
Go

package mcp
import (
"context"
"fmt"
"iter"
"regexp"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// graphQueryMaxStages caps how many pipeline stages one query may have.
// The grammar is intentionally tiny; a deep pipeline is almost always a
// malformed query rather than a real need, and the cap bounds the
// per-call work.
const graphQueryMaxStages = 5
// registerGraphQueryTool wires graph_query — an ad-hoc, read-only graph
// query escape hatch. It runs a frozen minimal pipeline DSL so an agent
// can express a one-off shape ("interfaces named ~Handler implemented
// under internal/mcp/") that no purpose-built tool covers, without
// dropping to raw graph traversal code.
func (s *Server) registerGraphQueryTool() {
s.addTool(
mcp.NewTool("graph_query",
mcp.WithDescription("Ad-hoc read-only graph query via a tiny pipeline DSL. Stages are separated by '|':\n"+
" nodes FILTER* — seed the working set with all nodes matching the filters\n"+
" traverse EDGEKINDS DIR — expand the working set one hop along the given edge kinds (DIR: out|in|both, default out)\n"+
" filter FILTER+ — narrow the working set in memory\n"+
"A FILTER is one of: kind=<kind> name~<regex> path=<prefix> lang=<lang>.\n"+
"Example: nodes kind=interface name~Handler | traverse implements in | filter path=internal/mcp/\n"+
"The query is read-only by construction (no edit verbs) and bounded by `limit` and a 5-stage cap."),
mcp.WithString("query", mcp.Required(), mcp.Description("The pipeline DSL query — see the tool description for the grammar.")),
mcp.WithNumber("limit", mcp.Description("Max nodes in the result (default 100, hard cap 1000).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx (GCX1 compact wire format), or toon.")),
),
s.handleGraphQuery,
)
}
// handleGraphQuery parses and evaluates the pipeline DSL and returns the
// resulting subgraph.
func (s *Server) handleGraphQuery(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
q, err := req.RequireString("query")
if err != nil {
return mcp.NewToolResultError("query is required"), nil
}
limit := req.GetInt("limit", 100)
if limit < 1 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
stages, parseErr := parseGraphQuery(q)
if parseErr != nil {
return mcp.NewToolResultError("graph_query: " + parseErr.Error()), nil
}
eng := s.engineFor(ctx)
sg, evalErr := evalGraphQuery(eng, stages, limit)
if evalErr != nil {
return mcp.NewToolResultError("graph_query: " + evalErr.Error()), nil
}
allowed, filterErr := s.resolveRepoFilter(ctx, req)
if filterErr != nil {
return mcp.NewToolResultError(filterErr.Error()), nil
}
sg = filterSubGraph(sg, allowed)
enrichSubGraphEdges(sg)
return s.returnSubGraph(ctx, req, sg)
}
// gqStageKind enumerates the three pipeline verbs.
type gqStageKind int
const (
gqStageNodes gqStageKind = iota
gqStageTraverse
gqStageFilter
)
// gqFilter is one parsed FILTER clause. Exactly one of the fields drives
// the predicate, selected by op.
type gqFilter struct {
op string // "kind=", "name~", "path=", "lang="
value string
// re is the compiled regexp for a "name~" filter; nil otherwise.
re *regexp.Regexp
}
// gqStage is one parsed pipeline stage.
type gqStage struct {
kind gqStageKind
filters []gqFilter // nodes / filter stages
edgeKinds []graph.EdgeKind // traverse stage
direction string // traverse stage: out|in|both
}
// parseGraphQuery tokenizes and parses the pipeline DSL into stages.
// The tokenizer is hand-written: stages split on '|', then each stage
// is whitespace-tokenized. A FILTER is a single token of the form
// kind=X / name~X / path=X / lang=X — values may not contain spaces,
// which keeps the grammar frozen and the parser tiny.
func parseGraphQuery(q string) ([]gqStage, error) {
q = strings.TrimSpace(q)
if q == "" {
return nil, fmt.Errorf("empty query")
}
rawStages := strings.Split(q, "|")
if len(rawStages) > graphQueryMaxStages {
return nil, fmt.Errorf("too many stages (%d); the pipeline is capped at %d",
len(rawStages), graphQueryMaxStages)
}
var stages []gqStage
for i, raw := range rawStages {
toks := strings.Fields(raw)
if len(toks) == 0 {
return nil, fmt.Errorf("stage %d is empty", i+1)
}
verb := strings.ToLower(toks[0])
args := toks[1:]
switch verb {
case "nodes":
if i != 0 {
return nil, fmt.Errorf("'nodes' must be the first stage")
}
filters, ferr := parseGQFilters(args)
if ferr != nil {
return nil, ferr
}
stages = append(stages, gqStage{kind: gqStageNodes, filters: filters})
case "traverse":
if i == 0 {
return nil, fmt.Errorf("'traverse' cannot be the first stage; start with 'nodes'")
}
if len(args) == 0 {
return nil, fmt.Errorf("'traverse' needs an edge-kind list")
}
kinds, kerr := query.ParseEdgeKindsCSV(args[0])
if kerr != nil {
return nil, kerr
}
if len(kinds) == 0 {
return nil, fmt.Errorf("'traverse' edge-kind list is empty")
}
dir := "out"
if len(args) >= 2 {
dir = strings.ToLower(args[1])
switch dir {
case "out", "in", "both":
default:
return nil, fmt.Errorf("traverse direction must be out, in, or both (got %q)", args[1])
}
}
if len(args) > 2 {
return nil, fmt.Errorf("'traverse' takes at most an edge-kind list and a direction")
}
stages = append(stages, gqStage{kind: gqStageTraverse, edgeKinds: kinds, direction: dir})
case "filter":
if i == 0 {
return nil, fmt.Errorf("'filter' cannot be the first stage; start with 'nodes'")
}
if len(args) == 0 {
return nil, fmt.Errorf("'filter' needs at least one filter clause")
}
filters, ferr := parseGQFilters(args)
if ferr != nil {
return nil, ferr
}
stages = append(stages, gqStage{kind: gqStageFilter, filters: filters})
default:
return nil, fmt.Errorf("unknown stage verb %q (expected nodes, traverse, or filter)", toks[0])
}
}
return stages, nil
}
// parseGQFilters parses a run of FILTER tokens.
func parseGQFilters(toks []string) ([]gqFilter, error) {
var out []gqFilter
for _, tok := range toks {
var op string
switch {
case strings.HasPrefix(tok, "kind="):
op = "kind="
case strings.HasPrefix(tok, "name~"):
op = "name~"
case strings.HasPrefix(tok, "path="):
op = "path="
case strings.HasPrefix(tok, "lang="):
op = "lang="
default:
return nil, fmt.Errorf("malformed filter %q (expected kind=, name~, path=, or lang=)", tok)
}
value := tok[len(op):]
if value == "" {
return nil, fmt.Errorf("filter %q has an empty value", tok)
}
f := gqFilter{op: op, value: value}
if op == "name~" {
re, err := regexp.Compile(value)
if err != nil {
return nil, fmt.Errorf("invalid name~ regex %q: %v", value, err)
}
f.re = re
}
out = append(out, f)
}
return out, nil
}
// matches reports whether n satisfies the filter.
func (f gqFilter) matches(n *graph.Node) bool {
switch f.op {
case "kind=":
return string(n.Kind) == f.value
case "name~":
return f.re.MatchString(n.Name)
case "path=":
return strings.HasPrefix(n.FilePath, f.value)
case "lang=":
return strings.EqualFold(n.Language, f.value)
}
return false
}
// matchesAll reports whether n satisfies every filter in fs.
func matchesAll(n *graph.Node, fs []gqFilter) bool {
for _, f := range fs {
if !f.matches(n) {
return false
}
}
return true
}
// evalGraphQuery threads a working set of nodes through the stages and
// builds the final SubGraph. The working set is capped at `limit` after
// each stage so an unbounded `nodes` seed or a fan-out `traverse` can't
// blow up memory. Edges produced by traverse stages are collected so
// the result shows the relationships the query walked.
func evalGraphQuery(eng *query.Engine, stages []gqStage, limit int) (*query.SubGraph, error) {
if len(stages) == 0 {
return nil, fmt.Errorf("empty pipeline")
}
var working []*graph.Node
seen := make(map[string]bool)
var collectedEdges []*graph.Edge
add := func(n *graph.Node) {
if n == nil || seen[n.ID] {
return
}
seen[n.ID] = true
working = append(working, n)
}
for _, st := range stages {
switch st.kind {
case gqStageNodes:
// When the pipeline opens with a `kind=` predicate (the
// common case — e.g. `nodes kind=function ...`), iterate
// the backend's per-kind bucket instead of AllNodes(). On
// a disk backend NodesByKind hits a server-side filter and only
// the matching rows cross the storage boundary; AllNodes() materialised the
// whole node table per request. Other filters
// (`name~`/`path=`/`lang=`) still post-filter in Go.
//
// Overlay views (NodesByKindReader-unaware) fall through
// to the AllNodes() walk — they're already in-memory, so
// the bucket optimisation has no win there.
seedKinds := seedKindsFromFilters(st.filters)
byKind, _ := eng.Reader().(nodesByKindReader)
if byKind != nil && len(seedKinds) > 0 {
done := false
for _, k := range seedKinds {
if done {
break
}
for n := range byKind.NodesByKind(k) {
if n == nil {
continue
}
if !matchesAll(n, st.filters) {
continue
}
add(n)
if len(working) >= limit {
done = true
break
}
}
}
} else {
for _, n := range eng.AllNodes() {
if matchesAll(n, st.filters) {
add(n)
if len(working) >= limit {
break
}
}
}
}
case gqStageFilter:
kept := working[:0]
newSeen := make(map[string]bool, len(working))
for _, n := range working {
if matchesAll(n, st.filters) {
kept = append(kept, n)
newSeen[n.ID] = true
}
}
working = kept
seen = newSeen
case gqStageTraverse:
kindSet := make(map[graph.EdgeKind]bool, len(st.edgeKinds))
for _, k := range st.edgeKinds {
kindSet[k] = true
}
both := st.direction == "both"
forward := st.direction != "in"
var next []*graph.Node
nextSeen := make(map[string]bool)
addNext := func(n *graph.Node) {
if n == nil || nextSeen[n.ID] {
return
}
nextSeen[n.ID] = true
next = append(next, n)
}
for _, src := range working {
var edges []*graph.Edge
if both {
edges = append(eng.GetOutEdges(src.ID), eng.GetInEdges(src.ID)...)
} else if forward {
edges = eng.GetOutEdges(src.ID)
} else {
edges = eng.GetInEdges(src.ID)
}
for _, e := range edges {
if !kindSet[e.Kind] {
continue
}
var targetID string
if both {
if e.From == src.ID {
targetID = e.To
} else {
targetID = e.From
}
} else if forward {
if e.From != src.ID {
continue
}
targetID = e.To
} else {
if e.To != src.ID {
continue
}
targetID = e.From
}
if graph.IsUnresolvedTarget(targetID) ||
strings.HasPrefix(targetID, "external::") {
continue
}
tn := eng.GetSymbol(targetID)
if tn == nil {
continue
}
collectedEdges = append(collectedEdges, e)
addNext(tn)
}
if len(next) >= limit {
break
}
}
// traverse replaces the working set with the expanded one.
working = next
seen = nextSeen
}
if len(working) > limit {
working = working[:limit]
// Rebuild seen so a later traverse only fans out from the
// retained nodes.
seen = make(map[string]bool, len(working))
for _, n := range working {
seen[n.ID] = true
}
}
}
// Keep only edges whose endpoints are both in the final node set,
// so the subgraph is internally consistent.
inSet := make(map[string]bool, len(working))
for _, n := range working {
inSet[n.ID] = true
}
var edges []*graph.Edge
edgeSeen := make(map[string]bool)
for _, e := range collectedEdges {
if !inSet[e.From] || !inSet[e.To] {
continue
}
key := string(e.Kind) + "\x00" + e.From + "\x00" + e.To
if edgeSeen[key] {
continue
}
edgeSeen[key] = true
edges = append(edges, e)
}
return &query.SubGraph{
Nodes: working,
Edges: edges,
TotalNodes: len(working),
TotalEdges: len(edges),
}, nil
}
// nodesByKindReader is the optional read-side capability the eng.Reader
// underlying type may implement. *graph.Graph satisfies it directly
// (Store has NodesByKind); OverlaidView does not, which is fine —
// overlays already work in-memory and don't benefit from the bucket
// fast path.
type nodesByKindReader interface {
NodesByKind(kind graph.NodeKind) iter.Seq[*graph.Node]
}
// seedKindsFromFilters extracts every `kind=` predicate from a stage's
// filter list so the seed loop can iterate the corresponding NodesByKind
// buckets instead of AllNodes(). Returns nil when no `kind=` filter is
// present — the caller falls back to the AllNodes() walk in that case.
// Duplicates are deduped so a sloppy author writing `kind=function
// kind=function` doesn't double-iterate.
func seedKindsFromFilters(filters []gqFilter) []graph.NodeKind {
var out []graph.NodeKind
seen := make(map[graph.NodeKind]struct{}, len(filters))
for _, f := range filters {
if f.op != "kind=" {
continue
}
k := graph.NodeKind(f.value)
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, k)
}
return out
}