Summary
neighbors_v2 returns wrong-labeled edges because Kuzu 0.11.3 silently drops the label(e) IN $list predicate. When the requested edge_types is a strict subset of the origin node's actual out-edges, the tool returns edges whose label is NOT in the requested set.
This is a silent correctness bug, not a performance regression. The output looks plausible (it returns Edges with real edge_type field populated) but the filter is a no-op.
Repro
import sys; sys.path.insert(0, '.')
from kuzu_queries import KuzuGraph
from mcp_v2 import neighbors_v2
g = KuzuGraph('/tmp/your_kuzu')
# Pick any method that has both DECLARES_CLIENT and CALLS edges out
mid = 'acf9c57a9f50a153542dc5d00f5f47fa4b188f8a' # from tests/bank-chat-system
out = neighbors_v2(ids=mid, direction='out', edge_types=['DECLARES_CLIENT'], graph=g)
print(out.results[0].edge_type) # observed: 'CALLS'
# expected: 'DECLARES_CLIENT'
Root cause
Direct Cypher probe against Kuzu 0.11.3 on tests/bank-chat-system fixture, method with 15 CALLS + 1 DECLARES_CLIENT + 1 HTTP_CALLS out-edges:
| Cypher predicate |
Rows returned |
Label seen |
label(e) IN $list_param (parameterized) |
1 |
CALLS |
label(e) IN ["DECLARES_CLIENT"] (literal list) |
1 |
CALLS |
label(e) = "DECLARES_CLIENT" (equality) |
1 |
DECLARES_CLIENT ✓ |
MATCH (a)-[e:DECLARES_CLIENT]->(b) (typed pattern) |
1 |
DECLARES_CLIENT ✓ |
| no filter |
17 |
15 CALLS + 1 DECLARES_CLIENT + 1 HTTP_CALLS (ground truth) |
So label(e) IN $list is broken in both parameterized and literal forms. Typed-pattern matching and equality work correctly. This is upstream — Kuzu issue, not user-rag issue — but our code uses the broken predicate.
Call sites
Only two, both in mcp_v2.py:
- Line 547–548:
direction="out" branch
- Line 558–559:
direction="in" branch
No other code in the repo uses label(e) IN or label(r) IN.
Why we didn't see this earlier
The bug only surfaces when the requested labels are a strict subset of the actual edges on the origin node. Tests typically:
- Ask for one specific label on a node where that's the only out-edge → broken filter is a no-op, looks right
- Ask for "all" labels (no edge_types filter, or covering all present labels) → broken filter is a no-op, looks right
- Iterate per-label assertions → each iteration matches the "only one label exists" case
The failure shape ("returns 1 row of the wrong label") is also unusually misleading — it doesn't return 0, it doesn't error, it returns a plausible-looking Edge whose edge_type field is populated with the actual queried-edge label. Naive assertion assert out.results[0].edge_type == "X" would fail loud, but assertions checking len(results) > 0 would silently pass with garbage.
Proposed fix
Replace label(e) IN $edge_types with typed pattern matching:
# edge_types is already validated by _NEIGHBOR_EDGE_TYPES_ADAPTER against the
# EdgeType Literal allowlist — interpolation is injection-safe.
edge_pattern = "|".join(edge_types)
rows = g._rows(
f"MATCH (a)-[e:{edge_pattern}]->(b) WHERE a.id = $id "
f"RETURN b.id AS other_id, label(e) AS edge_type, "
f"e.confidence AS confidence, e.strategy AS strategy, ...",
{"id": origin_id},
)
Same shape for the direction="in" branch with <-[e:LABEL|...]-.
Why this is safe
EdgeType is Literal[...] (mcp_v2.py:17–31), enforced by _NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python(edge_types) (line 534) before the Cypher is built. Only allowlisted values reach the f-string.
- No new Cypher parameter contract — Kuzu's typed-pattern syntax is stable across recent versions.
- Identical semantics on the happy path; the only behavior change is that broken cases now return the correct subset instead of the wrong row.
Suggested test coverage
A regression test that fails today, passes after the fix:
def test_neighbors_filters_by_requested_edge_types():
"""Regression: Kuzu 0.11.3 label(e) IN $list silently drops the filter."""
g = build_test_graph_with_method_having_multiple_edge_types()
# method has 15 CALLS, 1 DECLARES_CLIENT, 1 HTTP_CALLS out
out = neighbors_v2(ids=method_id, direction='out',
edge_types=['DECLARES_CLIENT'], graph=g)
assert all(e.edge_type == 'DECLARES_CLIENT' for e in out.results)
assert len(out.results) == 1
out = neighbors_v2(ids=method_id, direction='out',
edge_types=['CALLS', 'HTTP_CALLS'], graph=g)
assert set(e.edge_type for e in out.results) == {'CALLS', 'HTTP_CALLS'}
assert len(out.results) == 16
Plus existing neighbors tests should be re-audited — they were running under broken-filter assumptions.
Severity
High. Every neighbors call with a non-trivial edge_types filter is suspect. The MCP V2 surface is the primary agent affordance, and neighbors is the only traversal primitive. Without this fix, the rollup-decomposition walk from issue #118 cannot work in practice — even when the agent does the right 2-hop decomposition, step 3 (neighbors([methods], "out", ["DECLARES_CLIENT"])) returns CALLS edges instead of clients.
Refs
Summary
neighbors_v2returns wrong-labeled edges because Kuzu 0.11.3 silently drops thelabel(e) IN $listpredicate. When the requestededge_typesis a strict subset of the origin node's actual out-edges, the tool returns edges whose label is NOT in the requested set.This is a silent correctness bug, not a performance regression. The output looks plausible (it returns Edges with real
edge_typefield populated) but the filter is a no-op.Repro
Root cause
Direct Cypher probe against Kuzu 0.11.3 on
tests/bank-chat-systemfixture, method with 15 CALLS + 1 DECLARES_CLIENT + 1 HTTP_CALLS out-edges:label(e) IN $list_param(parameterized)CALLSlabel(e) IN ["DECLARES_CLIENT"](literal list)CALLSlabel(e) = "DECLARES_CLIENT"(equality)DECLARES_CLIENT✓MATCH (a)-[e:DECLARES_CLIENT]->(b)(typed pattern)DECLARES_CLIENT✓So
label(e) IN $listis broken in both parameterized and literal forms. Typed-pattern matching and equality work correctly. This is upstream — Kuzu issue, not user-rag issue — but our code uses the broken predicate.Call sites
Only two, both in
mcp_v2.py:direction="out"branchdirection="in"branchNo other code in the repo uses
label(e) INorlabel(r) IN.Why we didn't see this earlier
The bug only surfaces when the requested labels are a strict subset of the actual edges on the origin node. Tests typically:
The failure shape ("returns 1 row of the wrong label") is also unusually misleading — it doesn't return 0, it doesn't error, it returns a plausible-looking Edge whose
edge_typefield is populated with the actual queried-edge label. Naive assertionassert out.results[0].edge_type == "X"would fail loud, but assertions checkinglen(results) > 0would silently pass with garbage.Proposed fix
Replace
label(e) IN $edge_typeswith typed pattern matching:Same shape for the
direction="in"branch with<-[e:LABEL|...]-.Why this is safe
EdgeTypeisLiteral[...](mcp_v2.py:17–31), enforced by_NEIGHBOR_EDGE_TYPES_ADAPTER.validate_python(edge_types)(line 534) before the Cypher is built. Only allowlisted values reach the f-string.Suggested test coverage
A regression test that fails today, passes after the fix:
Plus existing
neighborstests should be re-audited — they were running under broken-filter assumptions.Severity
High. Every
neighborscall with a non-trivialedge_typesfilter is suspect. The MCP V2 surface is the primary agent affordance, andneighborsis the only traversal primitive. Without this fix, the rollup-decomposition walk from issue #118 cannot work in practice — even when the agent does the right 2-hop decomposition, step 3 (neighbors([methods], "out", ["DECLARES_CLIENT"])) returns CALLS edges instead of clients.Refs
tests/bank-chat-system(any method with mixed-label out-edges)mcp_v2.py:547–567mcp_v2.py:17–31,:534