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
469 lines
13 KiB
Go
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
|
|
}
|