Skip to content

Commit bdba206

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 55875a1 commit bdba206

7 files changed

Lines changed: 600 additions & 13 deletions

File tree

docs/AGENT-GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,13 @@ Identifier lookup; three statuses above. Args: `identifier`, optional `hint_kind
211211

212212
#### `neighbors`
213213

214-
One hop. Args: `ids` (string or array), **`direction`**, **`edge_types`**, `limit` (default 25), `offset`, optional `filter` on the other node.
214+
One hop. Args: `ids` (string or array), **`direction`**, **`edge_types`**, `limit` (default 25), `offset`, optional `filter` on the other node, optional **`edge_filter`** (CALLS only — single label in `edge_types`; fail-loud when a filter field is not on that label).
215215

216216
Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, … on cross-service edges) and **`other`** node.
217217

218218
**Cross-service edges** (`HTTP_CALLS`, `ASYNC_CALLS`): read `attrs.confidence` and `attrs.match` — low confidence or `unresolved`/`phantom`/`ambiguous` means treat as a resolver signal, not ground truth.
219219

220-
**`CALLS` edges:** `attrs.resolved=false` or low `attrs.confidence` may be JDK/external or unresolved static sites — still a lower bound, not exhaustive runtime behaviour.
220+
**`CALLS` edges:** source-ordered (`call_site_line`, `call_site_byte`). `attrs.resolved=false` or low `attrs.confidence` may be JDK/external or unresolved static sites — still a lower bound, not exhaustive runtime behaviour. Optional **`edge_filter`** projects before pagination: `min_confidence`; `include_strategies` / `exclude_strategies` (mutually exclusive); `callee_declaring_role`, `callee_declaring_roles`, `exclude_callee_declaring_roles` (`["OTHER"]` also drops known-external rows). **`filter.role` filters the neighbor method (usually `OTHER`), not the callee stereotype** — use `edge_filter.callee_declaring_role` for repository/service hops. **`exclude_external` applies to `find_callers` / `find_callees` only** (FQN-prefix); trim JDK noise on CALLS via `edge_filter`. Accessor noise: role excludes help; getter/setter heuristics in [`propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md`](../propose/AGENT-SKILLS-AND-COMMANDS-PROPOSE.md) `/mini-map`.
221221

222222
### Ontology glossary
223223

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)