Skip to content

Commit 4337de8

Browse files
feat(mcp): EdgeFilter on neighbors_v2 with CALLS ordering and pushdown
Add typed edge_filter for CALLS-only projection (confidence, strategies, callee_declaring_role) with fail-loud schema validation, ORDER BY call site in Kuzu before pagination, and Decision 20/30 hints. Supersedes MCP-V2 no per-edge filter on neighbors (Decision 16). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2e369e4 commit 4337de8

7 files changed

Lines changed: 604 additions & 12 deletions

File tree

docs/AGENT-GUIDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,12 @@ Virtual keys (`OVERRIDDEN_BY`, …) are **not** valid `neighbors` arguments —
261261
#### `neighbors`
262262

263263
- **Purpose:** One hop over explicit edge types; returns **edges** with attributes (`confidence`, `strategy`, `match`, …) and the **`other`** node.
264-
- **Args:** `ids` (string or array — batch allowed), **`direction`** (`in`|`out`), **`edge_types`** (non-empty list), `limit`, `offset`, optional `filter` on the other node.
264+
- **Args:** `ids` (string or array — batch allowed), **`direction`** (`in`|`out`), **`edge_types`** (non-empty list), `limit`, `offset`, optional `filter` on the other node, optional **`edge_filter`** on CALLS edge attributes (single edge type only; fail-loud if an attribute is not on every requested label).
265+
- **CALLS ordering:** `edge_types=['CALLS']` results are delivered in **source order** (`call_site_line`, then `call_site_byte`). `edge_filter` predicates are applied in the graph query **before** `offset`/`limit`.
266+
- **`edge_filter` (CALLS):** `min_confidence`, `include_strategies` / `exclude_strategies` (mutually exclusive), `callee_declaring_role`, `callee_declaring_roles`, `exclude_callee_declaring_roles`. Example: `edge_filter={{"callee_declaring_role":"SERVICE"}}` for delegation hops; `exclude_callee_declaring_roles: ["ENTITY","DTO"]` drops accessor noise. **`exclude_callee_declaring_roles: ["OTHER"]` also drops known-external rows** (JDK/library callees are typically `OTHER`).
267+
- **`exclude_external` is for `find_callers` / `find_callees` only** (FQN-prefix trimming). On CALLS hops, trim JDK/library noise via `edge_filter` (`min_confidence`, `exclude_strategies`, role axes) — not FQN-prefix rules.
268+
- **Role-filter trap:** `NodeFilter.role` filters the **neighbor node's** role (method Symbols are almost always `OTHER`). `edge_filter.callee_declaring_role` filters the **callee declaring type's** stereotype — use that for “does this method call a repository/service?”.
269+
- **Accessor noise:** `exclude_callee_declaring_roles` helps; entity getter/setter heuristics live in the `/mini-map` skill ([`propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md`](../propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md)).
265270
- **Batching:** Multiple origins are expanded; pagination slices the **combined** edge list — use larger `limit` when batching many ids.
266271
- **Mixed flat + composed `edge_types`:** flat edges are appended before composed edges, then `limit`/`offset` apply. A small `limit` with e.g. `["DECLARES", "DECLARES.DECLARES_CLIENT"]` may return only member Symbols and no Clients — use the dot-key alone when enumerating terminals.
267272
- **Confidence:** Cross-service edges (`HTTP_CALLS`, `ASYNC_CALLS`) carry confidence, strategy, and match metadata on `edge.attrs` (`attrs.confidence`, `attrs.strategy`, `attrs.match`). Low confidence means the resolver had to guess at the route binding — treat it as a **resolver gap signal**, not a hallucination. Report low-confidence edges with their confidence value, not as facts. Intra-service edges (`CALLS`, `INJECTS`, `IMPLEMENTS`, `EXTENDS`, `DECLARES`, `DECLARES_CLIENT`, `EXPOSES`, `OVERRIDES`) faithfully represent the static graph; the resolved set is still a **lower bound** under reflection / dynamic dispatch (see *What this MCP is NOT*).

kuzu_queries.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,76 @@ def member_edge_traversal_for(self, type_id: str, composed_key: str) -> list[dic
667667
{"id": type_id, "rel": rel},
668668
)
669669

670+
def count_calls_for_symbol(self, origin_id: str, *, direction: Literal["in", "out"]) -> int:
671+
"""Count CALLS edges incident on a Symbol (hints / diagnostics)."""
672+
if direction == "out":
673+
pattern = "MATCH (origin:Symbol {id: $id})-[e:CALLS]->() RETURN count(e) AS n"
674+
else:
675+
pattern = "MATCH (origin:Symbol {id: $id})<-[e:CALLS]-() RETURN count(e) AS n"
676+
rows = self._rows(pattern, {"id": origin_id})
677+
return int(rows[0].get("n") or 0) if rows else 0
678+
679+
def neighbor_calls_for_symbol(
680+
self,
681+
origin_id: str,
682+
*,
683+
direction: Literal["in", "out"],
684+
offset: int = 0,
685+
limit: int | None = None,
686+
sql_pagination: bool = True,
687+
min_confidence: float | None = None,
688+
include_strategies: list[str] | None = None,
689+
exclude_strategies: list[str] | None = None,
690+
callee_declaring_role: str | None = None,
691+
callee_declaring_roles: list[str] | None = None,
692+
exclude_callee_declaring_roles: list[str] | None = None,
693+
) -> list[dict[str, Any]]:
694+
"""CALLS neighbors with source-order delivery and optional edge-attribute pushdown.
695+
696+
When ``sql_pagination`` is True and ``limit`` is set, ``SKIP``/``LIMIT`` apply after
697+
``ORDER BY e.call_site_line, e.call_site_byte``. Otherwise the full ordered stream is
698+
returned for caller-side ``NodeFilter`` / pagination.
699+
"""
700+
wh_parts = ["origin.id = $id"]
701+
params: dict[str, Any] = {"id": origin_id}
702+
if min_confidence is not None:
703+
wh_parts.append("e.confidence >= $min_confidence")
704+
params["min_confidence"] = min_confidence
705+
if include_strategies:
706+
wh_parts.append("e.strategy IN $include_strategies")
707+
params["include_strategies"] = include_strategies
708+
if exclude_strategies:
709+
wh_parts.append("NOT (e.strategy IN $exclude_strategies)")
710+
params["exclude_strategies"] = exclude_strategies
711+
if callee_declaring_role is not None:
712+
wh_parts.append("e.callee_declaring_role = $callee_declaring_role")
713+
params["callee_declaring_role"] = callee_declaring_role
714+
if callee_declaring_roles:
715+
wh_parts.append("e.callee_declaring_role IN $callee_declaring_roles")
716+
params["callee_declaring_roles"] = callee_declaring_roles
717+
if exclude_callee_declaring_roles:
718+
wh_parts.append("NOT (e.callee_declaring_role IN $exclude_callee_declaring_roles)")
719+
params["exclude_callee_declaring_roles"] = exclude_callee_declaring_roles
720+
where = " AND ".join(wh_parts)
721+
if direction == "out":
722+
match = "MATCH (origin:Symbol)-[e:CALLS]->(other:Symbol)"
723+
else:
724+
match = "MATCH (origin:Symbol)<-[e:CALLS]-(other:Symbol)"
725+
q = (
726+
f"{match} WHERE {where} "
727+
"RETURN other.id AS other_id, 'CALLS' AS edge_type, "
728+
"e.confidence AS confidence, e.strategy AS strategy, e.source AS source, "
729+
"e.call_site_line AS call_site_line, e.call_site_byte AS call_site_byte, "
730+
"e.arg_count AS arg_count, e.resolved AS resolved, "
731+
"e.callee_declaring_role AS callee_declaring_role "
732+
"ORDER BY e.call_site_line, e.call_site_byte"
733+
)
734+
if sql_pagination and limit is not None:
735+
q += " SKIP $offset LIMIT $limit"
736+
params["offset"] = offset
737+
params["limit"] = limit
738+
return self._rows(q, params)
739+
670740
def _edge_row_count_from_method_ids(self, method_ids: list[str], rel: str) -> int:
671741
"""Count outgoing ``rel`` edges from method symbols (describe rollup helper)."""
672742
total = 0

mcp_hints.py

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
"Maximum 5 hints per output. Describe-time type rollup hints may recommend "
2222
"DECLARES.* dot-keys for neighbors(); empty neighbors structural hints never use "
2323
"dot-key edge labels. For neighbors with multiple origin ids, empty-result "
24-
"structural hints describe the first origin only."
24+
"structural hints describe the first origin only. On neighbors with "
25+
"edge_types=['CALLS'], optional edge_filter projects the ordered CALLS stream "
26+
"(min_confidence, strategies, callee_declaring_role axes); include_unresolved and "
27+
"dedup_calls are separate knobs (mutually exclusive with edge_filter where noted)."
2528
)
2629

2730
# --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) ---
@@ -109,6 +112,18 @@
109112
"some edges resolved via brownfield/fallback strategy — check attrs.strategy on each row"
110113
)
111114

115+
TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK = (
116+
"0 CALLS matched callee_declaring_role filter but method has many callees — "
117+
"targets may be OTHER (interface/JDK); try "
118+
"edge_filter={{exclude_callee_declaring_roles: ['ENTITY','DTO']}} instead of role exact match"
119+
)
120+
121+
TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION = (
122+
"NodeFilter.role filters the neighbor method's role (usually OTHER), not the callee's "
123+
"declaring type — use edge_filter={{callee_declaring_role: 'SERVICE'}} (or REPOSITORY) "
124+
"for CALLS stereotype projection"
125+
)
126+
112127
# v4 neighbors success-path (propose/HINTS-V4-SUCCESS-PATH-PROPOSE.md); N1a/N1b alias describe templates.
113128
TPL_NEIGHBORS_SUCCESS_HTTP_TARGETS = "HTTP targets: neighbors(client_ids,'out',['HTTP_CALLS'])"
114129
TPL_NEIGHBORS_SUCCESS_ASYNC_TARGETS = "async targets: neighbors(producer_ids,'out',['ASYNC_CALLS'])"
@@ -329,6 +344,45 @@ def _append_neighbors_success_hint(pairs: list[tuple[int, str]], text: str) -> N
329344
pairs.append((PRIORITY_LEAF_FOLLOWUP, text))
330345

331346

347+
def neighbors_calls_meta_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
348+
"""CALLS-specific hints: role-filter OTHER fallback (Decision 20) and NodeFilter.role trap (30)."""
349+
pairs: list[tuple[int, str]] = []
350+
req_types = payload.get("requested_edge_types")
351+
if not isinstance(req_types, list) or req_types != ["CALLS"]:
352+
return pairs
353+
results = list(payload.get("results") or [])
354+
edge_flt = payload.get("edge_filter") if isinstance(payload.get("edge_filter"), dict) else {}
355+
node_flt = payload.get("node_filter") if isinstance(payload.get("node_filter"), dict) else {}
356+
role_exact = edge_flt.get("callee_declaring_role")
357+
if (
358+
role_exact in ("SERVICE", "REPOSITORY")
359+
and not results
360+
and int(payload.get("unfiltered_calls_count") or 0) >= 5
361+
):
362+
pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_ROLE_FILTER_OTHER_FALLBACK))
363+
node_role = node_flt.get("role")
364+
if node_role and results:
365+
method_rows = [
366+
r
367+
for r in results
368+
if str(((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("symbol_kind") or "")
369+
== "method"
370+
]
371+
if method_rows:
372+
other_roles = [
373+
str(
374+
((r.get("other") or {}) if isinstance(r.get("other"), dict) else {}).get("role")
375+
or ""
376+
)
377+
for r in method_rows
378+
]
379+
if other_roles and sum(1 for role in other_roles if role == "OTHER") >= max(
380+
1, (len(other_roles) * 3) // 4
381+
):
382+
pairs.append((PRIORITY_META, TPL_NEIGHBORS_CALLS_NODEFILTER_ROLE_COLLISION))
383+
return pairs
384+
385+
332386
def neighbors_success_hints(payload: dict[str, Any]) -> list[tuple[int, str]]:
333387
"""v4 non-empty neighbors follow-ups (N1a–N7); no graph I/O."""
334388
if not payload.get("success"):
@@ -573,11 +627,11 @@ def generate_hints(
573627
requested_direction=requested_direction,
574628
)
575629
)
576-
else:
577-
if results and offset == 0:
578-
success_pairs = neighbors_success_hints(payload)
579-
if _any_fuzzy_strategy(results):
580-
meta_pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
630+
elif results and offset == 0:
631+
success_pairs = neighbors_success_hints(payload)
632+
meta_pairs.extend(neighbors_calls_meta_hints(payload))
633+
if results and _any_fuzzy_strategy(results):
634+
meta_pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
581635
return finalize_hint_list(
582636
_filter_neighbors_dotkey_hints(empty_pairs) + success_pairs + meta_pairs,
583637
)

0 commit comments

Comments
 (0)