Skip to content

Commit 45f34b0

Browse files
add EDGE_SCHEMA-driven empty neighbors hints (HINTS-V3 PR-D)
Replace the generic empty-neighbors template with kind-, direction-, and type-level structural hints sourced from EDGE_SCHEMA; extend neighbors_v2 hint payload with subject_record and requested_direction. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5202305 commit 45f34b0

5 files changed

Lines changed: 507 additions & 22 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ Example:
261261
{"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}}
262262
```
263263

264-
**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success for empty-result hints and diagnostics; when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint).
264+
**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, `neighbors`, and `resolve` return a `hints` field (`list[str]`, capped at five unique strings) with short, templated suggestions for likely next tool calls; hints are advisory. `hints` is always empty when `success` is false. `resolve` additionally echoes `resolved_identifier` (post-validation trimmed identifier) on every `success=true` response; it is `null` when `success` is false. Resolve hints fire only on `status: none` or `status: many` (not on `status: one`). `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). The find page-full hint fires only when another page may exist (handler over-fetches by one row; not exposed on the output model). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success; empty results with non-empty `edge_types` may emit kind- and direction-aware structural hints driven by `EDGE_SCHEMA` (see [`propose/HINTS-V3-PROPOSE.md`](./propose/HINTS-V3-PROPOSE.md)); when any result edge carries a brownfield/fallback `attrs.strategy` (see `FUZZY_STRATEGY_SET` in `java_ontology.py`), a single meta-tier fuzzy-strategy hint may also appear on non-empty results. See [`propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog; see [`propose/HINTS-V2-PROPOSE.md`](./propose/HINTS-V2-PROPOSE.md) for v2 additions (`resolve` rules and neighbors fuzzy-strategy hint).
265265

266266
---
267267

mcp_hints.py

Lines changed: 166 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
"""Pure MCP v2 road-sign hint generation (no graph I/O, no search, no LLM).
22
33
Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A.
4-
v2 resolve + neighbors fuzzy-strategy catalog: ``propose/HINTS-V2-PROPOSE.md`` Appendix A.
4+
v2 resolve + neighbors fuzzy-strategy catalog: ``propose/completed/HINTS-V2-PROPOSE.md`` Appendix A.
5+
v3 empty-neighbors structural catalog: ``propose/HINTS-V3-PROPOSE.md`` §3.1–3.3.
56
Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles.
67
"""
78

89
from __future__ import annotations
910

1011
from typing import Any, Literal
1112

12-
from java_ontology import FUZZY_STRATEGY_SET
13+
from java_ontology import EDGE_SCHEMA, FUZZY_STRATEGY_SET
1314

1415
# Normative schema description (propose §3.1) — imported by ``mcp_v2`` for Field(description=...).
1516
MCP_HINTS_FIELD_DESCRIPTION = (
1617
"Road-sign hints pointing to likely next calls. Each hint is a short string "
1718
"referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. "
1819
"Maximum 5 hints per output. Hints never recommend dot-key edge labels (composed "
19-
"rollups) as neighbors() arguments."
20+
"rollups) as neighbors() arguments. For neighbors with multiple origin ids, "
21+
"empty-result structural hints describe the first origin only."
2022
)
2123

2224
# --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) ---
@@ -43,8 +45,25 @@
4345
TPL_FIND_EMPTY_RESOLVE = "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup"
4446
TPL_FIND_PAGE_FULL = "result page full at {limit} — narrow filter or paginate"
4547

46-
TPL_NEIGHBORS_EMPTY_KIND_CHECK = (
47-
"0 results — check if the requested edge_types apply to this kind"
48+
TPL_NEIGHBORS_WRONG_SUBJECT_KIND = (
49+
"0 results — '{edge}' connects {src_kind} → {dst_kind}; "
50+
"this is a {subject_kind}. Try: {canonical_traversal}"
51+
)
52+
53+
TPL_NEIGHBORS_WRONG_DIRECTION = (
54+
"0 results — '{edge}' is {src_kind} → {dst_kind}; "
55+
"you requested direction='{requested_dir}'. Try direction='{correct_dir}'."
56+
)
57+
58+
TPL_NEIGHBORS_TYPE_LEVEL_REQUERY = (
59+
"0 results — '{edge}' lives on methods, not on {subject_kind}. "
60+
"Try: {canonical_traversal}"
61+
)
62+
63+
TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED = (
64+
"edges on '{edge}' are emitted by the brownfield resolver — "
65+
"absence here may mean unresolved (no matching annotation/target), "
66+
"not absent from the codebase"
4867
)
4968

5069
TPL_SEARCH_WEAK = "results look weak — narrow the query or try find(role=…)"
@@ -80,6 +99,8 @@
8099
_TYPE_SYMBOL_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"})
81100
_METHOD_SYMBOL_KINDS = frozenset({"method", "constructor"})
82101

102+
_COMPOSED_DOT_KEY_PREFIXES = ("DECLARES.", "OVERRIDDEN_BY.")
103+
83104
_IDENTIFIER_FILTER_FIELDS: dict[str, tuple[str, ...]] = {
84105
"symbol": ("fqn_prefix",),
85106
"route": ("path_prefix",),
@@ -105,6 +126,133 @@ def _symbol_declaration_kind(record: dict[str, Any]) -> str | None:
105126
return None
106127

107128

129+
def _subject_node_label(subject_record: dict[str, Any]) -> str:
130+
if "producer_kind" in subject_record:
131+
return "Producer"
132+
if "client_kind" in subject_record:
133+
return "Client"
134+
if "framework" in subject_record:
135+
return "Route"
136+
return "Symbol"
137+
138+
139+
def _traversal_role_for_wrong_kind(subject_label: str, subject_record: dict[str, Any]) -> str:
140+
if subject_label == "Symbol":
141+
sk = str(subject_record.get("kind") or "")
142+
if sk in _METHOD_SYMBOL_KINDS:
143+
return "member_subject"
144+
if sk in _TYPE_SYMBOL_KINDS:
145+
return "alien_subject"
146+
return "alien_subject"
147+
148+
149+
def typical_traversal_for(
150+
edge: str,
151+
role_key: str,
152+
*,
153+
subject_id: str,
154+
direction: str,
155+
) -> str:
156+
template = EDGE_SCHEMA[edge].typical_traversals[role_key]
157+
return template.format(id=subject_id, direction=direction, edge=edge)
158+
159+
160+
def neighbors_empty_hints(
161+
*,
162+
subject_record: dict[str, Any],
163+
requested_edge_types: list[str],
164+
requested_direction: Literal["in", "out"],
165+
) -> list[tuple[int, str]]:
166+
"""Structural empty-neighbors hints from ``EDGE_SCHEMA`` (at most one row 1–3 per edge)."""
167+
pairs: list[tuple[int, str]] = []
168+
subject_label = _subject_node_label(subject_record)
169+
subject_id = str(subject_record.get("id") or "")
170+
171+
for edge in requested_edge_types:
172+
spec = EDGE_SCHEMA.get(edge)
173+
if spec is None:
174+
continue
175+
176+
if subject_label != spec.src and subject_label != spec.dst:
177+
role = _traversal_role_for_wrong_kind(subject_label, subject_record)
178+
trav = typical_traversal_for(
179+
edge, role, subject_id=subject_id, direction=requested_direction,
180+
)
181+
pairs.append(
182+
(
183+
PRIORITY_META,
184+
TPL_NEIGHBORS_WRONG_SUBJECT_KIND.format(
185+
edge=edge,
186+
src_kind=spec.src,
187+
dst_kind=spec.dst,
188+
subject_kind=subject_label,
189+
canonical_traversal=trav,
190+
),
191+
)
192+
)
193+
continue
194+
195+
wrong_direction = spec.src != spec.dst and (
196+
(requested_direction == "out" and subject_label == spec.dst)
197+
or (requested_direction == "in" and subject_label == spec.src)
198+
)
199+
if wrong_direction:
200+
correct_dir = "in" if requested_direction == "out" else "out"
201+
pairs.append(
202+
(
203+
PRIORITY_META,
204+
TPL_NEIGHBORS_WRONG_DIRECTION.format(
205+
edge=edge,
206+
src_kind=spec.src,
207+
dst_kind=spec.dst,
208+
requested_dir=requested_direction,
209+
correct_dir=correct_dir,
210+
),
211+
)
212+
)
213+
continue
214+
215+
if (
216+
subject_label == "Symbol"
217+
and str(subject_record.get("kind") or "") in _TYPE_SYMBOL_KINDS
218+
and spec.member_only
219+
):
220+
trav = typical_traversal_for(
221+
edge, "type_subject", subject_id=subject_id, direction=requested_direction,
222+
)
223+
pairs.append(
224+
(
225+
PRIORITY_META,
226+
TPL_NEIGHBORS_TYPE_LEVEL_REQUERY.format(
227+
edge=edge,
228+
subject_kind=subject_label,
229+
canonical_traversal=trav,
230+
),
231+
)
232+
)
233+
234+
for edge in requested_edge_types:
235+
spec = EDGE_SCHEMA.get(edge)
236+
if spec is not None and spec.brownfield_resolver_sourced:
237+
pairs.append(
238+
(
239+
PRIORITY_META,
240+
TPL_NEIGHBORS_BROWNFIELD_RESOLVED_MAYBE_UNRESOLVED.format(edge=edge),
241+
)
242+
)
243+
break
244+
245+
return pairs
246+
247+
248+
def _hint_contains_composed_dotkey(hint: str) -> bool:
249+
return any(prefix in hint for prefix in _COMPOSED_DOT_KEY_PREFIXES)
250+
251+
252+
def _filter_neighbors_dotkey_hints(pairs: list[tuple[int, str]]) -> list[tuple[int, str]]:
253+
return [(pri, text) for pri, text in pairs if not _hint_contains_composed_dotkey(text)]
254+
255+
108256
def _any_fuzzy_strategy(edges: list[dict[str, Any]]) -> bool:
109257
for e in edges:
110258
attrs = e.get("attrs") if isinstance(e.get("attrs"), dict) else {}
@@ -227,12 +375,21 @@ def generate_hints(
227375
req_types = payload.get("requested_edge_types")
228376
if not isinstance(req_types, list):
229377
req_types = []
230-
n_types = len([x for x in req_types if str(x).strip()])
231-
if not results and n_types > 0:
232-
pairs.append((PRIORITY_META, TPL_NEIGHBORS_EMPTY_KIND_CHECK))
378+
edge_labels = [str(x).strip() for x in req_types if str(x).strip()]
379+
if not results and edge_labels:
380+
subject_record = payload.get("subject_record")
381+
requested_direction = payload.get("requested_direction")
382+
if isinstance(subject_record, dict) and requested_direction in ("in", "out"):
383+
pairs.extend(
384+
neighbors_empty_hints(
385+
subject_record=subject_record,
386+
requested_edge_types=edge_labels,
387+
requested_direction=requested_direction,
388+
)
389+
)
233390
elif _any_fuzzy_strategy(results):
234391
pairs.append((PRIORITY_META, TPL_NEIGHBORS_FUZZY_STRATEGY))
235-
return finalize_hint_list(pairs)
392+
return finalize_hint_list(_filter_neighbors_dotkey_hints(pairs))
236393

237394
if output_kind == "describe":
238395
rec = payload.get("record")

mcp_v2.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,10 +1391,16 @@ def neighbors_v2(
13911391
)
13921392
)
13931393
sliced = results[offset : offset + limit]
1394+
first_origin = origins[0]
1395+
origin_kind = _resolve_node_kind(g, first_origin)
1396+
subject_record = _load_node_record(g, first_origin, origin_kind)
13941397
neigh_payload = {
13951398
"success": True,
13961399
"results": [e.model_dump() for e in sliced],
13971400
"requested_edge_types": list(labels),
1401+
"requested_direction": direction,
1402+
"origin_id": first_origin,
1403+
"subject_record": subject_record if subject_record is not None else {},
13981404
}
13991405
return NeighborsOutput(
14001406
success=True,

server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,8 @@ async def describe(
452452
"Optional `filter` applies to each neighbor endpoint row; populated fields must be applicable to that "
453453
"neighbor's kind—mixed-kind result sets fail on the first inapplicable neighbor (strict frame). "
454454
"Wildcards in prefix fields are rejected. Unknown NodeFilter keys return success=false. "
455-
"Successful responses echo `requested_edge_types` and may include `hints` (advisory next-step strings). "
455+
"Successful responses echo `requested_edge_types` and may include `hints` (advisory next-step strings; "
456+
"empty results may include EDGE_SCHEMA-driven traversal hints). "
456457
"Each edge's `attrs.strategy` indicates resolution quality (brownfield/fallback vs primary paths)."
457458
),
458459
)

0 commit comments

Comments
 (0)