Skip to content

Commit 91d31cd

Browse files
refine find page-full hints and cap tie-break order
Over-fetch one row in find_v2 so page-full hints skip the last page; cap same-priority hints by emission order; add symbol fqn_prefix resolve test. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent e068e5b commit 91d31cd

4 files changed

Lines changed: 76 additions & 15 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`, and `neighbors` 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. `search` and `find` additionally echo the request’s `limit` and `offset` on success; on failure those echoed fields are omitted (`null` in JSON). `neighbors` echoes `requested_edge_types` (deduped edge labels from the request) on success for empty-result hints and diagnostics. See [`propose/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog.
264+
**MCP v2 response extras (`hints`, pagination echo):** On success, `search`, `find`, `describe`, and `neighbors` 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. `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. See [`propose/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog.
265265

266266
---
267267

mcp_hints.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,20 @@ def _find_has_identifier_shaped_filter(kind: str, flt: dict[str, Any]) -> bool:
9393

9494

9595
def finalize_hint_list(scored: list[tuple[int, str]]) -> list[str]:
96-
"""Dedupe identical rendered strings keeping the highest priority; cap to 5 (drop lowest)."""
97-
best_pri: dict[str, int] = {}
98-
for pri, text in scored:
96+
"""Dedupe identical rendered strings keeping the highest priority; cap to 5 (drop lowest).
97+
98+
Within the same priority tier, keep hints in emission order (first scored wins the cap).
99+
"""
100+
best: dict[str, tuple[int, int]] = {}
101+
for idx, (pri, text) in enumerate(scored):
99102
if not text:
100103
continue
101-
prev = best_pri.get(text)
102-
if prev is None or pri > prev:
103-
best_pri[text] = pri
104-
ordered = sorted(best_pri.items(), key=lambda kv: (-kv[1], kv[0]))
104+
prev = best.get(text)
105+
if prev is None or pri > prev[0]:
106+
best[text] = (pri, idx)
107+
elif pri == prev[0]:
108+
best[text] = (pri, min(prev[1], idx))
109+
ordered = sorted(best.items(), key=lambda kv: (-kv[1][0], kv[1][1]))
105110
return [text for text, _pri in ordered[:5]]
106111

107112

@@ -137,7 +142,11 @@ def generate_hints(
137142
lim = payload.get("limit")
138143
if not results and _find_has_identifier_shaped_filter(kind, flt):
139144
pairs.append((PRIORITY_META, TPL_FIND_EMPTY_RESOLVE.format(kind=kind)))
140-
if lim is not None and len(results) >= int(lim):
145+
if (
146+
lim is not None
147+
and len(results) >= int(lim)
148+
and payload.get("has_more_results") is True
149+
):
141150
pairs.append((PRIORITY_META, TPL_FIND_PAGE_FULL.format(limit=int(lim))))
142151
return finalize_hint_list(pairs)
143152

mcp_v2.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -756,36 +756,36 @@ def find_v2(
756756
if err := _validate_no_wildcards(nf):
757757
_log_fail_loud("wildcard")
758758
return FindOutput(success=False, message=err, hints=[], limit=None, offset=None)
759+
fetch_cap = int(limit) + int(offset) + 1
759760
if kind == "symbol":
760761
where, params = _symbol_where_from_filter(nf)
761-
params["lim"] = int(limit) + int(offset)
762+
params["lim"] = fetch_cap
762763
rows = g._rows( # noqa: SLF001
763764
f"MATCH (s:Symbol) {where} RETURN s.id AS id, s.fqn AS fqn, s.microservice AS microservice, "
764765
"s.module AS module, s.role AS role, s.kind AS symbol_kind ORDER BY s.fqn LIMIT $lim",
765766
params,
766767
)
767-
rows = rows[offset : offset + limit]
768768
elif kind == "route":
769769
rows = g.list_routes(
770770
microservice=nf.microservice,
771771
framework=nf.framework,
772772
path_prefix=nf.path_prefix,
773773
method=nf.http_method,
774-
limit=max(500, limit + offset),
774+
limit=max(500, fetch_cap),
775775
)
776776
rows = [r for r in rows if _node_matches_filter("route", r, nf)]
777-
rows = rows[offset : offset + limit]
778777
else:
779778
rows = g.list_clients(
780779
microservice=nf.microservice,
781780
client_kind=nf.client_kind,
782781
target_service=nf.target_service,
783782
path_prefix=nf.target_path_prefix,
784783
method=nf.http_method,
785-
limit=max(500, limit + offset),
784+
limit=max(500, fetch_cap),
786785
)
787786
rows = [r for r in rows if _node_matches_filter("client", r, nf)]
788-
rows = rows[offset : offset + limit]
787+
has_more_results = len(rows) > int(offset) + int(limit)
788+
rows = rows[offset : offset + limit]
789789
refs = [_node_ref_from_row(kind, r) for r in rows]
790790
filter_dump = nf.model_dump(exclude_none=True)
791791
hint_payload: dict[str, Any] = {
@@ -795,6 +795,7 @@ def find_v2(
795795
"limit": limit,
796796
"offset": offset,
797797
"filter": filter_dump,
798+
"has_more_results": has_more_results,
798799
}
799800
return FindOutput(
800801
success=True,

tests/test_mcp_hints.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,34 @@ def test_hints_find_empty_identifier_filter_suggests_resolve(kuzu_graph) -> None
203203
assert any("resolve(identifier" in h and "hint_kind='client'" in h for h in out.hints)
204204

205205

206+
def test_hints_find_empty_symbol_fqn_prefix_suggests_resolve(kuzu_graph) -> None:
207+
out = find_v2("symbol", {"fqn_prefix": "__no_such_prefix__"}, graph=kuzu_graph)
208+
assert out.success is True
209+
assert out.results == []
210+
assert any("resolve(identifier" in h and "hint_kind='symbol'" in h for h in out.hints)
211+
212+
206213
def test_hints_find_page_full_emits_narrow_or_paginate(kuzu_graph) -> None:
207214
out = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=1, offset=0)
208215
assert out.success is True
209216
assert len(out.results) >= 1
210217
assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) in out.hints
211218

212219

220+
def test_hints_find_page_full_skips_when_last_page(kuzu_graph) -> None:
221+
full = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=500, offset=0)
222+
assert full.success and full.results
223+
last = find_v2(
224+
"symbol",
225+
{"role": "CONTROLLER"},
226+
graph=kuzu_graph,
227+
limit=1,
228+
offset=len(full.results) - 1,
229+
)
230+
assert last.success and len(last.results) == 1
231+
assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) not in last.hints
232+
233+
213234
def test_hints_neighbors_empty_with_edge_types_emits_kind_check(kuzu_graph) -> None:
214235
class_id = _class_symbol_id(kuzu_graph)
215236
out = neighbors_v2(class_id, direction="out", edge_types=["DECLARES_CLIENT"], graph=kuzu_graph)
@@ -313,6 +334,36 @@ def test_hints_cap_drops_lowest_priority_over_five() -> None:
313334
assert "d1" in got and "o1" in got
314335

315336

337+
def test_hints_cap_same_priority_keeps_emission_order() -> None:
338+
scored = [
339+
(PRIORITY_META, "z-meta"),
340+
(PRIORITY_META, "a-meta"),
341+
(PRIORITY_META, "b-meta"),
342+
(PRIORITY_META, "c-meta"),
343+
(PRIORITY_META, "d-meta"),
344+
(PRIORITY_META, "e-meta"),
345+
]
346+
got = finalize_hint_list(scored)
347+
assert len(got) == 5
348+
assert "z-meta" in got
349+
assert "e-meta" not in got
350+
351+
352+
def test_hints_find_page_full_requires_has_more_results_flag() -> None:
353+
full_page = {
354+
"success": True,
355+
"kind": "symbol",
356+
"results": [{"id": "sym:a"}],
357+
"limit": 1,
358+
"offset": 0,
359+
"filter": {},
360+
}
361+
assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) not in generate_hints("find", full_page)
362+
assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) in generate_hints(
363+
"find", {**full_page, "has_more_results": True}
364+
)
365+
366+
316367
def test_hints_kind_gate_method_payload_ignores_type_only_rollups() -> None:
317368
node_id = "sym:com.example.T#m()"
318369
rec = {

0 commit comments

Comments
 (0)