Skip to content

fix(mcp): search/find/neighbors family (#353, #354, #355, #356)#366

Merged
HumanBean17 merged 3 commits into
masterfrom
fix/mcp-search-family
Jul 4, 2026
Merged

fix(mcp): search/find/neighbors family (#353, #354, #355, #356)#366
HumanBean17 merged 3 commits into
masterfrom
fix/mcp-search-family

Conversation

@HumanBean17

Copy link
Copy Markdown
Owner

What

Four correctness/contract fixes in the search/find/neighbors family (mcp_v2.py).

#353 — search filter was post-filtered after pagination

search(filter=…) applied the NodeFilter only as a post-filter on rows already sliced by offset/limit, instead of pushing the predicates into the LanceDB query. A filtered page could shrink to 0–2 results even when many matches existed deeper in the ranking; paginating a filtered search gave inconsistent pages. Fix: forward role/module/microservice/capability/exclude_roles from the NodeFilter into run_search (which already accepted them). _node_matches_filter still re-checks every row afterward (covers the non-pushdownable fields and is the contract guarantee).

#354 — unresolved-call-site NodeRef dropped the callee name

NodeRef had no name field, so other=NodeRef(..., name=callee) was silently dropped (pydantic default extra="ignore"), leaving every unresolved-call-site edge with fqn="" and no readable callee in the structured ref. Fix: add name: str | None = None to NodeRef.

#355has_more_results computed but dropped

find_v2 computed has_more_results for the hint payload but FindOutput had no such field, so MCP clients never learned whether more pages existed. Fix: add has_more_results to FindOutput (wired) and NeighborsOutput (computed where neighbors paginates in Python; None on the single-origin CALLS SQL-paginated path, where calls_row_count is the signal).

#356 — neighbors generic Cypher typed-union RETURN (latent portability)

The flat-label neighbors query RETURNed a fixed superset of attributes regardless of which edge types matched — the anti-pattern AGENTS.md warns about. Missing columns return None on LadybugDB (no live bug), but a stricter binder (Kùzu) would error. Fix: issue one single-label query per edge type, RETURNing only that type's columns (via _FLAT_EDGE_ATTR_COLUMNS, aligned with the REL TABLE schemas in build_ast_graph.py). label(e) = $label scalar equality per the Cypher note.

Tests

One new test per fix, plus one FakeGraph test-double update:

  • test_search_pushes_nodefilter_into_run_search — asserts the 5 fields reach run_search.
  • test_unresolved_call_site_noderef_carries_callee_name_unresolved_site_to_edge keeps callee in other.name.
  • test_find_exposes_has_more_resultshas_more_results True mid-list, False past the end.
  • test_neighbors_flat_labels_select_columns_per_edge_type — each per-label query RETURNs only that type's columns.
  • test_neighbors_route_in_http_calls_returns_callers FakeGraph made label-aware (the per-label split now calls _rows once per label; the old double returned the same canned row for both labels).
.venv/bin/python -m pytest tests/test_mcp_v2.py -q
# 111 passed, 1 skipped

Notes

Closes #353, closes #354, closes #355, closes #356.

🤖 Generated with Claude Code

HumanBean17 and others added 2 commits July 3, 2026 23:31
…-type neighbor columns (#353-356)

Four correctness/contract fixes in the search/find/neighbors family:

#353 — search now forwards role/module/microservice/capability/exclude_roles
from NodeFilter into run_search so the filter applies BEFORE pagination, not as
a post-filter on the already-paginated page (which could empty a filtered page
even when many matches existed deeper in the ranking).

#354 — NodeRef gains `name`; the unresolved-call-site ref (other=NodeRef(...,
name=callee)) no longer silently drops the callee identifier (pydantic
extra='ignore' discarded it, leaving fqn='' and no readable callee).

#355 — FindOutput and NeighborsOutput gain `has_more_results`. find_v2 computed
it for the hint payload but never put it on the model; clients had to probe to
tell if a next page existed. neighbors_v2 computes it where it paginates in
Python (None on the single-origin CALLS SQL-paginated path, where
calls_row_count is the signal).

#356 — the generic flat-label neighbors query no longer RETURNs a fixed column
superset regardless of matched edge type (typed-union RETURN anti-pattern that
errors on stricter binders). It now issues one single-label query per edge type,
RETURNing only that type's columns (map aligned with build_ast_graph REL schemas).

Co-Authored-By: Claude <noreply@anthropic.com>
…full set

On the single-origin CALLS neighbors path, has_more_results was None
unconditionally. When paginate_in_sql is False (a node_filter is set,
include_unresolved, or dedup_calls), _neighbors_calls_for_origin loads
the FULL matching set (offset=0, limit=None), so the client already has
every edge -- has_more must be False, not None ("unknown"), so a paging
client need not issue a redundant probe (#355). Keep None only for the
SQL-paginated case where the row/unfiltered counts carry the signal.

Adds a regression test covering both modes (filtered -> False,
unfiltered single-origin -> None). Verified red->green.

Co-Authored-By: Claude <noreply@anthropic.com>
@HumanBean17

Copy link
Copy Markdown
Owner Author

Addressed a finding from a multi-agent review pass:

has_more_results=None on the non-paginated CALLS path (low) — the single-origin CALLS branch set has_more_results=None unconditionally, but when paginate_in_sql is False (a node filter is set, include_unresolved, or dedup_calls) the full filtered set is loaded and returned, so the client already has everything — None ("unknown") undercut #355's goal of letting a client avoid a probe. Now False when not SQL-paginated, None only when it is. Added a regression test covering both modes (filtered → False, unfiltered single-origin → None); verified red→green. test_mcp_v2.py 112 passed.

… hint input

- _FLAT_EDGE_ATTR_COLUMNS now asserts at import that it covers every EdgeType
  exactly. A future edge type added to EdgeType + EDGE_SCHEMA + a REL TABLE but
  missing here would otherwise silently SELECT only (id, label) and yield edges
  with empty attrs via the .get(label, ()) fallback; the assert trips on both a
  missing and a stale entry.
- The find/neighbors hint payloads used bare model_dump(), so every structured
  NodeRef carried name: null (and other null fields) into generate_hints. Switch
  to exclude_none=True -- these dicts feed generate_hints (which reads fields
  defensively via .get), not the tool result, and it matches the adjacent filter
  dumps. Verified against test_mcp_v2 / test_mcp_hints / test_client_hint_recovery.

Co-Authored-By: Claude <noreply@anthropic.com>
@HumanBean17

Copy link
Copy Markdown
Owner Author

Addressed 2 review findings:

  1. _FLAT_EDGE_ATTR_COLUMNS silent fallback — now asserts at import that it covers every EdgeType exactly. A future edge type missing here would otherwise SELECT only (id, label) and yield empty-attr edges; the assert trips on both a missing and a stale entry.
  2. name: null in hints — the find/neighbors hint payloads used bare model_dump(), leaking name: null (and other null fields) into generate_hints. Switched to exclude_none=True: these dicts feed hint generation only (not the tool result), generate_hints reads fields defensively via .get, and it matches the adjacent filter dumps. Verified against test_mcp_v2 / test_mcp_hints / test_client_hint_recovery.

@HumanBean17 HumanBean17 merged commit ffb17ec into master Jul 4, 2026
1 check passed
@HumanBean17 HumanBean17 deleted the fix/mcp-search-family branch July 4, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment