Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b3104bc
feat(neighborhood): add get_neighborhood MCP tool + REST endpoint (T12)
nadaverell May 17, 2026
662a91e
fix(neighborhood): apply RBAC during BFS expansion, not after
nadaverell May 17, 2026
acfd0b8
fix(neighborhood): normalize empty + unknown profile strings to Profi…
nadaverell May 17, 2026
b75cb24
perf(neighborhood): thread RelationshipsIndex through BFS expansion
nadaverell May 17, 2026
acebea6
fix(neighborhood): per-kind Secret RBAC + group-aware root lookup
nadaverell May 17, 2026
28aa9dd
fix(neighborhood): root Allow gate + group-aware root match + pseudo-…
nadaverell May 17, 2026
bfa265a
fix(neighborhood): case-insensitive pseudoKindFor + centralize cluste…
nadaverell May 17, 2026
1a9bbd8
fix(neighborhood): per-variant SAR for NodeClass + IncludeSecrets roo…
nadaverell May 17, 2026
18dc354
fix(neighborhood): root preflight handles cluster-scoped pseudo-kinds…
nadaverell May 17, 2026
10ac2b6
fix(neighborhood): lift ResolveProfile to pkg/topology + use topology…
nadaverell May 18, 2026
818102e
fix(neighborhood): use topology root.Kind in MCP response too
nadaverell May 18, 2026
63c19c3
fix(neighborhood): apply Hops clamp at handler level for symmetry wit…
nadaverell May 18, 2026
1a03c5e
refactor(topology): single APIVersionGroup helper across REST + MCP +…
nadaverell May 18, 2026
93f1c20
refactor(neighborhood): lift RBAC tuple selection into pkg/topology
nadaverell May 18, 2026
074ef81
chore(topology): drop unused LookupClusterScopedTopoKind
nadaverell May 18, 2026
fa71030
chore(topology): drop dead `considered` counter in RBACTuplesForKind
nadaverell May 18, 2026
c2f8d49
Simplify neighborhood profile surface
nadaverell May 18, 2026
a1400aa
chore(resourcecontext): mirror T6 speculative-surface cleanup
nadaverell May 18, 2026
875b356
fix(neighborhood): address Bugbot findings on T12
nadaverell May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 20 additions & 41 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,19 @@ func registerTools(server *mcp.Server) {
Annotations: readOnly,
}, logToolCall("get_topology", handleGetTopology))

mcp.AddTool(server, &mcp.Tool{
Name: "get_neighborhood",
Description: "Get the BFS-expanded neighborhood of a specific resource — the slice " +
"of the topology graph immediately relevant to one root. Cheaper and more " +
"focused than get_topology when you already know which resource you care " +
"about. Profile is 'auto' (default — picks a bounded edge set from the root " +
"kind) or 'all' (every edge type). Hops controls BFS depth (default 1, max " +
"2). Nodes are RBAC-filtered against the caller; dropped neighbors are " +
"listed in `omitted` with reason=rbac_denied. If max_nodes is exceeded " +
"mid-expansion, truncated=true is set and a partial subgraph is returned.",
Annotations: readOnly,
}, logToolCall("get_neighborhood", handleGetNeighborhood))

mcp.AddTool(server, &mcp.Tool{
Name: "get_events",
Description: "Get recent Kubernetes warning events, deduplicated and sorted by recency. " +
Expand Down Expand Up @@ -831,50 +844,16 @@ func handleGetTopology(ctx context.Context, req *mcp.CallToolRequest, input topo
return toJSONResult(topo)
}

// clusterScopedTopologyKinds maps topology NodeKinds for cluster-scoped
// resources to the (group, resource) tuple a SAR needs. Topology pulls
// these from the SA-populated cache regardless of the caller's namespace
// scope, so callers without per-kind RBAC must have them stripped.
//
// This is a denylist: it must enumerate every cluster-scoped kind the
// topology builder creates. Drift here = silent leak. See the checklist
// comment on NodeKind in pkg/topology/types.go and the planned scope-
// driven follow-up that removes the central table.
//
// KindNamespace is intentionally excluded — handled by per-user filter
// upstream. KindNodeClass has multiple entries (one per cloud provider)
// because the topology builder iterates EC2NodeClass / AKSNodeClass /
// GCPNodeClass under the same NodeKind label; a denial on any provider
// strips all NodeClass nodes. canReadClusterScopedKind's unknown-kind
// passthrough makes providers absent from the cluster's discovery
// non-blocking.
var clusterScopedTopologyKinds = []struct {
kind topology.NodeKind
group string
resource string
}{
{topology.KindNode, "", "nodes"},
{topology.KindNodePool, "karpenter.sh", "nodepools"},
{topology.KindNodeClaim, "karpenter.sh", "nodeclaims"},
{topology.KindNodeClass, "karpenter.k8s.aws", "ec2nodeclasses"},
{topology.KindNodeClass, "karpenter.azure.com", "aksnodeclasses"},
{topology.KindNodeClass, "karpenter.k8s.gcp", "gcpnodeclasses"},
{topology.KindGatewayClass, "gateway.networking.k8s.io", "gatewayclasses"},
{topology.KindPV, "", "persistentvolumes"},
{topology.KindStorageClass, "storage.k8s.io", "storageclasses"},
{topology.KindCiliumClusterwideNetworkPolicy, "cilium.io", "ciliumclusterwidenetworkpolicies"},
{topology.KindClusterNetworkPolicy, "policy.networking.k8s.io", "clusternetworkpolicies"},
}

// deniedClusterScopedTopoKinds returns the set of cluster-scoped topology
// NodeKinds the calling user cannot list. Reuses canReadClusterScopedKind's
// per-user canI cache so subsequent topology calls within the same TTL
// don't re-SAR.
// NodeKinds the calling user cannot list. Walks topology.ClusterScopedKinds
// (centralized table — see pkg/topology/cluster_scoped_kinds.go). Reuses
// canReadClusterScopedKind's per-user canI cache so subsequent topology
// calls within the same TTL don't re-SAR.
func deniedClusterScopedTopoKinds(ctx context.Context) map[topology.NodeKind]bool {
deny := make(map[topology.NodeKind]bool)
for _, ck := range clusterScopedTopologyKinds {
if !canReadClusterScopedKind(ctx, ck.resource, ck.group, "list") {
deny[ck.kind] = true
for _, ck := range topology.ClusterScopedKinds {
if !canReadClusterScopedKind(ctx, ck.Resource, ck.Group, "list") {
deny[ck.Kind] = true
}
}
return deny
Expand Down
278 changes: 278 additions & 0 deletions internal/mcp/tools_neighborhood.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
package mcp

import (
"context"
"fmt"
"strings"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/skyhook-io/radar/internal/k8s"
"github.com/skyhook-io/radar/pkg/resourcecontext"
"github.com/skyhook-io/radar/pkg/topology"
)

// Neighborhood tool input.
type getNeighborhoodInput struct {
Kind string `json:"kind" jsonschema:"resource kind: pod, deployment, service, application, etc."`
Group string `json:"group,omitempty" jsonschema:"API group required to disambiguate kinds that collide across groups. Examples: serving.knative.dev for KNative Service (vs core/v1 Service), cluster.x-k8s.io for CAPI Cluster (vs CNPG Cluster), networking.istio.io for Istio Gateway (vs gateway.networking.k8s.io Gateway). Omit for kinds with no known collisions."`
Namespace string `json:"namespace,omitempty" jsonschema:"resource namespace; omit for cluster-scoped kinds"`
Name string `json:"name" jsonschema:"resource name"`
Profile string `json:"profile,omitempty" jsonschema:"neighborhood breadth: auto or all. Default: auto (picks a bounded edge set from the root kind)."`
Hops int `json:"hops,omitempty" jsonschema:"BFS depth. Default 1, max 2."`
MaxNodes int `json:"max_nodes,omitempty" jsonschema:"node-budget cap. Default 25. When the cap is hit mid-expansion, truncated=true is set and the partial subgraph is returned."`
}

// neighborhoodResult is the MCP wire shape. Matches the REST envelope so
// agents that consume both surfaces parse identically.
type neighborhoodResult struct {
Root topology.ResourceRef `json:"root"`
Subgraph neighborhoodSubgraphMCP `json:"subgraph"`
Truncated bool `json:"truncated"`
Omitted []resourcecontext.OmittedField `json:"omitted,omitempty"`
}

type neighborhoodSubgraphMCP struct {
Nodes []topology.Node `json:"nodes"`
Edges []topology.Edge `json:"edges"`
}

func handleGetNeighborhood(ctx context.Context, req *mcp.CallToolRequest, input getNeighborhoodInput) (*mcp.CallToolResult, any, error) {
cache := k8s.GetResourceCache()
if cache == nil {
return nil, nil, fmt.Errorf("not connected to cluster")
}
if input.Kind == "" || input.Name == "" {
return nil, nil, fmt.Errorf("kind and name are required")
}

// RBAC for the root. Topology pseudo-kinds (NodeClass, NodePool, NodeClaim,
// …) FIRST: ClassifyKindScope doesn't recognize them ("nodeclass" isn't a
// real K8s kind — the variants are EC2NodeClass / AKSNodeClass / GCPNodeClass).
// Without this branch we fall into the namespaced arm below and reject as
// "namespace is required" even though the agent sees these kinds in
// get_topology output. topology.RBACTuplesForKind returns the per-variant
// SAR tuples — we iterate through canReadInNamespace and allow on any
// pass, matching the per-node gate's first-success semantics.
if pseudoTuples, tracked, fallthroughAllow := topology.RBACTuplesForKind(input.Kind, input.Group, pseudoKindDiscoveryLookupMCP()); tracked {
if !allowPseudoKindTuplesMCP(ctx, pseudoTuples, fallthroughAllow) {
return nil, nil, fmt.Errorf("forbidden: %s requires explicit cluster-scoped RBAC", input.Kind)
}
} else if clusterScoped, gvrGroup, gvrResource := k8s.ClassifyKindScope(input.Kind, input.Group); clusterScoped {
if !canReadClusterScopedKind(ctx, gvrResource, gvrGroup, "get") {
return nil, nil, fmt.Errorf("forbidden: %s requires explicit cluster-scoped RBAC", input.Kind)
}
} else {
if input.Namespace == "" {
return nil, nil, fmt.Errorf("namespace is required for namespaced kinds")
}
if !checkNamespaceAccess(ctx, input.Namespace) {
return nil, nil, fmt.Errorf("forbidden: no access to namespace %q", input.Namespace)
}
}

opts := topology.NeighborhoodOptions{
Profile: resolveProfile(input.Profile),
Hops: input.Hops,
MaxNodes: input.MaxNodes,
}
if opts.Hops <= 0 {
opts.Hops = 1
}
if opts.MaxNodes <= 0 {
opts.MaxNodes = 25
}
// Top-end clamps symmetric with REST. BFS clamps Hops internally
// (neighborhoodMaxHops) but doing it here too means opts.Hops is
// correct if anything inspects/logs it before BFS.
if opts.Hops > 2 {
opts.Hops = 2
}
if opts.MaxNodes > 200 {
opts.MaxNodes = 200
}

// Build the full topology and slice via BFS. The MCP server doesn't own
// a topology memoizer (the REST server does), so we accept the per-call
// rebuild cost here — neighborhood is a low-frequency tool.
//
// dp is captured once and threaded into both Builder and BuildNeighborhoodWithIndex
// so root-ID construction can resolve CRD plurals correctly (without it,
// buildNodeID falls back to the static kindMap which only covers built-in kinds).
dp := k8s.NewTopologyDynamicProvider(k8s.GetDynamicResourceCache(), k8s.GetResourceDiscovery())
buildOpts := topology.DefaultBuildOptions()
buildOpts.IncludeReplicaSets = true
buildOpts.ForRelationshipCache = true
// Override DefaultBuildOptions' Secret-elision: with IncludeSecrets=false,
// root lookup for kind=secret produces an empty subgraph and the handler
// returns "resource not found" even for authorized users. The Allow gate
// below applies the per-namespace `get secrets` SAR per node, so
// unauthorized users still get the same "not found" via the empty-subgraph
// path — existence-hiding preserved.
buildOpts.IncludeSecrets = true
topo, err := topology.NewBuilder(k8s.NewTopologyResourceProvider(cache)).
WithDynamic(dp).
Build(buildOpts)
if err != nil {
return nil, nil, fmt.Errorf("failed to build topology: %w", err)
}
// Build the inverted index once and reuse it across BFS expansion plus
// any per-resource relationship lookups downstream. Without a memoizer
// the cost is paid every call, but it's still cheaper than scanning
// topo.Edges from inside the BFS loop (O(E) per hop level).
idx := topology.IndexByResource(topo)

root := topology.ResourceRef{
Kind: displayKindForMCP(input.Kind),
Namespace: input.Namespace,
Name: input.Name,
Group: input.Group,
}
// Pre-filter RBAC into BFS so forbidden nodes can't shape the visible
// graph (path-fragment effects, budget consumption). See the matching
// REST handler for the rationale.
opts.Allow = func(n *topology.Node) bool {
return canReadNeighborhoodNodeMCP(ctx, n)
}

sub := topology.BuildNeighborhoodWithIndex(topo, root, opts, idx, dp)
if sub.AmbiguousRoot {
return nil, nil, fmt.Errorf("resource kind is ambiguous for %s/%s/%s; provide group", input.Kind, input.Namespace, input.Name)
}
if len(sub.Nodes) == 0 {
return nil, nil, fmt.Errorf("resource not found in topology: %s/%s/%s", input.Kind, input.Namespace, input.Name)
}

// Use the resolved root node's Kind for the response. displayKindForMCP
// only normalizes built-in kinds (Pod, Deployment, …); pseudo-kinds
// like "nodeclass"/"nodepool" pass through lowercase while subgraph
// nodes carry display-form NodeKind ("NodeClass"). Without this rewrite
// the response's root.kind would diverge from subgraph.nodes[0].kind
// within the same payload. Matches the REST handler's identical fix.
rootResp := root
rootResp.Kind = string(sub.Nodes[0].Kind)

result := neighborhoodResult{
Root: rootResp,
Subgraph: neighborhoodSubgraphMCP{
Nodes: sub.Nodes,
Edges: sub.Edges,
},
Truncated: sub.Truncated,
}
Comment thread
cursor[bot] marked this conversation as resolved.
if sub.RBACDenied > 0 {
// Aggregated rather than per-node — denied node refs would
// re-leak existence info the Allow gate exists to hide.
result.Omitted = append(result.Omitted, resourcecontext.OmittedField{
Field: "subgraph.nodes",
Reason: resourcecontext.OmittedRBACDenied,
})
}
return toJSONResult(result)
}

// resolveProfile is retained as a thin shim around topology.ResolveProfile
// so the local call sites in this file don't need updating. New callers
// should use topology.ResolveProfile directly.
func resolveProfile(s string) topology.Profile {
return topology.ResolveProfile(s)
}

// displayKindForMCP normalizes a lowercased / plural kind into the
// display-form used by topology nodes. MCP inputs are lowercase by
// convention; the topology graph uses display forms (Pod, Deployment, …).
func displayKindForMCP(kind string) string {
return normalizeDisplayKind(strings.ToLower(kind))
}

// canReadNeighborhoodNodeMCP is the MCP-side per-node RBAC gate. Mirrors
// the REST canReadNeighborhoodNode — same decision tree, different per-user
// check function. Tuple-selection logic lives in topology.RBACTuplesForNode
// so both surfaces stay in lockstep when the pseudo-kind table or Secret-
// tightening rules evolve.
//
// See REST canReadNeighborhoodNode for the Secret SAR rationale — namespace
// access alone leaks Secrets when the cache SA has cluster-wide secrets RBAC
// the calling user doesn't.
func canReadNeighborhoodNodeMCP(ctx context.Context, n *topology.Node) bool {
// Namespace-list gate is protocol-specific; apply it here for namespaced
// nodes BEFORE consulting the shared helper.
if n != nil && n.Data != nil {
if ns, ok := n.Data["namespace"].(string); ok && ns != "" {
if !checkNamespaceAccess(ctx, ns) {
return false
}
}
}

decision, tuples := topology.RBACTuplesForNode(n, pseudoKindDiscoveryLookupMCP())
switch decision {
case topology.NodeRBACAllow:
return true
case topology.NodeRBACDeny:
return false
case topology.NodeRBACCheckTuples:
for _, t := range tuples {
if canReadInNamespace(ctx, t.Group, t.Resource, t.Namespace, "get") {
return true
}
}
return false
case topology.NodeRBACConsultClassifyKindScope:
// Cluster-scoped node that isn't a tracked pseudo-kind. Fall back to
// the regular static-catalogue / discovery path. Unclassified kinds
// allow-through: the topology graph wouldn't have surfaced the node
// for an unprivileged SA either.
group := ""
if n.Data != nil {
if v, ok := n.Data["apiVersion"].(string); ok {
group = topology.APIVersionGroup(v)
}
}
clusterScoped, gvrGroup, gvrResource := k8s.ClassifyKindScope(string(n.Kind), group)
if !clusterScoped {
return true
}
return canReadClusterScopedKind(ctx, gvrResource, gvrGroup, "get")
default:
// New decision values must be handled explicitly; default-deny.
return false
}
}

// pseudoKindDiscoveryLookupMCP returns the function form of the discovery
// singleton that topology.RBACTuplesForKind / RBACTuplesForNode expect, or
// nil when discovery isn't initialised (test envs). See the doc on
// topology.PseudoKindDiscoveryLookup for why this is a function rather than
// an interface (typed-nil-into-interface gotcha).
func pseudoKindDiscoveryLookupMCP() topology.PseudoKindDiscoveryLookup {
disc := k8s.GetResourceDiscovery()
if disc == nil {
return nil
}
return disc.GetResourceWithGroup
}

// allowPseudoKindTuplesMCP authorizes a list of per-variant SAR tuples
// returned by topology.RBACTuplesForKind for the root-preflight path.
// Iterates each tuple through canReadInNamespace and allows on the first
// pass; if the helper returned zero tuples + fallthroughAllow=true (every
// variant was filtered out by discovery), allow — matches the pre-existing
// "over-include on absent provider variants" behavior.
//
// We use canReadInNamespace(group, resource, "", "get") directly rather than
// canReadClusterScopedKind: canReadClusterScopedKind re-resolves the
// resource via ClassifyKindScope's discovery, which over-broadens
// (passthrough-allow) when the CRD is missing. The table is the source of
// truth for "this is cluster-scoped" — no need for discovery to re-confirm.
func allowPseudoKindTuplesMCP(ctx context.Context, tuples []topology.SARTuple, fallthroughAllow bool) bool {
if len(tuples) == 0 {
return fallthroughAllow
}
for _, t := range tuples {
if canReadInNamespace(ctx, t.Group, t.Resource, t.Namespace, "get") {
return true
}
}
return false
}
Loading
Loading