Proposal — not yet implemented. Tracks upstream discussion: HumanBean17/java-codebase-rag#122 (tactical subset of the strategic frame question in #117).
MCP tools that accept a filter object use the shared NodeFilter Pydantic model in mcp_v2.py. Today that surface has two honesty gaps:
-
Unknown keys are ignored. Pydantic’s default extra handling means typos or misplaced fields (for example nesting a
kindselector insidefilterinstead of at the tool boundary) are silently dropped. The tool runs with a weaker filter than the agent believed it sent. -
Cross-kind fields are ignored at match time.
_node_matches_filteronly reads fields that apply to the row’s kind (symbol/route/client). Fields that belong to another kind are never consulted — so an agent can pass symbol-only constraints (for examplefqn_prefix) while callingfind(kind="client")and still receive every client, with no signal that part of the filter was inert.
Both behaviors are lossy: input the agent treated as meaningful is discarded without an error. That matches the “silent-drop bug class” called out in #117; this proposal fixes that class without choosing the long-term strict vs permissive vs hybrid frame (#117).
Bright line (from #122): lossless input normalization (same intent, different encoding — for example JSON string vs dict) remains acceptable; lossy silent drops are not.
-
NodeFilteruses explicit extra forbiddance. Setmodel_config = ConfigDict(extra="forbid")onNodeFilterso unknown top-level keys raisepydantic.ValidationErrorat parse time. That catches the “wrong key / nested kind” class cheaply. -
Per-kind applicability validation after
NodeFilteris constructed and before graph push-down / neighbor expansion: the set of filter fields that are populated (non-None, and for lists non-empty if that is the chosen rule) must be a subset of the fields defined as applicable for the effective kind:find_v2: thekindargument.search_v2: post-filter rows are always symbol-shaped hits from Lance; applicability is the symbol field set (today’s_node_matches_filter("symbol", …)branch).neighbors_v2: the neighbor node’s kind after resolution (symbol/route/client) — the filter applies toothernodes loaded in the loop, so validation should mirror that (see Open Questions).
When a populated field is not applicable, return the same structured failure style as other tool errors (
success=False,message=…) listing the offending field(s) and the applicable field names for that kind so the message acts as a teaching surface. Composition note: if #120 (hints field) lands, these messages are natural candidates for machine-readablehints. -
Keep
_coerce_filterbehavior. JSON-decoding a string into a dict before validation is a lossless multi-form input path (same object, different wire encoding) and stays as-is per #122. -
Do not change
search.querysemantics. Fuzzy query ranking is unrelated; only thefiltercontract is tightened.
Repo-specific correction vs #122 text: describe_v2 in this codebase does not accept filter; only find_v2, search_v2, and neighbors_v2 do. No change is required on describe_v2 for filter honesty unless a future proposal adds filtering there.
mcp_v2.py:NodeFiltermodel config; a small applicability helper (or inline checks) keyed by kind; wiring atfind_v2,search_v2, andneighbors_v2entry points after coerce + validate.- Tests: unknown filter keys; cross-kind populated fields; empty filter still succeeds; JSON-string filter still accepted.
- README / tool instruction strings if the public contract text should mention loud-fail filter semantics (minimal delta).
- Ontology bump: not required (no graph or indexer semantics change).
- Re-index required: no.
- Config / tool surface changes: behavior change only on invalid filter shapes — agents that relied on ignored extra keys or inert cross-kind fields will start seeing errors instead of silent success.
- Unknown top-level key in
filter→ validation failure (exact surface:ValidationErrorvs wrappedsuccess=False— see Open Questions). - Populated symbol-only field with
find(kind="client")→success=Falsewith message listing applicable client fields. - Populated client-only field with
find(kind="symbol")→success=False. fqn_prefix+find(kind="symbol")→ still honored (regression).- Empty
{}filter (andNonewhere allowed) → unchanged success path. - Filter passed as JSON string → still decodes then validates (lossless multi-form).
Run: .venv/bin/ruff check . and .venv/bin/python -m pytest tests -v (plus any new focused tests under tests/ naming the scenarios above).
-
ValidationErrorvs uniformsuccess=Falsefor unknown keys.find_v2/search_v2already wrap generic exceptions intosuccess=False;neighbors_v2currently re-raisesValidationErrorfor at least some validation paths. Should unknown-key failures be wrapped intosuccess=Falsefor all three tools for a uniform MCP experience, or is raisingValidationErroracceptable for “contract violations”?- Recommended: catch filter
ValidationErrorat the same boundary as applicability errors and returnsuccess=Falsewith a parsed, agent-readable message (consistent across tools).
- Recommended: catch filter
-
neighbors_v2applicability kind. Neighbors can be mixed when multiple origin ids are passed; each edge’sotherhas its own kind. Is the contract “filter must be applicable to every neighbor kind that could appear,” “union of fields that applies to any neighbor,” or “validate per neighbor row” (strictest, matches runtime)?- Recommended: validate per neighbor inside the loop is unnecessary if we define the rule as: populated fields must be subset of union of applicable fields across
symbol,route,client, or require the filter to only use the common subset (microservice,module). The issue text implies per-request kind likefind; neighbors are trickier — default to reject if any populated field is not applicable to the neighbor kind currently being tested (same as proposed post-validate before filtering eachother), which may mean no single upfront check — implementors can still factor a helper_filter_applies_to_kind(kind, nf) -> str | Nonereturning an error message.
- Recommended: validate per neighbor inside the loop is unnecessary if we define the rule as: populated fields must be subset of union of applicable fields across
-
Empty lists vs
None. Shouldexclude_roles: []count as “populated” for applicability?- Recommended: treat empty list as absent (same as
None) for applicability and for Cypher push-down, matching intuitive “no-op constraint” behavior.
- Recommended: treat empty list as absent (same as
- Choosing or implementing the full #117 frame (strict / permissive / hybrid vocabulary).
- New tools (
resolve, etc.), field renames, or cross-kind field aliasing. - Changing
search.queryfuzzy matching or ranking. - Changing
EdgeTypeliterals,find.kindliterals, or graph schema. describe_v2(no filter today).
- Independent of graph builder / Kuzu schema work; can land in one small PR after any urgent Kuzu query fixes upstream maintainers prioritize (issue #119 in the same tracker — independent).
- Composes with hints-field work (#120): teaching-style messages become structured hints later.
- Strategic #117 remains open to lock the long-term contract after observing real agent traffic under loud-fail behavior.
NodeFilter—mcp_v2.py(approx. lines 59–76)._coerce_filter—mcp_v2.py(approx. lines 79–98)._node_matches_filter—mcp_v2.py(approx. lines 351–398).search_v2/find_v2/neighbors_v2—mcp_v2.py(approx. lines 401+).