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
853 lines
28 KiB
Go
853 lines
28 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/contracts"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// handleAnalyzeRoutes surfaces the EdgeHandlesRoute graph layer:
|
|
// every (handler symbol, route contract) pair that the contracts
|
|
// pipeline detected as a real network route. Answers "which handler
|
|
// serves /v1/users/:id?" without making the agent walk EdgeProvides
|
|
// and filter by Meta["type"]="http" by hand.
|
|
//
|
|
// Filters:
|
|
// - method: HTTP verb (GET/POST/...) or gRPC method (case-insensitive)
|
|
// - path: substring match on the contract's path / topic / channel
|
|
// - type: contract type — http / grpc / graphql / topic / ws.
|
|
// Named `type` (not `kind`) because the analyze dispatcher
|
|
// reserves `kind` for the analyzer name itself.
|
|
func (s *Server) handleAnalyzeRoutes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
methodFilter := strings.ToUpper(strings.TrimSpace(stringArg(args, "method")))
|
|
pathFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "path")))
|
|
kindFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "type")))
|
|
|
|
type routeRow struct {
|
|
Handler string `json:"handler"`
|
|
Route string `json:"route"`
|
|
Method string `json:"method,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Kind string `json:"kind"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
}
|
|
var rows []*routeRow
|
|
for e := range edgesByKinds(s.graph, graph.EdgeHandlesRoute) {
|
|
contractNode := s.graph.GetNode(e.To)
|
|
if contractNode == nil {
|
|
continue
|
|
}
|
|
ctype, _ := contractNode.Meta["type"].(string)
|
|
if kindFilter != "" && ctype != kindFilter {
|
|
continue
|
|
}
|
|
method, path := routeMethodAndPath(contractNode)
|
|
if methodFilter != "" && strings.ToUpper(method) != methodFilter {
|
|
continue
|
|
}
|
|
if pathFilter != "" && !strings.Contains(strings.ToLower(path), pathFilter) {
|
|
continue
|
|
}
|
|
rows = append(rows, &routeRow{
|
|
Handler: e.From,
|
|
Route: e.To,
|
|
Method: method,
|
|
Path: path,
|
|
Kind: ctype,
|
|
File: e.FilePath,
|
|
Line: e.Line,
|
|
})
|
|
}
|
|
// routes reads EdgeHandlesRoute directly off s.graph; narrow each row to
|
|
// the session workspace + optional repo allow-set. Keep a row only when
|
|
// BOTH endpoints — the handler (e.From) and the route contract (e.To) —
|
|
// are visible. Unbound sessions see every row (analyzeNodeVisible passes),
|
|
// so this is a strict no-op there. total recomputes after this block.
|
|
if s.scopeFiltersActive(ctx) {
|
|
kept := make([]*routeRow, 0, len(rows))
|
|
for _, r := range rows {
|
|
if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Handler)) &&
|
|
s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Route)) {
|
|
kept = append(kept, r)
|
|
}
|
|
}
|
|
rows = kept
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Kind != rows[j].Kind {
|
|
return rows[i].Kind < rows[j].Kind
|
|
}
|
|
if rows[i].Path != rows[j].Path {
|
|
return rows[i].Path < rows[j].Path
|
|
}
|
|
if rows[i].Method != rows[j].Method {
|
|
return rows[i].Method < rows[j].Method
|
|
}
|
|
return rows[i].Handler < rows[j].Handler
|
|
})
|
|
if s.isGCX(ctx, req) {
|
|
items := make([]routeItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, routeItem(*r))
|
|
}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("routes", items))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%s %-6s %s → %s (%s:%d)\n", r.Kind, r.Method, r.Path, r.Handler, r.File, r.Line)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no routes\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"routes": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
// handleAnalyzeRouteFrameworks lists the registered structural route passes
|
|
// (the FrameworkRoutePass registry) — each pass's name and language filter —
|
|
// alongside the count of route contract nodes per framework in the active
|
|
// graph. It is the queryable face of the route-extraction front door, the
|
|
// sibling of analyze kind=synthesizers for the post-resolution dispatch
|
|
// registry.
|
|
func (s *Server) handleAnalyzeRouteFrameworks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
frameworkCounts := map[string]int{}
|
|
// route_frameworks tallies route contract nodes per framework straight off
|
|
// s.graph.AllNodes(). Gate the contributing loop on visibility so the
|
|
// per-framework counts (and total_passes) reflect only the session
|
|
// workspace + optional repo allow-set. Unbound sessions count every
|
|
// contract node, so the gate is a strict no-op there.
|
|
scoped := s.scopeFiltersActive(ctx)
|
|
for _, n := range s.graph.AllNodes() {
|
|
if n == nil || n.Kind != graph.KindContract || n.Meta == nil {
|
|
continue
|
|
}
|
|
if scoped && !s.analyzeNodeVisible(ctx, n) {
|
|
continue
|
|
}
|
|
if fw := routeFramework(n); fw != "" {
|
|
frameworkCounts[fw]++
|
|
}
|
|
}
|
|
type passRow struct {
|
|
Name string `json:"name"`
|
|
Languages []string `json:"languages,omitempty"`
|
|
Routes int `json:"routes"`
|
|
}
|
|
var passes []passRow
|
|
for _, p := range contracts.RegisteredFrameworkRoutePasses() {
|
|
passes = append(passes, passRow{Name: p.Name(), Languages: p.Languages(), Routes: frameworkCounts[p.Name()]})
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, p := range passes {
|
|
fmt.Fprintf(&b, "%-20s %v (%d routes)\n", p.Name, p.Languages, p.Routes)
|
|
}
|
|
if len(passes) == 0 {
|
|
b.WriteString("no registered route frameworks\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"passes": passes,
|
|
"route_counts_by_framework": frameworkCounts,
|
|
"total_passes": len(passes),
|
|
})
|
|
}
|
|
|
|
// routeFramework reads the framework label off a contract node's Meta
|
|
// (top-level or the nested contract_meta map).
|
|
func routeFramework(n *graph.Node) string {
|
|
if n == nil {
|
|
return ""
|
|
}
|
|
if meta, ok := n.Meta["contract_meta"].(map[string]any); ok {
|
|
if fw, _ := meta["framework"].(string); fw != "" {
|
|
return fw
|
|
}
|
|
}
|
|
fw, _ := n.Meta["framework"].(string)
|
|
return fw
|
|
}
|
|
|
|
// handleAnalyzeDrupalHooks rolls up every detected Drupal hook
|
|
// implementation, grouped by the hook it implements — the queryable face of
|
|
// the hook layer ("which modules implement hook_node_insert?").
|
|
// handleAnalyzeSwiftUIViews groups SwiftUI types by their classified role
|
|
// (component / app_entry), stamped on Meta["swiftui_role"] by the extractor.
|
|
func (s *Server) handleAnalyzeSwiftUIViews(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
roleFilter := strings.TrimSpace(stringArg(req.GetArguments(), "role"))
|
|
byRole := map[string][]string{}
|
|
// swiftui_views groups SwiftUI types straight off s.graph.AllNodes(). Gate
|
|
// the contributing loop on visibility so each role's member list (and its
|
|
// recomputed Count) covers only the session workspace + optional repo
|
|
// allow-set; a role with no in-scope members never gets a map key, so it
|
|
// drops out naturally. Unbound sessions keep every type (no-op gate).
|
|
scoped := s.scopeFiltersActive(ctx)
|
|
for _, n := range s.graph.AllNodes() {
|
|
if n == nil || n.Meta == nil {
|
|
continue
|
|
}
|
|
role, _ := n.Meta["swiftui_role"].(string)
|
|
if role == "" || (roleFilter != "" && role != roleFilter) {
|
|
continue
|
|
}
|
|
if scoped && !s.analyzeNodeVisible(ctx, n) {
|
|
continue
|
|
}
|
|
byRole[role] = append(byRole[role], n.ID)
|
|
}
|
|
type roleRow struct {
|
|
Role string `json:"role"`
|
|
Types []string `json:"types"`
|
|
Count int `json:"count"`
|
|
}
|
|
rows := make([]roleRow, 0, len(byRole))
|
|
for r, ids := range byRole {
|
|
sort.Strings(ids)
|
|
rows = append(rows, roleRow{Role: r, Types: ids, Count: len(ids)})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Role < rows[j].Role })
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%s: %d\n", r.Role, r.Count)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no swiftui views\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{"roles": rows, "total": len(rows)})
|
|
}
|
|
|
|
// handleAnalyzeUIKitClasses groups UIKit types by their classified role
|
|
// (view_controller / view / cell), stamped on Meta["uikit_role"].
|
|
func (s *Server) handleAnalyzeUIKitClasses(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
roleFilter := strings.TrimSpace(stringArg(req.GetArguments(), "role"))
|
|
byRole := map[string][]string{}
|
|
// uikit_classes groups UIKit types straight off s.graph.AllNodes(). Gate
|
|
// the contributing loop on visibility so each role's member list (and its
|
|
// recomputed Count) covers only the session workspace + optional repo
|
|
// allow-set; a role with no in-scope members never gets a map key, so it
|
|
// drops out naturally. Unbound sessions keep every type (no-op gate).
|
|
scoped := s.scopeFiltersActive(ctx)
|
|
for _, n := range s.graph.AllNodes() {
|
|
if n == nil || n.Meta == nil {
|
|
continue
|
|
}
|
|
role, _ := n.Meta["uikit_role"].(string)
|
|
if role == "" || (roleFilter != "" && role != roleFilter) {
|
|
continue
|
|
}
|
|
if scoped && !s.analyzeNodeVisible(ctx, n) {
|
|
continue
|
|
}
|
|
byRole[role] = append(byRole[role], n.ID)
|
|
}
|
|
type roleRow struct {
|
|
Role string `json:"role"`
|
|
Classes []string `json:"classes"`
|
|
Count int `json:"count"`
|
|
}
|
|
rows := make([]roleRow, 0, len(byRole))
|
|
for r, ids := range byRole {
|
|
sort.Strings(ids)
|
|
rows = append(rows, roleRow{Role: r, Classes: ids, Count: len(ids)})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Role < rows[j].Role })
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%s: %d\n", r.Role, r.Count)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no uikit classes\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{"roles": rows, "total": len(rows)})
|
|
}
|
|
|
|
func (s *Server) handleAnalyzeDrupalHooks(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
nameFilter := strings.TrimSpace(stringArg(req.GetArguments(), "name"))
|
|
hooks := map[string][]string{}
|
|
// drupal_hooks groups hook implementations straight off s.graph.AllNodes().
|
|
// Gate the contributing loop on visibility so each hook's implementation
|
|
// list (and its recomputed Count) covers only the session workspace +
|
|
// optional repo allow-set; a hook with no in-scope implementations never
|
|
// gets a map key, so it drops out naturally. Unbound sessions keep every
|
|
// implementation (no-op gate).
|
|
scoped := s.scopeFiltersActive(ctx)
|
|
for _, n := range s.graph.AllNodes() {
|
|
if n == nil || n.Meta == nil {
|
|
continue
|
|
}
|
|
hook, _ := n.Meta["drupal_hook"].(string)
|
|
if hook == "" || (nameFilter != "" && hook != nameFilter) {
|
|
continue
|
|
}
|
|
if scoped && !s.analyzeNodeVisible(ctx, n) {
|
|
continue
|
|
}
|
|
hooks[hook] = append(hooks[hook], n.ID)
|
|
}
|
|
type hookRow struct {
|
|
Hook string `json:"hook"`
|
|
Implementations []string `json:"implementations"`
|
|
Count int `json:"count"`
|
|
}
|
|
rows := make([]hookRow, 0, len(hooks))
|
|
for h, impls := range hooks {
|
|
sort.Strings(impls)
|
|
rows = append(rows, hookRow{Hook: h, Implementations: impls, Count: len(impls)})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Hook < rows[j].Hook })
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%s: %d implementation(s)\n", r.Hook, r.Count)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no drupal hooks\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{"hooks": rows, "total": len(rows)})
|
|
}
|
|
|
|
// routeMethodAndPath pulls the most useful pair of fields out of a
|
|
// KindContract node's Meta. HTTP and WS routes use Meta["method"] +
|
|
// Meta["path"]; gRPC uses Meta["service"] + Meta["method"]; topic uses
|
|
// Meta["topic"]; GraphQL uses Meta["operation"] + Meta["field"].
|
|
func routeMethodAndPath(n *graph.Node) (string, string) {
|
|
if n == nil {
|
|
return "", ""
|
|
}
|
|
// The route fields live in the nested contract_meta map — the
|
|
// contract's own Meta, copied in wholesale at node-build time. The
|
|
// node's top-level Meta only carries type/role/symbol_id/line/
|
|
// confidence, so reading these keys off n.Meta directly always
|
|
// missed. Fall back to the top level for any node that does stamp
|
|
// them there.
|
|
meta, _ := n.Meta["contract_meta"].(map[string]any)
|
|
if meta == nil {
|
|
meta = n.Meta
|
|
}
|
|
method, _ := meta["method"].(string)
|
|
path, _ := meta["path"].(string)
|
|
if path != "" || method != "" {
|
|
return method, path
|
|
}
|
|
if topic, ok := meta["topic"].(string); ok && topic != "" {
|
|
return "", topic
|
|
}
|
|
if op, ok := meta["operation"].(string); ok && op != "" {
|
|
field, _ := meta["field"].(string)
|
|
return op, field
|
|
}
|
|
if svc, ok := meta["service"].(string); ok && svc != "" {
|
|
return method, svc
|
|
}
|
|
return method, path
|
|
}
|
|
|
|
// handleAnalyzeModels surfaces the EdgeModelsTable graph layer: every
|
|
// model class that maps to a database table. Useful for "which model
|
|
// owns the orders table?" and "which tables does this codebase
|
|
// persist?" queries.
|
|
//
|
|
// Filters:
|
|
// - orm: orm flavour (gorm / sqlalchemy / django / activerecord / jpa / typeorm)
|
|
// - table: substring match on the table name
|
|
// - model: substring match on the model class name
|
|
func (s *Server) handleAnalyzeModels(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
ormFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "orm")))
|
|
tableFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "table")))
|
|
modelFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "model")))
|
|
|
|
type modelRow struct {
|
|
Model string `json:"model"`
|
|
Table string `json:"table"`
|
|
ORM string `json:"orm"`
|
|
Derivation string `json:"derivation,omitempty"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
}
|
|
var rows []*modelRow
|
|
for e := range edgesByKinds(s.graph, graph.EdgeModelsTable) {
|
|
modelNode := s.graph.GetNode(e.From)
|
|
if modelNode == nil {
|
|
continue
|
|
}
|
|
orm, _ := e.Meta["orm"].(string)
|
|
if ormFilter != "" && strings.ToLower(orm) != ormFilter {
|
|
continue
|
|
}
|
|
tableName, _ := e.Meta["table_name"].(string)
|
|
if tableName == "" {
|
|
tableNode := s.graph.GetNode(e.To)
|
|
if tableNode != nil {
|
|
tableName = tableNode.Name
|
|
}
|
|
}
|
|
if tableFilter != "" && !strings.Contains(strings.ToLower(tableName), tableFilter) {
|
|
continue
|
|
}
|
|
if modelFilter != "" && !strings.Contains(strings.ToLower(modelNode.Name), modelFilter) {
|
|
continue
|
|
}
|
|
derivation, _ := e.Meta["derivation"].(string)
|
|
rows = append(rows, &modelRow{
|
|
Model: modelNode.ID,
|
|
Table: tableName,
|
|
ORM: orm,
|
|
Derivation: derivation,
|
|
File: e.FilePath,
|
|
Line: e.Line,
|
|
})
|
|
}
|
|
// models reads EdgeModelsTable directly off s.graph; narrow each row to the
|
|
// session workspace + optional repo allow-set. Table is a plain name (not a
|
|
// node ID), so visibility hinges on the model node (e.From, r.Model) only.
|
|
// Unbound sessions see every model (no-op gate); total recomputes below.
|
|
if s.scopeFiltersActive(ctx) {
|
|
kept := make([]*modelRow, 0, len(rows))
|
|
for _, r := range rows {
|
|
if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Model)) {
|
|
kept = append(kept, r)
|
|
}
|
|
}
|
|
rows = kept
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].ORM != rows[j].ORM {
|
|
return rows[i].ORM < rows[j].ORM
|
|
}
|
|
if rows[i].Table != rows[j].Table {
|
|
return rows[i].Table < rows[j].Table
|
|
}
|
|
return rows[i].Model < rows[j].Model
|
|
})
|
|
if s.isGCX(ctx, req) {
|
|
items := make([]modelItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, modelItem(*r))
|
|
}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("models", items))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%s %-12s %s → %s (%s:%d)\n", r.ORM, r.Derivation, r.Model, r.Table, r.File, r.Line)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no model→table edges\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"models": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
// handleAnalyzeComponents surfaces the EdgeRendersChild graph layer:
|
|
// the parent → child component dependency tree. Two views:
|
|
//
|
|
// - rollup (no `id`): per-component fan-in / fan-out summary so the
|
|
// agent sees which components are central (high fan-in =
|
|
// widely-rendered shared component; high fan-out = composite
|
|
// view).
|
|
// - per-component (id=<symbol>): list of every child the component
|
|
// renders with their resolved targets.
|
|
func (s *Server) handleAnalyzeComponents(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
idFilter := strings.TrimSpace(stringArg(args, "id"))
|
|
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
|
|
|
|
if idFilter != "" {
|
|
return s.componentsForOne(ctx, req, idFilter)
|
|
}
|
|
return s.componentsRollup(ctx, req, nameFilter)
|
|
}
|
|
|
|
// componentsRollup groups EdgeRendersChild edges per parent + per
|
|
// child to produce a fan-in / fan-out leaderboard.
|
|
func (s *Server) componentsRollup(ctx context.Context, req mcp.CallToolRequest, nameFilter string) (*mcp.CallToolResult, error) {
|
|
type compRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
FanIn int `json:"fan_in"`
|
|
FanOut int `json:"fan_out"`
|
|
File string `json:"file,omitempty"`
|
|
}
|
|
stats := map[string]*compRow{}
|
|
get := func(id string) *compRow {
|
|
row, ok := stats[id]
|
|
if ok {
|
|
return row
|
|
}
|
|
name := id
|
|
file := ""
|
|
if n := s.graph.GetNode(id); n != nil {
|
|
name = n.Name
|
|
file = n.FilePath
|
|
} else if i := strings.LastIndex(id, "::"); i >= 0 {
|
|
name = id[i+2:]
|
|
}
|
|
row = &compRow{ID: id, Name: name, File: file}
|
|
stats[id] = row
|
|
return row
|
|
}
|
|
// components reads EdgeRendersChild directly off s.graph. When the request
|
|
// narrows scope, gate the edge loop on BOTH endpoints being visible so the
|
|
// fan-in / fan-out tallies (and which nodes enter `stats`) cover only the
|
|
// session workspace + optional repo allow-set — no out-of-scope neighbor
|
|
// inflates a count. Unbound sessions count every edge (no-op gate).
|
|
scoped := s.scopeFiltersActive(ctx)
|
|
for e := range edgesByKinds(s.graph, graph.EdgeRendersChild) {
|
|
if scoped && (!s.analyzeNodeVisible(ctx, s.graph.GetNode(e.From)) ||
|
|
!s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To))) {
|
|
continue
|
|
}
|
|
parent := get(e.From)
|
|
parent.FanOut++
|
|
// Skip the child if it never resolved to a real node — leaving
|
|
// it in the fan-in count would inflate uses-of-unresolved
|
|
// references and pollute the rollup. Resolved targets show up
|
|
// without the "unresolved::" prefix.
|
|
if !strings.HasPrefix(e.To, "unresolved::") {
|
|
child := get(e.To)
|
|
child.FanIn++
|
|
}
|
|
}
|
|
rows := make([]*compRow, 0, len(stats))
|
|
for _, r := range stats {
|
|
if nameFilter != "" && !strings.Contains(strings.ToLower(r.Name), nameFilter) {
|
|
continue
|
|
}
|
|
if r.FanIn == 0 && r.FanOut == 0 {
|
|
continue
|
|
}
|
|
// Belt-and-suspenders row gate: keep a row only when its component node
|
|
// is itself visible. Redundant given the edge-loop gate above (under
|
|
// scope, `stats` only holds visible nodes) but kept explicit per the
|
|
// scope contract; a strict no-op for unbound sessions.
|
|
if scoped && !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) {
|
|
continue
|
|
}
|
|
rows = append(rows, r)
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
ai := rows[i].FanIn + rows[i].FanOut
|
|
aj := rows[j].FanIn + rows[j].FanOut
|
|
if ai != aj {
|
|
return ai > aj
|
|
}
|
|
return rows[i].Name < rows[j].Name
|
|
})
|
|
if s.isGCX(ctx, req) {
|
|
items := make([]componentRollupItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, componentRollupItem(*r))
|
|
}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("components", items))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%-3d in / %-3d out %-30s (%s)\n", r.FanIn, r.FanOut, r.Name, r.ID)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no renders_child edges\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"components": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
// componentsForOne returns every child component a single parent
|
|
// renders, with the resolved-target indicator per row.
|
|
func (s *Server) componentsForOne(ctx context.Context, req mcp.CallToolRequest, parentID string) (*mcp.CallToolResult, error) {
|
|
type childRow struct {
|
|
To string `json:"to"`
|
|
Name string `json:"name"`
|
|
Resolved bool `json:"resolved"`
|
|
File string `json:"file,omitempty"`
|
|
Line int `json:"line"`
|
|
}
|
|
var rows []*childRow
|
|
// components(id=…) reads the parent's out-edges directly off s.graph. Under
|
|
// an active scope, emit no children when the requested parent is itself out
|
|
// of scope, and prune children to visible resolved targets (e.To). Unbound
|
|
// sessions see the parent and every child (both gates collapse to no-ops).
|
|
scoped := s.scopeFiltersActive(ctx)
|
|
parentInScope := !scoped || s.analyzeNodeVisible(ctx, s.graph.GetNode(parentID))
|
|
for _, e := range s.graph.GetOutEdges(parentID) {
|
|
if e.Kind != graph.EdgeRendersChild {
|
|
continue
|
|
}
|
|
if scoped && (!parentInScope || !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To))) {
|
|
continue
|
|
}
|
|
name, _ := e.Meta["child_name"].(string)
|
|
if name == "" {
|
|
if strings.HasPrefix(e.To, "unresolved::") {
|
|
name = strings.TrimPrefix(e.To, "unresolved::")
|
|
} else if n := s.graph.GetNode(e.To); n != nil {
|
|
name = n.Name
|
|
}
|
|
}
|
|
rows = append(rows, &childRow{
|
|
To: e.To,
|
|
Name: name,
|
|
Resolved: !strings.HasPrefix(e.To, "unresolved::"),
|
|
File: e.FilePath,
|
|
Line: e.Line,
|
|
})
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Line != rows[j].Line {
|
|
return rows[i].Line < rows[j].Line
|
|
}
|
|
return rows[i].Name < rows[j].Name
|
|
})
|
|
if s.isGCX(ctx, req) {
|
|
items := make([]componentChildItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, componentChildItem{
|
|
To: r.To,
|
|
Name: r.Name,
|
|
Resolved: boolStr(r.Resolved),
|
|
File: r.File,
|
|
Line: r.Line,
|
|
})
|
|
}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("components.children", items))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
marker := "✓"
|
|
if !r.Resolved {
|
|
marker = "?"
|
|
}
|
|
fmt.Fprintf(&b, "%s %s (%s:%d)\n", marker, r.Name, r.File, r.Line)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no children\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"parent": parentID,
|
|
"children": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
// handleAnalyzeDbtModels surfaces the dbt / SQLMesh graph layer: every
|
|
// KindTable node the dbt / SQLMesh extractor emitted (models, seeds,
|
|
// snapshots, sources), with its column count and its lineage fan-out /
|
|
// fan-in over EdgeDependsOn. Answers "which models have no columns
|
|
// documented?", "what feeds stg_orders?", "which sources does nothing
|
|
// consume?" without walking the graph by hand.
|
|
//
|
|
// Filters:
|
|
// - framework: dbt | sqlmesh
|
|
// - type: resource type — model / seed / snapshot / source.
|
|
// Named `type` (not `kind`) because the analyze
|
|
// dispatcher reserves `kind` for the analyzer name.
|
|
// - materialized: substring match on the materialization
|
|
// - name: substring match on the model / source name
|
|
func (s *Server) handleAnalyzeDbtModels(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
frameworkFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "framework")))
|
|
typeFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "type")))
|
|
matFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "materialized")))
|
|
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
|
|
|
|
type dbtModelRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Framework string `json:"framework"`
|
|
ResourceType string `json:"resource_type"`
|
|
Materialized string `json:"materialized,omitempty"`
|
|
Schema string `json:"schema,omitempty"`
|
|
Columns int `json:"columns"`
|
|
Upstream int `json:"upstream"`
|
|
Downstream int `json:"downstream"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
}
|
|
|
|
// First pass: collect the model nodes (KindTable nodes the dbt /
|
|
// SQLMesh extractor stamped with a `framework` meta key).
|
|
rowByID := map[string]*dbtModelRow{}
|
|
for _, n := range s.scopedNodes(ctx) {
|
|
if n.Kind != graph.KindTable {
|
|
continue
|
|
}
|
|
framework, _ := n.Meta["framework"].(string)
|
|
if framework != "dbt" && framework != "sqlmesh" {
|
|
continue
|
|
}
|
|
resourceType, _ := n.Meta["resource_type"].(string)
|
|
materialized, _ := n.Meta["materialized"].(string)
|
|
schema, _ := n.Meta["schema"].(string)
|
|
rowByID[n.ID] = &dbtModelRow{
|
|
ID: n.ID, Name: n.Name, Framework: framework,
|
|
ResourceType: resourceType, Materialized: materialized,
|
|
Schema: schema, File: n.FilePath, Line: n.StartLine,
|
|
}
|
|
}
|
|
|
|
// Second pass: tally columns (EdgeMemberOf → model) and lineage
|
|
// (EdgeDependsOn between two model nodes) in one walk of AllEdges.
|
|
for e := range edgesByKinds(s.graph, graph.EdgeMemberOf, graph.EdgeDependsOn) {
|
|
switch e.Kind {
|
|
case graph.EdgeMemberOf:
|
|
if r := rowByID[e.To]; r != nil {
|
|
r.Columns++
|
|
}
|
|
case graph.EdgeDependsOn:
|
|
if r := rowByID[e.From]; r != nil {
|
|
r.Upstream++
|
|
}
|
|
if r := rowByID[e.To]; r != nil {
|
|
r.Downstream++
|
|
}
|
|
}
|
|
}
|
|
|
|
var rows []*dbtModelRow
|
|
for _, r := range rowByID {
|
|
if frameworkFilter != "" && r.Framework != frameworkFilter {
|
|
continue
|
|
}
|
|
if typeFilter != "" && strings.ToLower(r.ResourceType) != typeFilter {
|
|
continue
|
|
}
|
|
if matFilter != "" && !strings.Contains(strings.ToLower(r.Materialized), matFilter) {
|
|
continue
|
|
}
|
|
if nameFilter != "" && !strings.Contains(strings.ToLower(r.Name), nameFilter) {
|
|
continue
|
|
}
|
|
rows = append(rows, r)
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Framework != rows[j].Framework {
|
|
return rows[i].Framework < rows[j].Framework
|
|
}
|
|
if rows[i].ResourceType != rows[j].ResourceType {
|
|
return rows[i].ResourceType < rows[j].ResourceType
|
|
}
|
|
return rows[i].Name < rows[j].Name
|
|
})
|
|
|
|
if s.isGCX(ctx, req) {
|
|
items := make([]dbtModelItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, dbtModelItem(*r))
|
|
}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("dbt_models", items))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%-7s %-9s %-14s %-30s %2d cols %d↑ %d↓ (%s:%d)\n",
|
|
r.Framework, r.ResourceType, r.Materialized, r.Name,
|
|
r.Columns, r.Upstream, r.Downstream, r.File, r.Line)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no dbt / SQLMesh models\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"dbt_models": rows,
|
|
"total": len(rows),
|
|
})
|
|
}
|
|
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
// routeItem is the GCX1 row layout for the routes analyzer.
|
|
type routeItem struct {
|
|
Handler string `gcx:"handler"`
|
|
Route string `gcx:"route"`
|
|
Method string `gcx:"method"`
|
|
Path string `gcx:"path"`
|
|
Kind string `gcx:"kind"`
|
|
File string `gcx:"file"`
|
|
Line int `gcx:"line"`
|
|
}
|
|
|
|
// modelItem is the GCX1 row layout for the models analyzer.
|
|
type modelItem struct {
|
|
Model string `gcx:"model"`
|
|
Table string `gcx:"table"`
|
|
ORM string `gcx:"orm"`
|
|
Derivation string `gcx:"derivation"`
|
|
File string `gcx:"file"`
|
|
Line int `gcx:"line"`
|
|
}
|
|
|
|
// componentRollupItem is the GCX1 row layout for the components rollup.
|
|
type componentRollupItem struct {
|
|
ID string `gcx:"id"`
|
|
Name string `gcx:"name"`
|
|
FanIn int `gcx:"fan_in"`
|
|
FanOut int `gcx:"fan_out"`
|
|
File string `gcx:"file"`
|
|
}
|
|
|
|
// componentChildItem is the GCX1 row layout for per-component children.
|
|
type componentChildItem struct {
|
|
To string `gcx:"to"`
|
|
Name string `gcx:"name"`
|
|
Resolved string `gcx:"resolved"`
|
|
File string `gcx:"file"`
|
|
Line int `gcx:"line"`
|
|
}
|
|
|
|
// dbtModelItem is the GCX1 row layout for the dbt_models analyzer. The
|
|
// field set mirrors the JSON dbtModelRow one-for-one so the
|
|
// dbtModelItem(*r) conversion in handleAnalyzeDbtModels stays valid.
|
|
type dbtModelItem struct {
|
|
ID string `gcx:"id"`
|
|
Name string `gcx:"name"`
|
|
Framework string `gcx:"framework"`
|
|
ResourceType string `gcx:"resource_type"`
|
|
Materialized string `gcx:"materialized"`
|
|
Schema string `gcx:"schema"`
|
|
Columns int `gcx:"columns"`
|
|
Upstream int `gcx:"upstream"`
|
|
Downstream int `gcx:"downstream"`
|
|
File string `gcx:"file"`
|
|
Line int `gcx:"line"`
|
|
}
|