diff --git a/README.md b/README.md index ad582d9..c40f4ee 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). 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/completed/HINTS-ROAD-SIGNS-PROPOSE.md`](./propose/completed/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`, `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. 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; neighbors fuzzy-strategy in a follow-up PR). --- diff --git a/mcp_hints.py b/mcp_hints.py index ee2f43b..f03b7a3 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -1,6 +1,7 @@ """Pure MCP v2 road-sign hint generation (no graph I/O, no search, no LLM). Locked v1 catalog: ``propose/completed/HINTS-ROAD-SIGNS-PROPOSE.md`` Appendix A. +v2 resolve catalog: ``propose/HINTS-V2-PROPOSE.md`` Appendix A. Priority cap: same propose §7.12 / ``plans/completed/PLAN-HINTS.md`` principles. """ @@ -46,6 +47,24 @@ TPL_SEARCH_WEAK = "results look weak — narrow the query or try find(role=…)" +# --- v2: resolve templates (propose/HINTS-V2-PROPOSE.md Appendix A) --- + +TPL_RESOLVE_NONE_TRY_SEARCH = ( + "no match — try search(query='{identifier}') for ranked fuzzy lookup" +) +TPL_RESOLVE_NONE_TRY_FIND_ROUTE = ( + "no match — try find(kind='route', filter={{path_prefix: '{seed}'}})" +) +TPL_RESOLVE_NONE_TRY_FIND_CLIENT = ( + "no match — try find(kind='client', filter={{target_service: '{seed}'}})" +) +TPL_RESOLVE_MANY_TIGHTEN = ( + "{n} candidates — tighten identifier or pick a candidate by id" +) + +_RESOLVE_HINT_MAX_CHARS = 120 +_RESOLVE_WILDCARDS = ("*", "?") + # §7.12 priority: DECLARES.* type rollups > OVERRIDDEN_BY.* > leaf follow-ups > meta. PRIORITY_DECLARES_TYPE_ROLLUP = 4 PRIORITY_OVERRIDDEN_AXIS = 3 @@ -111,18 +130,56 @@ def finalize_hint_list(scored: list[tuple[int, str]]) -> list[str]: def generate_hints( - output_kind: Literal["search", "find", "describe", "neighbors"], + output_kind: Literal["search", "find", "describe", "neighbors", "resolve"], 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. + For ``search`` / ``find`` / ``describe`` / ``neighbors``, callers must pass + ``success: True``; this function returns ``[]`` when ``success`` is false or + missing. The ``resolve`` branch is **status-driven** (``status``, + ``resolved_identifier``, ``candidates``, optional seeds) and does not require + ``success`` in the payload; an explicit ``success: False`` still suppresses + hints (defense in depth). """ - if not payload.get("success"): + pairs: list[tuple[int, str]] = [] + + if output_kind == "resolve": + if payload.get("success") is False: + return [] + status = str(payload.get("status") or "") + if status == "one": + return [] + if status == "many": + n = len(payload.get("candidates") or []) + if n > 1: + pairs.append((PRIORITY_META, TPL_RESOLVE_MANY_TIGHTEN.format(n=n))) + return finalize_hint_list(pairs) + if status == "none": + identifier = payload.get("resolved_identifier") + hint_kind = payload.get("hint_kind") + if not isinstance(identifier, str) or not identifier.strip(): + return finalize_hint_list(pairs) + if any(w in identifier for w in _RESOLVE_WILDCARDS): + return finalize_hint_list(pairs) + rendered: str | None = None + if hint_kind == "route": + seed = payload.get("path_prefix_seed") + if isinstance(seed, str) and seed.strip(): + rendered = TPL_RESOLVE_NONE_TRY_FIND_ROUTE.format(seed=seed) + elif hint_kind == "client": + seed = payload.get("target_service_seed") + if isinstance(seed, str) and seed.strip(): + rendered = TPL_RESOLVE_NONE_TRY_FIND_CLIENT.format(seed=seed) + else: + rendered = TPL_RESOLVE_NONE_TRY_SEARCH.format(identifier=identifier) + if rendered is not None and len(rendered) <= _RESOLVE_HINT_MAX_CHARS: + pairs.append((PRIORITY_META, rendered)) + return finalize_hint_list(pairs) return [] - pairs: list[tuple[int, str]] = [] + if not payload.get("success"): + return [] if output_kind == "search": results: list[dict[str, Any]] = list(payload.get("results") or []) diff --git a/mcp_v2.py b/mcp_v2.py index ba39657..e972352 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -388,6 +388,8 @@ class ResolveOutput(BaseModel): node: NodeRef | None = None candidates: list[ResolveCandidate] = Field(default_factory=list) message: str | None = None + resolved_identifier: str | None = None + hints: list[str] = Field(default_factory=list, description=MCP_HINTS_FIELD_DESCRIPTION) def _node_kind_from_id(id_str: str) -> Literal["symbol", "route", "client"]: @@ -1079,7 +1081,35 @@ def _resolve_assert_invariants(out: ResolveOutput) -> None: assert out.message -def _resolve_build_output(matches: list[ResolveCandidate]) -> ResolveOutput: +def _resolve_seeds_for_hints(identifier: str) -> tuple[str | None, str | None]: + path_prefix_seed: str | None = None + method_path = _resolve_parse_route_method_path(identifier) + if method_path is not None: + path_prefix_seed = method_path[1] + else: + ms_route = _resolve_parse_microservice_route(identifier) + if ms_route is not None: + path_prefix_seed = ms_route[2] + elif identifier.startswith("/"): + path_prefix_seed = identifier + + target_service_seed: str | None = None + if " " in identifier: + target, _path_prefix = identifier.split(" ", 1) + target = target.strip() + if target: + target_service_seed = target + elif not identifier.startswith("/"): + target_service_seed = identifier + + return path_prefix_seed, target_service_seed + + +def _resolve_finalize_success( + trimmed: str, + hint_kind: Literal["symbol", "route", "client"] | None, + matches: list[ResolveCandidate], +) -> ResolveOutput: if not matches: out = ResolveOutput( success=True, @@ -1087,11 +1117,33 @@ def _resolve_build_output(matches: list[ResolveCandidate]) -> ResolveOutput: message=( "No matches for identifier; use search(query=...) for ranked fuzzy lookup." ), + resolved_identifier=trimmed, ) elif len(matches) == 1: - out = ResolveOutput(success=True, status="one", node=matches[0].node) + out = ResolveOutput( + success=True, + status="one", + node=matches[0].node, + resolved_identifier=trimmed, + ) else: - out = ResolveOutput(success=True, status="many", candidates=matches) + out = ResolveOutput( + success=True, + status="many", + candidates=matches, + resolved_identifier=trimmed, + ) + + path_prefix_seed, target_service_seed = _resolve_seeds_for_hints(trimmed) + hint_payload = { + "status": out.status, + "resolved_identifier": trimmed, + "candidates": out.candidates, + "hint_kind": hint_kind, + "path_prefix_seed": path_prefix_seed, + "target_service_seed": target_service_seed, + } + out = out.model_copy(update={"hints": generate_hints("resolve", hint_payload)}) _resolve_assert_invariants(out) return out @@ -1104,13 +1156,19 @@ def resolve_v2( try: trimmed, err = _resolve_validate_identifier(identifier) if err is not None: - out = ResolveOutput(success=False, status="none", message=err) + out = ResolveOutput( + success=False, + status="none", + message=err, + hints=[], + resolved_identifier=None, + ) _resolve_assert_invariants(out) return out assert trimmed is not None if "*" in trimmed or "?" in trimmed: - return _resolve_build_output([]) + return _resolve_finalize_success(trimmed, hint_kind, []) g = graph or KuzuGraph.get() raw: list[tuple[NodeRef, ResolveReason, int]] = [] @@ -1125,9 +1183,17 @@ def resolve_v2( deduped = _resolve_dedupe_candidates(raw) ranked = _resolve_rank_candidates(deduped) capped = ranked[:_RESOLVE_CANDIDATE_CAP] - return _resolve_build_output(capped) + return _resolve_finalize_success(trimmed, hint_kind, capped) except Exception as exc: - return ResolveOutput(success=False, status="none", message=str(exc)) + out = ResolveOutput( + success=False, + status="none", + message=str(exc), + hints=[], + resolved_identifier=None, + ) + _resolve_assert_invariants(out) + return out @validate_call(config={"arbitrary_types_allowed": True}) diff --git a/server.py b/server.py index 6d3b2a9..6ce89b1 100644 --- a/server.py +++ b/server.py @@ -498,6 +498,7 @@ async def neighbors( "status=one (single node), many (≥2 ranked candidates with reason), or none " "(no match — fall back to search(query=...) for natural language or fuzzy text). " "Optional hint_kind narrows to symbol, route, or client. " + "Successful responses may include advisory hints (same contract as other v2 tools). " "Malformed empty/whitespace identifier returns success=false. " "Examples: resolve('com.foo.Bar', hint_kind='symbol'); " "resolve('GET /api/v1/customers', hint_kind='route'); " diff --git a/tests/test_mcp_hints.py b/tests/test_mcp_hints.py index 25decc2..7e20984 100644 --- a/tests/test_mcp_hints.py +++ b/tests/test_mcp_hints.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +from collections import Counter from typing import Any import pytest @@ -410,6 +411,225 @@ def test_hints_clean_outputs_empty(kuzu_graph) -> None: assert fout.hints == [] +def _resolve_symbol_id_status_one(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (s:Symbol) WHERE s.kind = 'class' RETURN s.id AS id LIMIT 1", + ) + assert rows + sym_id = str(rows[0]["id"]) + out = resolve_v2(sym_id, hint_kind="symbol", graph=kuzu_graph) + if not (out.success and out.status == "one"): + pytest.fail(f"expected status one for symbol id {sym_id!r}, got {out.status!r}") + return sym_id + + +def _resolve_symbol_short_name_status_many(kuzu_graph) -> str: + rows = kuzu_graph._rows( # noqa: SLF001 + "MATCH (s:Symbol) WHERE s.kind = 'method' RETURN s.name AS name", + ) + counts = Counter(str(r["name"]) for r in rows if r.get("name")) + dup_name = next((name for name, c in counts.items() if c >= 2), None) + if dup_name is None: + pytest.fail("no duplicated method short names in bank-chat fixture") + out = resolve_v2(dup_name, hint_kind="symbol", graph=kuzu_graph) + if not (out.success and out.status == "many" and len(out.candidates) >= 2): + pytest.fail(f"expected status many for short name {dup_name!r}, got {out.status!r}") + return dup_name + + +def _resolve_symbol_identifier_status_none(kuzu_graph) -> str: + ident = "com.nonexistent.ZzzMissing" + out = resolve_v2(ident, hint_kind="symbol", graph=kuzu_graph) + if not (out.success and out.status == "none"): + pytest.fail(f"expected status none for {ident!r}, got {out.status!r}") + return ident + + +def test_hints_resolve_status_one_emits_empty() -> None: + assert generate_hints("resolve", {"status": "one", "resolved_identifier": "com.foo.Bar"}) == [] + + +def test_hints_resolve_status_none_symbol_suggests_search() -> None: + ident = "com.foo.Bar#nonExistent" + hints = generate_hints( + "resolve", + {"status": "none", "resolved_identifier": ident, "hint_kind": "symbol"}, + ) + assert hints + assert "search(query=" in hints[0] + assert ident in hints[0] + + +def test_hints_resolve_status_none_symbol_drop_on_overflow() -> None: + ident = "x" * 80 + hints = generate_hints( + "resolve", + {"status": "none", "resolved_identifier": ident, "hint_kind": "symbol"}, + ) + assert hints == [] + + +def test_hints_resolve_status_none_symbol_wildcard_suppressed() -> None: + hints = generate_hints( + "resolve", + {"status": "none", "resolved_identifier": "com.foo.*", "hint_kind": "symbol"}, + ) + assert hints == [] + + +def test_hints_resolve_status_none_route_suggests_find() -> None: + seed = "/v1/operator/session/update" + hints = generate_hints( + "resolve", + { + "status": "none", + "resolved_identifier": f"POST {seed}", + "hint_kind": "route", + "path_prefix_seed": seed, + }, + ) + assert hints + assert "find(kind='route'" in hints[0] + assert seed in hints[0] + + +def test_hints_resolve_status_none_route_no_seed_suppressed() -> None: + hints = generate_hints( + "resolve", + { + "status": "none", + "resolved_identifier": "not-a-route-shape", + "hint_kind": "route", + "path_prefix_seed": None, + }, + ) + assert hints == [] + + +def test_hints_resolve_status_none_client_suggests_find() -> None: + seed = "smartcare-assign-chat" + hints = generate_hints( + "resolve", + { + "status": "none", + "resolved_identifier": seed, + "hint_kind": "client", + "target_service_seed": seed, + }, + ) + assert hints + assert "find(kind='client'" in hints[0] + assert seed in hints[0] + + +def test_hints_resolve_status_none_client_no_seed_suppressed() -> None: + hints = generate_hints( + "resolve", + { + "status": "none", + "resolved_identifier": "/only/a/path", + "hint_kind": "client", + "target_service_seed": None, + }, + ) + assert hints == [] + + +def test_hints_resolve_status_many_emits_tighten() -> None: + hints = generate_hints( + "resolve", + { + "status": "many", + "resolved_identifier": "open", + "candidates": [{"id": "a"}, {"id": "b"}], + }, + ) + assert hints + assert "2 candidates" in hints[0] + assert "tighten identifier" in hints[0] + + +def test_hints_resolve_status_many_truncated_cap_wording() -> None: + hints = generate_hints( + "resolve", + { + "status": "many", + "resolved_identifier": "open", + "candidates": [{"id": f"c{i}"} for i in range(10)], + }, + ) + assert hints + assert "10 candidates" in hints[0] + + +def test_hints_resolve_success_false_suppresses() -> None: + hints = generate_hints( + "resolve", + { + "success": False, + "status": "none", + "resolved_identifier": "com.foo.Bar", + "hint_kind": "symbol", + }, + ) + assert hints == [] + + +def test_hints_resolve_payload_missing_identifier_suppressed() -> None: + hints = generate_hints( + "resolve", + {"status": "none", "resolved_identifier": "", "hint_kind": "symbol"}, + ) + assert hints == [] + + +def test_hints_resolve_v2_round_trip(kuzu_graph) -> None: + none_ident = _resolve_symbol_identifier_status_none(kuzu_graph) + none_out = resolve_v2(none_ident, hint_kind="symbol", graph=kuzu_graph) + assert none_out.resolved_identifier == none_ident + assert none_out.hints + assert "search(query=" in none_out.hints[0] + + one_id = _resolve_symbol_id_status_one(kuzu_graph) + one_out = resolve_v2(one_id, hint_kind="symbol", graph=kuzu_graph) + assert one_out.resolved_identifier == one_id + assert one_out.hints == [] + + wildcard_out = resolve_v2("com.foo.*Service", hint_kind="symbol", graph=kuzu_graph) + assert wildcard_out.success is True + assert wildcard_out.status == "none" + assert wildcard_out.resolved_identifier == "com.foo.*Service" + assert wildcard_out.hints == [] + + many_ident = _resolve_symbol_short_name_status_many(kuzu_graph) + many_out = resolve_v2(many_ident, hint_kind="symbol", graph=kuzu_graph) + assert many_out.resolved_identifier == many_ident + assert many_out.hints + assert "candidates" in many_out.hints[0] + assert "tighten identifier" in many_out.hints[0] + + route_ident = "POST /v1/__no_such_resolve_route__" + route_out = resolve_v2(route_ident, hint_kind="route", graph=kuzu_graph) + assert route_out.success is True + assert route_out.status == "none" + assert route_out.resolved_identifier == route_ident + assert route_out.hints + assert "find(kind='route'" in route_out.hints[0] + + client_ident = "__no_such_resolve_client_target__" + client_out = resolve_v2(client_ident, hint_kind="client", graph=kuzu_graph) + assert client_out.success is True + assert client_out.status == "none" + assert client_out.resolved_identifier == client_ident + assert client_out.hints + assert "find(kind='client'" in client_out.hints[0] + + invalid_out = resolve_v2("", graph=kuzu_graph) + assert invalid_out.success is False + assert invalid_out.resolved_identifier is None + assert invalid_out.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": []}) == [] @@ -508,6 +728,13 @@ def test_hints_pagination_none_skips_page_derived_hints() -> None: (mcp_hints.TPL_FIND_PAGE_FULL, {"limit": 500}), (mcp_hints.TPL_NEIGHBORS_EMPTY_KIND_CHECK, {}), (mcp_hints.TPL_SEARCH_WEAK, {}), + (mcp_hints.TPL_RESOLVE_NONE_TRY_SEARCH, {"identifier": "com.example.Type#m()"}), + ( + mcp_hints.TPL_RESOLVE_NONE_TRY_FIND_ROUTE, + {"seed": "/v1/operator/session/update"}, + ), + (mcp_hints.TPL_RESOLVE_NONE_TRY_FIND_CLIENT, {"seed": "smartcare-assign-chat"}), + (mcp_hints.TPL_RESOLVE_MANY_TIGHTEN, {"n": 10}), ], ) def test_hints_template_rendered_length_leq_120(template: str, fmt: dict[str, Any]) -> None: