Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
67 changes: 62 additions & 5 deletions mcp_hints.py
Original file line number Diff line number Diff line change
@@ -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.
"""

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 [])
Expand Down
80 changes: 73 additions & 7 deletions mcp_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down Expand Up @@ -1079,19 +1081,69 @@ 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,
status="none",
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

Expand All @@ -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]] = []
Expand All @@ -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})
Expand Down
1 change: 1 addition & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'); "
Expand Down
Loading
Loading