From 708d42fd74b011270af4a4de7410eaa18885a6e5 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 10:46:21 +0300 Subject: [PATCH 1/4] add MCP v2 hints and find/search pagination echo Implement PR-HINTS-B: pure mcp_hints catalog with dedupe/cap, wire hints into search/find/describe/neighbors outputs, echo limit/offset on find/search, and cover the v1 contract with tests/test_mcp_hints.py. Co-authored-by: Cursor --- README.md | 2 + mcp_hints.py | 207 ++++++++++++++++++ mcp_v2.py | 125 +++++++++-- pyproject.toml | 1 + server.py | 9 +- tests/test_mcp_hints.py | 457 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 777 insertions(+), 24 deletions(-) create mode 100644 mcp_hints.py create mode 100644 tests/test_mcp_hints.py diff --git a/README.md b/README.md index 4155c0b..24d2e35 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,8 @@ Example: {"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}} ``` +**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). See [`propose/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog. + --- ## 5. CLI reference (`java-codebase-rag`) diff --git a/mcp_hints.py b/mcp_hints.py new file mode 100644 index 0000000..9b7eb41 --- /dev/null +++ b/mcp_hints.py @@ -0,0 +1,207 @@ +"""Pure MCP v2 road-sign hint generation (no graph I/O, no search, no LLM). + +Locked v1 catalog: ``propose/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A. +Priority cap: same propose §7.12 / ``plans/PLAN-HINTS.md`` principles. +""" + +from __future__ import annotations + +from typing import Any, Literal + +# Normative schema description (propose §3.1) — imported by ``mcp_v2`` for Field(description=...). +MCP_HINTS_FIELD_DESCRIPTION = ( + "Road-sign hints pointing to likely next calls. Each hint is a short string " + "referencing one MCP V2 tool call. Hints are advisory and may be safely ignored. " + "Maximum 5 hints per output. Hints never recommend dot-key edge labels (composed " + "rollups) as neighbors() arguments." +) + +# --- Appendix A verbatim templates (substitute {id}, {kind}, {limit}) --- + +TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS = ( + "clients via members: neighbors(['{id}'],'out',['DECLARES']) " + "then neighbors(member_ids,'out',['DECLARES_CLIENT'])" +) +TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS = ( + "routes via members: neighbors(['{id}'],'out',['DECLARES']) " + "then neighbors(member_ids,'out',['EXPOSES'])" +) +TPL_DESCRIBE_METHOD_OVERRIDERS = "overriders: neighbors(['{id}'],'in',['OVERRIDES'])" +TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS = ( + "clients in overriders: neighbors(['{id}'],'in',['OVERRIDES']) " + "then neighbors(overrider_ids,'out',['DECLARES_CLIENT'])" +) +TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT = "outbound client: neighbors(['{id}'],'out',['DECLARES_CLIENT'])" +TPL_DESCRIBE_METHOD_INBOUND_ROUTE = "inbound route: neighbors(['{id}'],'out',['EXPOSES'])" +TPL_DESCRIBE_METHOD_MANY_CALLS = "many CALLS — consider filtering by target microservice" +TPL_DESCRIBE_ROUTE_DECLARING = "declaring method: neighbors(['{id}'],'in',['EXPOSES'])" +TPL_DESCRIBE_CLIENT_DECLARING = "declaring method: neighbors(['{id}'],'in',['DECLARES_CLIENT'])" + +TPL_FIND_EMPTY_RESOLVE = "no matches — try resolve(identifier, hint_kind='{kind}') for canonical lookup" +TPL_FIND_PAGE_FULL = "result page full at {limit} — narrow filter or paginate" + +TPL_NEIGHBORS_EMPTY_KIND_CHECK = ( + "0 results — check if the requested edge_types apply to this kind" +) + +TPL_SEARCH_WEAK = "results look weak — narrow the query or try find(role=…)" + +# §7.12 priority: DECLARES.* type rollups > OVERRIDDEN_BY.* > leaf follow-ups > meta. +PRIORITY_DECLARES_TYPE_ROLLUP = 4 +PRIORITY_OVERRIDDEN_AXIS = 3 +PRIORITY_LEAF_FOLLOWUP = 2 +PRIORITY_META = 1 + +_TYPE_SYMBOL_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"}) +_METHOD_SYMBOL_KINDS = frozenset({"method", "constructor"}) + +_IDENTIFIER_FILTER_FIELDS: dict[str, tuple[str, ...]] = { + "symbol": ("fqn_prefix",), + "route": ("path_prefix",), + "client": ("target_service", "target_path_prefix"), +} + + +def _out_count(edge_summary: dict[str, Any] | None, key: str) -> int: + if not edge_summary or key not in edge_summary: + return 0 + cell = edge_summary[key] + if not isinstance(cell, dict): + return 0 + return int(cell.get("out", 0) or 0) + + +def _symbol_declaration_kind(record: dict[str, Any]) -> str | None: + data = record.get("data") + if isinstance(data, dict): + k = data.get("kind") + if k is not None: + return str(k).strip() or None + return None + + +def _find_has_identifier_shaped_filter(kind: str, flt: dict[str, Any]) -> bool: + for name in _IDENTIFIER_FILTER_FIELDS.get(kind, ()): + val = flt.get(name) + if val is None: + continue + if isinstance(val, str) and val.strip(): + return True + if not isinstance(val, str): + return True + return False + + +def finalize_hint_list(scored: list[tuple[int, str]]) -> list[str]: + """Dedupe identical rendered strings keeping the highest priority; cap to 5 (drop lowest).""" + best_pri: dict[str, int] = {} + for pri, text in scored: + if not text: + continue + prev = best_pri.get(text) + if prev is None or pri > prev: + best_pri[text] = pri + ordered = sorted(best_pri.items(), key=lambda kv: (-kv[1], kv[0])) + return [text for text, _pri in ordered[:5]] + + +def generate_hints( + output_kind: Literal["search", "find", "describe", "neighbors"], + payload: dict[str, Any], +) -> list[str]: + """Return up to 5 road-sign hint strings for a success-only MCP v2 payload dict. + + Callers must pass ``success: True`` payloads only for hint rows; this function + returns ``[]`` when ``success`` is false or missing. + """ + if not payload.get("success"): + return [] + + pairs: list[tuple[int, str]] = [] + + if output_kind == "search": + results: list[dict[str, Any]] = list(payload.get("results") or []) + lim = payload.get("limit") + if lim is not None and len(results) == int(lim) and results: + scores = [float(r.get("score", 0.0) or 0.0) for r in results] + mx = max(scores) + mn = min(scores) + if mx > 0.0 and (mx - mn) < 0.1 * mx: + pairs.append((PRIORITY_META, TPL_SEARCH_WEAK)) + return finalize_hint_list(pairs) + + if output_kind == "find": + kind = str(payload.get("kind") or "") + results = list(payload.get("results") or []) + flt = payload.get("filter") if isinstance(payload.get("filter"), dict) else {} + lim = payload.get("limit") + if not results and _find_has_identifier_shaped_filter(kind, flt): + pairs.append((PRIORITY_META, TPL_FIND_EMPTY_RESOLVE.format(kind=kind))) + if lim is not None and len(results) >= int(lim): + pairs.append((PRIORITY_META, TPL_FIND_PAGE_FULL.format(limit=int(lim)))) + return finalize_hint_list(pairs) + + if output_kind == "neighbors": + results = list(payload.get("results") or []) + req_types = payload.get("requested_edge_types") + if not isinstance(req_types, list): + req_types = [] + n_types = len([x for x in req_types if str(x).strip()]) + if not results and n_types > 0: + pairs.append((PRIORITY_META, TPL_NEIGHBORS_EMPTY_KIND_CHECK)) + return finalize_hint_list(pairs) + + if output_kind == "describe": + rec = payload.get("record") + if not isinstance(rec, dict): + return [] + node_id = str(rec.get("id") or "") + if not node_id: + return [] + kind = str(rec.get("kind") or "") + es = rec.get("edge_summary") + edge_summary = es if isinstance(es, dict) else None + + if kind == "route": + pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_ROUTE_DECLARING.format(id=node_id))) + return finalize_hint_list(pairs) + if kind == "client": + pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_CLIENT_DECLARING.format(id=node_id))) + return finalize_hint_list(pairs) + + if kind != "symbol": + return finalize_hint_list(pairs) + + decl_kind = _symbol_declaration_kind(rec) + is_type = decl_kind in _TYPE_SYMBOL_KINDS + is_method = decl_kind in _METHOD_SYMBOL_KINDS + + if is_type: + if _out_count(edge_summary, "DECLARES.DECLARES_CLIENT") > 0: + pairs.append( + (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS.format(id=node_id)) + ) + if _out_count(edge_summary, "DECLARES.EXPOSES") > 0: + pairs.append( + (PRIORITY_DECLARES_TYPE_ROLLUP, TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS.format(id=node_id)) + ) + return finalize_hint_list(pairs) + + if is_method: + if _out_count(edge_summary, "OVERRIDDEN_BY") > 0: + pairs.append((PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_OVERRIDERS.format(id=node_id))) + if _out_count(edge_summary, "OVERRIDDEN_BY.DECLARES_CLIENT") > 0: + pairs.append( + (PRIORITY_OVERRIDDEN_AXIS, TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS.format(id=node_id)) + ) + if _out_count(edge_summary, "DECLARES_CLIENT") > 0: + pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT.format(id=node_id))) + if _out_count(edge_summary, "EXPOSES") > 0: + pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_INBOUND_ROUTE.format(id=node_id))) + if _out_count(edge_summary, "CALLS") >= 10: + pairs.append((PRIORITY_LEAF_FOLLOWUP, TPL_DESCRIBE_METHOD_MANY_CALLS)) + return finalize_hint_list(pairs) + + return finalize_hint_list(pairs) + + return [] diff --git a/mcp_v2.py b/mcp_v2.py index 505f1ad..0751d0e 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -31,6 +31,7 @@ from java_codebase_rag.config import resolved_sbert_model_for_process_env from java_ontology import ResolveReason from kuzu_queries import KuzuGraph +from mcp_hints import MCP_HINTS_FIELD_DESCRIPTION, generate_hints from search_lancedb import TABLES, run_search DeclarationSymbolKind = Literal["class", "interface", "enum", "record", "annotation", "method", "constructor"] @@ -287,24 +288,48 @@ class SearchOutput(BaseModel): success: bool results: list[SearchHit] = Field(default_factory=list) message: str | None = None + limit: int | None = Field( + default=None, + description="Echoed from the request — the page size the server applied. None on success=False.", + ) + offset: int | None = Field( + default=None, + description="Echoed from the request — the page offset the server applied. None on success=False.", + ) + hints: list[str] = Field(default_factory=list, description=MCP_HINTS_FIELD_DESCRIPTION) class FindOutput(BaseModel): success: bool results: list[NodeRef] = Field(default_factory=list) message: str | None = None + limit: int | None = Field( + default=None, + description="Echoed from the request — the page size the server applied. None on success=False.", + ) + offset: int | None = Field( + default=None, + description="Echoed from the request — the page offset the server applied. None on success=False.", + ) + hints: list[str] = Field(default_factory=list, description=MCP_HINTS_FIELD_DESCRIPTION) class DescribeOutput(BaseModel): success: bool record: NodeRecord | None = None message: str | None = None + hints: list[str] = Field(default_factory=list, description=MCP_HINTS_FIELD_DESCRIPTION) class NeighborsOutput(BaseModel): success: bool results: list[Edge] = Field(default_factory=list) message: str | None = None + requested_edge_types: list[str] = Field( + default_factory=list, + description="Echo of neighbors(edge_types=...) from the request; empty when success=False.", + ) + hints: list[str] = Field(default_factory=list, description=MCP_HINTS_FIELD_DESCRIPTION) ResolveStatus = Literal["one", "many", "none"] @@ -641,13 +666,19 @@ def search_v2( ) except ValidationError as exc: _log_fail_loud("unknown_key") - return SearchOutput(success=False, message=_filter_validation_error_message(exc)) + return SearchOutput( + success=False, + message=_filter_validation_error_message(exc), + hints=[], + limit=None, + offset=None, + ) if nf and (err := _nodefilter_applicability_error("symbol", nf)): _log_fail_loud("applicability") - return SearchOutput(success=False, message=err) + return SearchOutput(success=False, message=err, hints=[], limit=None, offset=None) if nf and (err := _validate_no_wildcards(nf)): _log_fail_loud("wildcard") - return SearchOutput(success=False, message=err) + return SearchOutput(success=False, message=err, hints=[], limit=None, offset=None) model_name = resolved_sbert_model_for_process_env(SBERT_MODEL) device = os.environ.get("SBERT_DEVICE") or None model = _get_sentence_transformer(model_name, device) @@ -679,9 +710,21 @@ def search_v2( if not _node_matches_filter(row_kind, row, nf): continue hits.append(_row_to_search_hit(row)) - return SearchOutput(success=True, results=hits) + hint_payload = { + "success": True, + "results": [h.model_dump() for h in hits], + "limit": limit, + "offset": offset, + } + return SearchOutput( + success=True, + results=hits, + limit=limit, + offset=offset, + hints=generate_hints("search", hint_payload), + ) except Exception as exc: - return SearchOutput(success=False, message=str(exc)) + return SearchOutput(success=False, message=str(exc), hints=[], limit=None, offset=None) def find_v2( @@ -700,13 +743,19 @@ def find_v2( nf = NodeFilter.model_validate(raw_filter) if not isinstance(raw_filter, NodeFilter) else raw_filter except ValidationError as exc: _log_fail_loud("unknown_key") - return FindOutput(success=False, message=_filter_validation_error_message(exc)) + return FindOutput( + success=False, + message=_filter_validation_error_message(exc), + hints=[], + limit=None, + offset=None, + ) if err := _nodefilter_applicability_error(kind, nf): _log_fail_loud("applicability") - return FindOutput(success=False, message=err) + return FindOutput(success=False, message=err, hints=[], limit=None, offset=None) if err := _validate_no_wildcards(nf): _log_fail_loud("wildcard") - return FindOutput(success=False, message=err) + return FindOutput(success=False, message=err, hints=[], limit=None, offset=None) if kind == "symbol": where, params = _symbol_where_from_filter(nf) params["lim"] = int(limit) + int(offset) @@ -737,9 +786,25 @@ def find_v2( ) rows = [r for r in rows if _node_matches_filter("client", r, nf)] rows = rows[offset : offset + limit] - return FindOutput(success=True, results=[_node_ref_from_row(kind, r) for r in rows]) + refs = [_node_ref_from_row(kind, r) for r in rows] + filter_dump = nf.model_dump(exclude_none=True) + hint_payload: dict[str, Any] = { + "success": True, + "kind": kind, + "results": [r.model_dump() for r in refs], + "limit": limit, + "offset": offset, + "filter": filter_dump, + } + return FindOutput( + success=True, + results=refs, + limit=limit, + offset=offset, + hints=generate_hints("find", hint_payload), + ) except Exception as exc: - return FindOutput(success=False, message=str(exc)) + return FindOutput(success=False, message=str(exc), hints=[], limit=None, offset=None) def describe_v2( @@ -752,7 +817,7 @@ def describe_v2( has_id = bool(id and str(id).strip()) has_fqn = bool(fqn and str(fqn).strip()) if not has_id and not has_fqn: - return DescribeOutput(success=False, message="id or fqn required") + return DescribeOutput(success=False, message="id or fqn required", hints=[]) hint_message: str | None = None node_id: str if has_id: @@ -764,7 +829,7 @@ def describe_v2( {"fqn": fqn_val}, ) if not rows: - return DescribeOutput(success=False, message=f"No Symbol found for fqn='{fqn_val}'") + return DescribeOutput(success=False, message=f"No Symbol found for fqn='{fqn_val}'", hints=[]) node_id = str(rows[0]["id"] or "") if len(rows) > 1: hint_message = ( @@ -775,18 +840,20 @@ def describe_v2( kind = _resolve_node_kind(g, node_id) row = _load_node_record(g, node_id, kind) if row is None: - return DescribeOutput(success=False, message=f"No node found for `{node_id}`") + return DescribeOutput(success=False, message=f"No node found for `{node_id}`", hints=[]) ref = _node_ref_from_row(kind, row) edge_summary = _edge_summary_for_node(g, node_id, kind=kind, row=row) + record = NodeRecord(id=ref.id, kind=kind, fqn=ref.fqn, data=row, edge_summary=edge_summary) return DescribeOutput( success=True, - record=NodeRecord(id=ref.id, kind=kind, fqn=ref.fqn, data=row, edge_summary=edge_summary), + record=record, message=hint_message, + hints=generate_hints("describe", {"success": True, "record": record.model_dump()}), ) except ValueError as exc: - return DescribeOutput(success=False, message=str(exc)) + return DescribeOutput(success=False, message=str(exc), hints=[]) except Exception as exc: - return DescribeOutput(success=False, message=str(exc)) + return DescribeOutput(success=False, message=str(exc), hints=[]) def _resolve_validate_identifier(raw: str) -> tuple[str | None, str | None]: @@ -1092,10 +1159,15 @@ def neighbors_v2( ) except ValidationError as exc: _log_fail_loud("unknown_key") - return NeighborsOutput(success=False, message=_filter_validation_error_message(exc)) + return NeighborsOutput( + success=False, + message=_filter_validation_error_message(exc), + hints=[], + requested_edge_types=[], + ) if nf and (err := _validate_no_wildcards(nf)): _log_fail_loud("wildcard") - return NeighborsOutput(success=False, message=err) + return NeighborsOutput(success=False, message=err, hints=[], requested_edge_types=[]) origins = [ids] if isinstance(ids, str) else list(ids) results: list[Edge] = [] for origin_id in origins: @@ -1133,7 +1205,7 @@ def neighbors_v2( continue if nf and (err := _nodefilter_applicability_error(other_kind, nf)): _log_fail_loud("applicability") - return NeighborsOutput(success=False, message=err) + return NeighborsOutput(success=False, message=err, hints=[], requested_edge_types=[]) if not _node_matches_filter(other_kind, other_rec, nf): continue attrs = { @@ -1155,8 +1227,19 @@ def neighbors_v2( attrs=attrs, ) ) - return NeighborsOutput(success=True, results=results[offset : offset + limit]) + sliced = results[offset : offset + limit] + neigh_payload = { + "success": True, + "results": [e.model_dump() for e in sliced], + "requested_edge_types": list(labels), + } + return NeighborsOutput( + success=True, + results=sliced, + requested_edge_types=list(labels), + hints=generate_hints("neighbors", neigh_payload), + ) except ValidationError: raise except Exception as exc: - return NeighborsOutput(success=False, message=str(exc)) + return NeighborsOutput(success=False, message=str(exc), hints=[], requested_edge_types=[]) diff --git a/pyproject.toml b/pyproject.toml index b3a6354..756d056 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ py-modules = [ "java_index_v1_common", "java_ontology", "kuzu_queries", + "mcp_hints", "mcp_v2", "path_filtering", "pr_analysis", diff --git a/server.py b/server.py index a2c3413..4b17915 100644 --- a/server.py +++ b/server.py @@ -339,7 +339,8 @@ def create_mcp_server() -> FastMCP: "(`*`, `?`) in prefix fields are rejected—use ranked `query` text instead. There is **no** " "structured DSL inside `query`; structured predicates belong in `find`. " "For identifier-shaped lookups (FQN, id prefix, route/client identifiers, …), use `resolve` first; " - "use `search` for natural-language or ranked fuzzy discovery." + "use `search` for natural-language or ranked fuzzy discovery. " + "Successful responses echo `limit`/`offset` and may include `hints` (advisory next-step strings)." ), ) async def search( @@ -386,7 +387,8 @@ async def search( "**route** — microservice, module, http_method, path_prefix, framework; **client** — microservice, module, " "source_layer, client_kind, target_service, target_path_prefix, http_method. " "Wildcards in prefix fields are rejected. An empty filter (`{}`) or `filter=None` means no predicate (all nodes of " - "that kind; use pagination). Unknown keys or inapplicable populated fields return success=false." + "that kind; use pagination). Unknown keys or inapplicable populated fields return success=false. " + "Successful responses echo `limit`/`offset` and may include `hints` (advisory next-step strings)." ), ) async def find( @@ -419,7 +421,8 @@ async def find( "is a normal edge label and may be traversed via neighbors(edge_types=[..., \"OVERRIDES\", ...]). " "Pass `id` for any kind, or exact `fqn` for Symbol lookup (`id` wins when both are set). " "`describe(fqn=…)` keeps the first graph row when multiple symbols share that FQN; when an FQN may collide, " - "prefer `resolve(identifier=…, hint_kind='symbol')` first, then `describe(id=…)` on the chosen node." + "prefer `resolve(identifier=…, hint_kind='symbol')` first, then `describe(id=…)` on the chosen node. " + "Successful responses may include `hints` (advisory next-step strings)." ), ) async def describe( diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py new file mode 100644 index 0000000..7af804d --- /dev/null +++ b/tests/test_mcp_hints.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import inspect +from typing import Any + +import pytest + +import mcp_hints +from mcp_hints import ( + PRIORITY_DECLARES_TYPE_ROLLUP, + PRIORITY_LEAF_FOLLOWUP, + PRIORITY_META, + PRIORITY_OVERRIDDEN_AXIS, + finalize_hint_list, + generate_hints, +) +from mcp_v2 import FindOutput, SearchOutput, describe_v2, find_v2, neighbors_v2, resolve_v2, search_v2 + +_TYPE_KINDS = frozenset({"class", "interface", "enum", "record", "annotation"}) + + +def _type_symbol_id_with_member_clients(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol)-[:DECLARES]->(m:Symbol)-[:DECLARES_CLIENT]->(:Client) " + "WHERE t.kind IN $kinds " + "RETURN t.id AS id ORDER BY t.fqn LIMIT 1", + {"kinds": sorted(_TYPE_KINDS)}, + ) + assert rows + return str(rows[0]["id"]) + + +def _controller_class_id_with_exposes(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol)-[:DECLARES]->(m:Symbol)-[:EXPOSES]->(:Route) " + "WHERE t.role = 'CONTROLLER' AND t.kind = 'class' " + "RETURN t.id AS id LIMIT 1", + ) + assert rows + return str(rows[0]["id"]) + + +def _interface_method_with_override_rollups(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (iface:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'requestAssignment' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "com.bank.chat.engine.assign.ChatAssignmentPort"}, + ) + assert rows + return str(rows[0]["id"]) + + +def _concrete_override_method_id(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " + "WHERE m.kind = 'method' AND m.name = 'requestAssignment' " + "RETURN m.id AS id LIMIT 1", + {"fqn": "com.bank.chat.engine.assign.ConfigurableChatAssignment"}, + ) + assert rows + return str(rows[0]["id"]) + + +def _method_id_declares_client_and_other_out_edge(kuzu_graph) -> str | None: + for pattern in ( + "MATCH (m:Symbol {kind: 'method'})-[:DECLARES_CLIENT]->() MATCH (m)-[:CALLS]->() RETURN m.id AS id LIMIT 1", + "MATCH (m:Symbol {kind: 'method'})-[:DECLARES_CLIENT]->() MATCH (m)-[:HTTP_CALLS]->() RETURN m.id AS id LIMIT 1", + ): + rows = kuzu_graph._rows(pattern) # noqa: SLF001 + if rows: + return str(rows[0]["id"]) + return None + + +def _method_declares_client(kuzu_graph) -> str: + mid = _method_id_declares_client_and_other_out_edge(kuzu_graph) + if mid is None: + pytest.skip("no method with DECLARES_CLIENT + outbound edge in fixture") + return mid + + +def _method_id_without_dispatch_rollups(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol) " + "WHERE m.kind = 'method' " + "AND NOT list_contains(COALESCE(m.modifiers, []), 'static') " + "AND NOT EXISTS { " + "MATCH (m)<-[:DECLARES]-(t:Symbol), (impl:Symbol)-[:IMPLEMENTS|EXTENDS]->(t), " + "(impl)-[:DECLARES]->(mover:Symbol) " + "WHERE mover.signature = m.signature AND mover.id <> m.id } " + "AND NOT EXISTS { " + "MATCH (m)<-[:DECLARES]-(impl:Symbol), (impl)-[:IMPLEMENTS|EXTENDS]->(parent:Symbol), " + "(parent)-[:DECLARES]->(decl:Symbol) " + "WHERE decl.signature = m.signature AND decl.id <> m.id } " + "RETURN m.id AS id LIMIT 1", + ) + assert rows + return str(rows[0]["id"]) + + +def _controller_method_many_calls(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol)-[e:CALLS]->() WHERE m.kind = 'method' " + "WITH m, count(e) AS nout WHERE nout >= 10 RETURN m.id AS id LIMIT 1", + ) + assert rows + return str(rows[0]["id"]) + + +def _route_id(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (r:Route) RETURN r.id AS id ORDER BY r.id LIMIT 1" + ) + assert rows + return str(rows[0]["id"]) + + +def _client_id(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (c:Client) RETURN c.id AS id ORDER BY c.id LIMIT 1" + ) + assert rows + return str(rows[0]["id"]) + + +def _class_symbol_id(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (t:Symbol) WHERE t.kind = 'class' RETURN t.id AS id LIMIT 1") + assert rows + return str(rows[0]["id"]) + + +def test_hints_describe_type_symbol_clients_via_members_emits(kuzu_graph) -> None: + tid = _type_symbol_id_with_member_clients(kuzu_graph) + out = describe_v2(tid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS.format(id=tid) + assert want in out.hints + + +def test_hints_describe_type_symbol_routes_via_members_emits(kuzu_graph) -> None: + tid = _controller_class_id_with_exposes(kuzu_graph) + out = describe_v2(tid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS.format(id=tid) + assert want in out.hints + + +def test_hints_describe_method_overriders_emits(kuzu_graph) -> None: + mid = _interface_method_with_override_rollups(kuzu_graph) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_OVERRIDERS.format(id=mid) + assert want in out.hints + + +def test_hints_describe_method_clients_in_overriders_emits(kuzu_graph) -> None: + mid = _interface_method_with_override_rollups(kuzu_graph) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS.format(id=mid) + assert want in out.hints + + +def test_hints_describe_method_declares_client_emits(kuzu_graph) -> None: + mid = _method_declares_client(kuzu_graph) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT.format(id=mid) + assert want in out.hints + + +def test_hints_describe_method_exposes_emits(kuzu_graph) -> None: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol)-[:EXPOSES]->(:Route) WHERE m.kind = 'method' RETURN m.id AS id LIMIT 1" + ) + assert rows + mid = str(rows[0]["id"]) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_METHOD_INBOUND_ROUTE.format(id=mid) + assert want in out.hints + + +def test_hints_describe_method_many_calls_emits(kuzu_graph) -> None: + mid = _controller_method_many_calls(kuzu_graph) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + assert mcp_hints.TPL_DESCRIBE_METHOD_MANY_CALLS in out.hints + + +def test_hints_describe_route_always_declaring_method(kuzu_graph) -> None: + rid = _route_id(kuzu_graph) + out = describe_v2(rid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_ROUTE_DECLARING.format(id=rid) + assert out.hints == [want] + + +def test_hints_describe_client_always_declaring_method(kuzu_graph) -> None: + cid = _client_id(kuzu_graph) + out = describe_v2(cid, graph=kuzu_graph) + assert out.success and out.record + want = mcp_hints.TPL_DESCRIBE_CLIENT_DECLARING.format(id=cid) + assert out.hints == [want] + + +def test_hints_find_empty_identifier_filter_suggests_resolve(kuzu_graph) -> None: + out = find_v2("client", {"target_service": "__no_such_target_service__"}, graph=kuzu_graph) + assert out.success is True + assert out.results == [] + assert "hint_kind" in inspect.signature(resolve_v2).parameters + assert any("resolve(identifier" in h and "hint_kind='client'" in h for h in out.hints) + + +def test_hints_find_page_full_emits_narrow_or_paginate(kuzu_graph) -> None: + out = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=1, offset=0) + assert out.success is True + assert len(out.results) >= 1 + assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) in out.hints + + +def test_hints_neighbors_empty_with_edge_types_emits_kind_check(kuzu_graph) -> None: + class_id = _class_symbol_id(kuzu_graph) + out = neighbors_v2(class_id, direction="out", edge_types=["DECLARES_CLIENT"], graph=kuzu_graph) + assert out.success is True + assert out.results == [] + assert out.requested_edge_types == ["DECLARES_CLIENT"] + assert mcp_hints.TPL_NEIGHBORS_EMPTY_KIND_CHECK in out.hints + + +def test_hints_search_weak_structural_signal_emits(monkeypatch, kuzu_graph) -> None: + rows = [ + { + "filename": "X.java", + "start": {"byte_offset": 0}, + "end": {"byte_offset": 1}, + "symbol_id": "sym:a", + "primary_type_fqn": "x.A", + "_rrf_score": 1.0, + "text": "a", + }, + { + "filename": "Y.java", + "start": {"byte_offset": 0}, + "end": {"byte_offset": 1}, + "symbol_id": "sym:b", + "primary_type_fqn": "x.B", + "_rrf_score": 0.95, + "text": "b", + }, + ] + monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: rows) + out = search_v2("q", limit=2, offset=0, graph=kuzu_graph) + assert out.success is True + assert len(out.results) == 2 + assert out.limit == 2 + assert mcp_hints.TPL_SEARCH_WEAK in out.hints + + +def test_hints_search_dominant_top_no_weak_hint(monkeypatch, kuzu_graph) -> None: + rows = [ + { + "filename": "X.java", + "start": {"byte_offset": 0}, + "end": {"byte_offset": 1}, + "symbol_id": "sym:a", + "primary_type_fqn": "x.A", + "_rrf_score": 1.0, + "text": "a", + }, + { + "filename": "Y.java", + "start": {"byte_offset": 0}, + "end": {"byte_offset": 1}, + "symbol_id": "sym:b", + "primary_type_fqn": "x.B", + "_rrf_score": 0.5, + "text": "b", + }, + ] + monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: rows) + out = search_v2("q", limit=2, offset=0, graph=kuzu_graph) + assert out.success is True + assert mcp_hints.TPL_SEARCH_WEAK not in out.hints + + +def test_hints_search_limit_none_never_emits_weak_hint() -> None: + payload = { + "success": True, + "limit": None, + "offset": 0, + "results": [ + {"chunk_id": "a", "symbol_id": "s", "fqn": "F", "score": 1.0, "snippet": ""}, + {"chunk_id": "b", "symbol_id": "s", "fqn": "F", "score": 0.99, "snippet": ""}, + ], + } + assert generate_hints("search", payload) == [] + + +def test_hints_dedupe_collapses_identical_rendered_strings() -> None: + out = finalize_hint_list( + [ + (PRIORITY_META, "same"), + (PRIORITY_DECLARES_TYPE_ROLLUP, "same"), + ] + ) + assert out == ["same"] + + +def test_hints_cap_drops_lowest_priority_over_five() -> None: + scored = [ + (PRIORITY_META, "m1"), + (PRIORITY_META, "m2"), + (PRIORITY_LEAF_FOLLOWUP, "l1"), + (PRIORITY_LEAF_FOLLOWUP, "l2"), + (PRIORITY_OVERRIDDEN_AXIS, "o1"), + (PRIORITY_DECLARES_TYPE_ROLLUP, "d1"), + ] + got = finalize_hint_list(scored) + assert len(got) == 5 + assert "m2" not in got + assert "d1" in got and "o1" in got + + +def test_hints_kind_gate_method_payload_ignores_type_only_rollups() -> None: + node_id = "sym:com.example.T#m()" + rec = { + "id": node_id, + "kind": "symbol", + "fqn": "com.example.T#m()", + "data": {"kind": "method"}, + "edge_summary": { + "DECLARES.DECLARES_CLIENT": {"in": 0, "out": 3}, + "DECLARES.EXPOSES": {"in": 0, "out": 2}, + }, + } + hints = generate_hints("describe", {"success": True, "record": rec}) + for h in hints: + assert "via members" not in h + + +def test_hints_clean_outputs_empty(kuzu_graph) -> None: + mid = _method_id_without_dispatch_rollups(kuzu_graph) + out = describe_v2(mid, graph=kuzu_graph) + assert out.success and out.record + assert out.hints == [] + + fout = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=500, offset=0) + assert fout.success and fout.results + assert fout.hints == [] + + +def test_hints_error_path_success_false_empty(kuzu_graph) -> None: + assert generate_hints("find", {"success": False, "kind": "symbol", "results": [], "filter": {}}) == [] + assert generate_hints("search", {"success": False, "results": []}) == [] + assert generate_hints("describe", {"success": False, "record": {}}) == [] + assert generate_hints("neighbors", {"success": False, "results": [], "requested_edge_types": ["CALLS"]}) == [] + serr = search_v2("q", filter={"bad_key": 1}, graph=kuzu_graph) + assert serr.success is False and serr.hints == [] and serr.limit is None and serr.offset is None + ferr = find_v2("symbol", {"path_prefix": "/api"}, graph=kuzu_graph) + assert ferr.success is False and ferr.hints == [] and ferr.limit is None and ferr.offset is None + + +def test_find_output_pagination_echo_round_trip(kuzu_graph) -> None: + out = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=12, offset=7) + assert out.success is True + assert out.limit == 12 + assert out.offset == 7 + raw = FindOutput( + success=True, + results=out.results, + limit=12, + offset=7, + hints=[], + ) + assert raw.model_dump()["limit"] == 12 and raw.model_dump()["offset"] == 7 + + +def test_search_output_pagination_echo_round_trip(monkeypatch, kuzu_graph) -> None: + rows = [ + { + "filename": "X.java", + "start": {"byte_offset": 0}, + "end": {"byte_offset": 1}, + "_rrf_score": 0.5, + "text": "x", + }, + ] + monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: rows) + out = search_v2("q", limit=9, offset=4, graph=kuzu_graph) + assert out.success is True + assert out.limit == 9 + assert out.offset == 4 + dumped = SearchOutput( + success=True, + results=out.results, + limit=9, + offset=4, + hints=[], + ).model_dump() + assert dumped["limit"] == 9 and dumped["offset"] == 4 + + +def test_hints_pagination_none_skips_page_derived_hints() -> None: + assert ( + generate_hints( + "find", + { + "success": True, + "kind": "symbol", + "results": [{"id": "x"}], + "limit": None, + "offset": 0, + "filter": {}, + }, + ) + == [] + ) + assert ( + mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) + not in generate_hints( + "find", + { + "success": True, + "kind": "symbol", + "results": [{"id": str(i)} for i in range(5)], + "limit": None, + "offset": 0, + "filter": {}, + }, + ) + ) + + +@pytest.mark.parametrize( + ("template", "fmt"), + [ + (mcp_hints.TPL_DESCRIBE_TYPE_CLIENTS_VIA_MEMBERS, {"id": "sym:a"}), + (mcp_hints.TPL_DESCRIBE_TYPE_ROUTES_VIA_MEMBERS, {"id": "sym:a"}), + (mcp_hints.TPL_DESCRIBE_METHOD_OVERRIDERS, {"id": "sym:a"}), + (mcp_hints.TPL_DESCRIBE_METHOD_CLIENTS_IN_OVERRIDERS, {"id": "sym:a"}), + (mcp_hints.TPL_DESCRIBE_METHOD_OUTBOUND_CLIENT, {"id": "sym:pkg.Type#m(int)"}), + (mcp_hints.TPL_DESCRIBE_METHOD_INBOUND_ROUTE, {"id": "sym:pkg.Type#m(int)"}), + (mcp_hints.TPL_DESCRIBE_METHOD_MANY_CALLS, {}), + (mcp_hints.TPL_DESCRIBE_ROUTE_DECLARING, {"id": "route:svc:GET:/api/v1/x"}), + (mcp_hints.TPL_DESCRIBE_CLIENT_DECLARING, {"id": "client:svc:feign:target:GET:/p"}), + (mcp_hints.TPL_FIND_EMPTY_RESOLVE, {"kind": "client"}), + (mcp_hints.TPL_FIND_PAGE_FULL, {"limit": 500}), + (mcp_hints.TPL_NEIGHBORS_EMPTY_KIND_CHECK, {}), + (mcp_hints.TPL_SEARCH_WEAK, {}), + ], +) +def test_hints_template_rendered_length_leq_120(template: str, fmt: dict[str, Any]) -> None: + rendered = template.format(**fmt) if fmt else template + assert len(rendered) <= 120, rendered From e068e5b1c92a1eee518f381c31bbab238939b7bf Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 10:52:54 +0300 Subject: [PATCH 2/4] document neighbors hints echo and drop dead test helper Align README and neighbors tool description with requested_edge_types and hints on success; remove unused override fixture helper. Co-authored-by: Cursor --- README.md | 2 +- server.py | 3 ++- tests/test_mcp_hints.py | 11 ----------- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 24d2e35..4bae1f1 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Example: {"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}} ``` -**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). See [`propose/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/HINTS-ROAD-SIGNS-PROPOSE.md) Appendix A for the locked v1 template catalog. +**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. --- diff --git a/server.py b/server.py index 4b17915..6d3b2a9 100644 --- a/server.py +++ b/server.py @@ -447,7 +447,8 @@ async def describe( "One-hop graph walk: **direction** (`in` | `out`) and non-empty **edge_types** are required. " "Optional `filter` applies to each neighbor endpoint row; populated fields must be applicable to that " "neighbor's kind—mixed-kind result sets fail on the first inapplicable neighbor (strict frame). " - "Wildcards in prefix fields are rejected. Unknown NodeFilter keys return success=false." + "Wildcards in prefix fields are rejected. Unknown NodeFilter keys return success=false. " + "Successful responses echo `requested_edge_types` and may include `hints` (advisory next-step strings)." ), ) async def neighbors( diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 7af804d..9384bfa 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -51,17 +51,6 @@ def _interface_method_with_override_rollups(kuzu_graph) -> str: return str(rows[0]["id"]) -def _concrete_override_method_id(kuzu_graph) -> str: - rows = kuzu_graph._rows( # noqa: SLF001 - "MATCH (t:Symbol {fqn: $fqn})-[:DECLARES]->(m:Symbol) " - "WHERE m.kind = 'method' AND m.name = 'requestAssignment' " - "RETURN m.id AS id LIMIT 1", - {"fqn": "com.bank.chat.engine.assign.ConfigurableChatAssignment"}, - ) - assert rows - return str(rows[0]["id"]) - - def _method_id_declares_client_and_other_out_edge(kuzu_graph) -> str | None: for pattern in ( "MATCH (m:Symbol {kind: 'method'})-[:DECLARES_CLIENT]->() MATCH (m)-[:CALLS]->() RETURN m.id AS id LIMIT 1", From 91d31cd544969c3b4bee400bf59aa295593148d4 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 10:57:43 +0300 Subject: [PATCH 3/4] 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 --- README.md | 2 +- mcp_hints.py | 25 +++++++++++++------- mcp_v2.py | 13 ++++++----- tests/test_mcp_hints.py | 51 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 4bae1f1..2e95a37 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Example: {"kind":"symbol","filter":{"microservice":"chat-core","symbol_kind":"interface"}} ``` -**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. +**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. --- diff --git a/mcp_hints.py b/mcp_hints.py index 9b7eb41..72e3f06 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -93,15 +93,20 @@ def _find_has_identifier_shaped_filter(kind: str, flt: dict[str, Any]) -> bool: def finalize_hint_list(scored: list[tuple[int, str]]) -> list[str]: - """Dedupe identical rendered strings keeping the highest priority; cap to 5 (drop lowest).""" - best_pri: dict[str, int] = {} - for pri, text in scored: + """Dedupe identical rendered strings keeping the highest priority; cap to 5 (drop lowest). + + Within the same priority tier, keep hints in emission order (first scored wins the cap). + """ + best: dict[str, tuple[int, int]] = {} + for idx, (pri, text) in enumerate(scored): if not text: continue - prev = best_pri.get(text) - if prev is None or pri > prev: - best_pri[text] = pri - ordered = sorted(best_pri.items(), key=lambda kv: (-kv[1], kv[0])) + prev = best.get(text) + if prev is None or pri > prev[0]: + best[text] = (pri, idx) + elif pri == prev[0]: + best[text] = (pri, min(prev[1], idx)) + ordered = sorted(best.items(), key=lambda kv: (-kv[1][0], kv[1][1])) return [text for text, _pri in ordered[:5]] @@ -137,7 +142,11 @@ def generate_hints( lim = payload.get("limit") if not results and _find_has_identifier_shaped_filter(kind, flt): pairs.append((PRIORITY_META, TPL_FIND_EMPTY_RESOLVE.format(kind=kind))) - if lim is not None and len(results) >= int(lim): + if ( + lim is not None + and len(results) >= int(lim) + and payload.get("has_more_results") is True + ): pairs.append((PRIORITY_META, TPL_FIND_PAGE_FULL.format(limit=int(lim)))) return finalize_hint_list(pairs) diff --git a/mcp_v2.py b/mcp_v2.py index 0751d0e..ba39657 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -756,25 +756,24 @@ def find_v2( if err := _validate_no_wildcards(nf): _log_fail_loud("wildcard") return FindOutput(success=False, message=err, hints=[], limit=None, offset=None) + fetch_cap = int(limit) + int(offset) + 1 if kind == "symbol": where, params = _symbol_where_from_filter(nf) - params["lim"] = int(limit) + int(offset) + params["lim"] = fetch_cap rows = g._rows( # noqa: SLF001 f"MATCH (s:Symbol) {where} RETURN s.id AS id, s.fqn AS fqn, s.microservice AS microservice, " "s.module AS module, s.role AS role, s.kind AS symbol_kind ORDER BY s.fqn LIMIT $lim", params, ) - rows = rows[offset : offset + limit] elif kind == "route": rows = g.list_routes( microservice=nf.microservice, framework=nf.framework, path_prefix=nf.path_prefix, method=nf.http_method, - limit=max(500, limit + offset), + limit=max(500, fetch_cap), ) rows = [r for r in rows if _node_matches_filter("route", r, nf)] - rows = rows[offset : offset + limit] else: rows = g.list_clients( microservice=nf.microservice, @@ -782,10 +781,11 @@ def find_v2( target_service=nf.target_service, path_prefix=nf.target_path_prefix, method=nf.http_method, - limit=max(500, limit + offset), + limit=max(500, fetch_cap), ) rows = [r for r in rows if _node_matches_filter("client", r, nf)] - rows = rows[offset : offset + limit] + has_more_results = len(rows) > int(offset) + int(limit) + rows = rows[offset : offset + limit] refs = [_node_ref_from_row(kind, r) for r in rows] filter_dump = nf.model_dump(exclude_none=True) hint_payload: dict[str, Any] = { @@ -795,6 +795,7 @@ def find_v2( "limit": limit, "offset": offset, "filter": filter_dump, + "has_more_results": has_more_results, } return FindOutput( success=True, diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 9384bfa..0d9985b 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -203,6 +203,13 @@ def test_hints_find_empty_identifier_filter_suggests_resolve(kuzu_graph) -> None assert any("resolve(identifier" in h and "hint_kind='client'" in h for h in out.hints) +def test_hints_find_empty_symbol_fqn_prefix_suggests_resolve(kuzu_graph) -> None: + out = find_v2("symbol", {"fqn_prefix": "__no_such_prefix__"}, graph=kuzu_graph) + assert out.success is True + assert out.results == [] + assert any("resolve(identifier" in h and "hint_kind='symbol'" in h for h in out.hints) + + def test_hints_find_page_full_emits_narrow_or_paginate(kuzu_graph) -> None: out = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=1, offset=0) assert out.success is True @@ -210,6 +217,20 @@ def test_hints_find_page_full_emits_narrow_or_paginate(kuzu_graph) -> None: assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) in out.hints +def test_hints_find_page_full_skips_when_last_page(kuzu_graph) -> None: + full = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=500, offset=0) + assert full.success and full.results + last = find_v2( + "symbol", + {"role": "CONTROLLER"}, + graph=kuzu_graph, + limit=1, + offset=len(full.results) - 1, + ) + assert last.success and len(last.results) == 1 + assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) not in last.hints + + def test_hints_neighbors_empty_with_edge_types_emits_kind_check(kuzu_graph) -> None: class_id = _class_symbol_id(kuzu_graph) 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: assert "d1" in got and "o1" in got +def test_hints_cap_same_priority_keeps_emission_order() -> None: + scored = [ + (PRIORITY_META, "z-meta"), + (PRIORITY_META, "a-meta"), + (PRIORITY_META, "b-meta"), + (PRIORITY_META, "c-meta"), + (PRIORITY_META, "d-meta"), + (PRIORITY_META, "e-meta"), + ] + got = finalize_hint_list(scored) + assert len(got) == 5 + assert "z-meta" in got + assert "e-meta" not in got + + +def test_hints_find_page_full_requires_has_more_results_flag() -> None: + full_page = { + "success": True, + "kind": "symbol", + "results": [{"id": "sym:a"}], + "limit": 1, + "offset": 0, + "filter": {}, + } + assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) not in generate_hints("find", full_page) + assert mcp_hints.TPL_FIND_PAGE_FULL.format(limit=1) in generate_hints( + "find", {**full_page, "has_more_results": True} + ) + + def test_hints_kind_gate_method_payload_ignores_type_only_rollups() -> None: node_id = "sym:com.example.T#m()" rec = { From d0ddd2c0cc866a5321f5d76f51f1a875733c9c76 Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Sat, 16 May 2026 11:10:38 +0300 Subject: [PATCH 4/4] fix clean-hints test by selecting on actual describe output Pick a method with empty describe hints via describe_v2; assert find returns all CONTROLLER rows under the page cap so page-full hints cannot fire in CI. Co-authored-by: Cursor --- tests/test_mcp_hints.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 0d9985b..25decc2 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -88,6 +88,18 @@ def _method_id_without_dispatch_rollups(kuzu_graph) -> str: return str(rows[0]["id"]) +def _method_id_with_empty_describe_hints(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (m:Symbol) WHERE m.kind = 'method' RETURN m.id AS id LIMIT 100", + ) + for row in rows: + mid = str(row["id"]) + out = describe_v2(mid, graph=kuzu_graph) + if out.success and out.record and out.hints == []: + return mid + pytest.fail("no method with empty describe hints in fixture") + + def _controller_method_many_calls(kuzu_graph) -> str: rows = kuzu_graph._rows( # noqa: SLF001 "MATCH (m:Symbol)-[e:CALLS]->() WHERE m.kind = 'method' " @@ -382,13 +394,19 @@ def test_hints_kind_gate_method_payload_ignores_type_only_rollups() -> None: def test_hints_clean_outputs_empty(kuzu_graph) -> None: - mid = _method_id_without_dispatch_rollups(kuzu_graph) + mid = _method_id_with_empty_describe_hints(kuzu_graph) out = describe_v2(mid, graph=kuzu_graph) assert out.success and out.record assert out.hints == [] + count_rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (s:Symbol) WHERE s.role = 'CONTROLLER' RETURN count(*) AS n", + ) + n_controllers = int(count_rows[0]["n"]) + assert n_controllers > 0 + assert n_controllers <= 500, "fixture has >500 CONTROLLER symbols; narrow filter for clean find hints" fout = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=500, offset=0) - assert fout.success and fout.results + assert fout.success and len(fout.results) == n_controllers assert fout.hints == []