Skip to content

Commit e442877

Browse files
enforce lossless nodefilter failures
Co-authored-by: HumanBean17 <doudmitry@gmail.com>
1 parent c7cdc10 commit e442877

4 files changed

Lines changed: 190 additions & 20 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ Edit `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/
269269
**`NodeFilter` notes:**
270270

271271
- `filter` is a JSON object matching the `NodeFilter` schema. Wire types are `object` or, as a fallback, a JSON-encoded string for clients that flatten objects.
272+
- Unknown filter keys and populated fields that are not applicable to the effective node kind fail loudly with `success=false` and a teaching `message` (no silent key dropping).
272273
- Symbol-only keys: `symbol_kind` (single value) and `symbol_kinds` (set membership) for declaration granularity (`class`, `interface`, `enum`, `record`, `annotation`, `method`, `constructor`).
273274
- `find(kind="symbol", ...)` results include `symbol_kind` so callers can see declaration granularity without a follow-up `describe`.
274275
- For `find`, an empty / whitespace-only filter string or the JSON literal `null` is treated like `{}` (match anything).

mcp_v2.py

Lines changed: 110 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import threading
77
from typing import Annotated, Any, Literal
88

9-
from pydantic import BaseModel, Field, TypeAdapter, ValidationError, validate_call
9+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, validate_call
1010
from sentence_transformers import SentenceTransformer
1111

1212
from index_common import SBERT_MODEL
@@ -57,6 +57,8 @@ def _get_sentence_transformer(model_name: str, device: str | None) -> SentenceTr
5757

5858

5959
class NodeFilter(BaseModel):
60+
model_config = ConfigDict(extra="forbid")
61+
6062
microservice: str | None = None
6163
module: str | None = None
6264
source_layer: str | None = None
@@ -76,6 +78,86 @@ class NodeFilter(BaseModel):
7678
client_method: str | None = None
7779

7880

81+
_NODEFILTER_FIELD_ORDER: tuple[str, ...] = tuple(NodeFilter.model_fields.keys())
82+
83+
_NODEFILTER_APPLICABLE_FIELDS: dict[Literal["symbol", "route", "client"], tuple[str, ...]] = {
84+
"symbol": (
85+
"microservice",
86+
"module",
87+
"role",
88+
"exclude_roles",
89+
"annotation",
90+
"capability",
91+
"fqn_prefix",
92+
"symbol_kind",
93+
"symbol_kinds",
94+
),
95+
"route": (
96+
"microservice",
97+
"module",
98+
"http_method",
99+
"path_prefix",
100+
"framework",
101+
),
102+
"client": (
103+
"microservice",
104+
"module",
105+
"source_layer",
106+
"client_kind",
107+
"target_service",
108+
"target_path_prefix",
109+
"client_method",
110+
),
111+
}
112+
113+
114+
def _ordered_nodefilter_fields(field_names: set[str]) -> list[str]:
115+
return [name for name in _NODEFILTER_FIELD_ORDER if name in field_names]
116+
117+
118+
def _populated_nodefilter_fields(nf: NodeFilter) -> set[str]:
119+
populated: set[str] = set()
120+
for field_name in _NODEFILTER_FIELD_ORDER:
121+
value = getattr(nf, field_name)
122+
if value is None:
123+
continue
124+
if isinstance(value, list) and not value:
125+
continue
126+
populated.add(field_name)
127+
return populated
128+
129+
130+
def _nodefilter_inapplicable_fields(kind: Literal["symbol", "route", "client"], nf: NodeFilter) -> list[str]:
131+
populated = _populated_nodefilter_fields(nf)
132+
applicable = set(_NODEFILTER_APPLICABLE_FIELDS[kind])
133+
return _ordered_nodefilter_fields(populated - applicable)
134+
135+
136+
def _nodefilter_applicability_error(kind: Literal["symbol", "route", "client"], nf: NodeFilter) -> str | None:
137+
inapplicable = _nodefilter_inapplicable_fields(kind, nf)
138+
if not inapplicable:
139+
return None
140+
applicable = ", ".join(_NODEFILTER_APPLICABLE_FIELDS[kind])
141+
bad = ", ".join(inapplicable)
142+
return (
143+
f"Invalid filter for kind='{kind}': populated field(s) not applicable: [{bad}]. "
144+
f"Applicable field(s): [{applicable}]"
145+
)
146+
147+
148+
def _filter_validation_error_message(exc: ValidationError) -> str:
149+
items: list[str] = []
150+
for err in exc.errors():
151+
loc = ".".join(str(part) for part in err.get("loc", ()))
152+
msg = str(err.get("msg") or "invalid value")
153+
if loc:
154+
items.append(f"{loc}: {msg}")
155+
else:
156+
items.append(msg)
157+
details = "; ".join(items) if items else str(exc)
158+
return f"Invalid filter: {details}"
159+
160+
79161
def _coerce_filter(
80162
value: NodeFilter | dict[str, Any] | str | None,
81163
) -> NodeFilter | dict[str, Any] | None:
@@ -432,11 +514,16 @@ def search_v2(
432514
model=model,
433515
)
434516
raw_filter = _coerce_filter(filter)
435-
nf = (
436-
NodeFilter.model_validate(raw_filter)
437-
if raw_filter is not None and not isinstance(raw_filter, NodeFilter)
438-
else raw_filter
439-
)
517+
try:
518+
nf = (
519+
NodeFilter.model_validate(raw_filter)
520+
if raw_filter is not None and not isinstance(raw_filter, NodeFilter)
521+
else raw_filter
522+
)
523+
except ValidationError as exc:
524+
return SearchOutput(success=False, message=_filter_validation_error_message(exc))
525+
if nf and (err := _nodefilter_applicability_error("symbol", nf)):
526+
return SearchOutput(success=False, message=err)
440527
hits: list[SearchHit] = []
441528
for row in rows:
442529
if path_contains and path_contains not in str(row.get("filename") or ""):
@@ -463,7 +550,12 @@ def find_v2(
463550
raw_filter = _coerce_filter(filter)
464551
if raw_filter is None:
465552
raw_filter = {}
466-
nf = NodeFilter.model_validate(raw_filter) if not isinstance(raw_filter, NodeFilter) else raw_filter
553+
try:
554+
nf = NodeFilter.model_validate(raw_filter) if not isinstance(raw_filter, NodeFilter) else raw_filter
555+
except ValidationError as exc:
556+
return FindOutput(success=False, message=_filter_validation_error_message(exc))
557+
if err := _nodefilter_applicability_error(kind, nf):
558+
return FindOutput(success=False, message=err)
467559
if kind == "symbol":
468560
where, params = _symbol_where_from_filter(nf)
469561
params["lim"] = int(limit) + int(offset)
@@ -539,12 +631,15 @@ def neighbors_v2(
539631
label_params = [f"l{i}" for i in range(len(labels))]
540632
label_predicate = "(" + " OR ".join(f"label(e) = ${name}" for name in label_params) + ")"
541633
g = graph or KuzuGraph.get()
542-
raw_filter = _coerce_filter(filter)
543-
nf = (
544-
NodeFilter.model_validate(raw_filter)
545-
if raw_filter is not None and not isinstance(raw_filter, NodeFilter)
546-
else raw_filter
547-
)
634+
try:
635+
raw_filter = _coerce_filter(filter)
636+
nf = (
637+
NodeFilter.model_validate(raw_filter)
638+
if raw_filter is not None and not isinstance(raw_filter, NodeFilter)
639+
else raw_filter
640+
)
641+
except ValidationError as exc:
642+
return NeighborsOutput(success=False, message=_filter_validation_error_message(exc))
548643
origins = [ids] if isinstance(ids, str) else list(ids)
549644
results: list[Edge] = []
550645
for origin_id in origins:
@@ -580,6 +675,8 @@ def neighbors_v2(
580675
other_rec = _load_node_record(g, other_id, other_kind)
581676
if other_rec is None:
582677
continue
678+
if nf and (err := _nodefilter_applicability_error(other_kind, nf)):
679+
return NeighborsOutput(success=False, message=err)
583680
if not _node_matches_filter(other_kind, other_rec, nf):
584681
continue
585682
attrs = {

server.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"Tools: search (NL/code locate), find (structured NodeFilter), describe (one node + edge_summary: stored edge-label counts and optional composed keys for type Symbols and override-axis virtual keys for method Symbols), "
2929
"neighbors (one hop; you MUST pass direction in|out AND edge_types list — no defaults). "
3030
"NodeFilter `filter` is a JSON object (preferred); a JSON-encoded string is also accepted as a fallback. "
31+
"Unknown filter keys and populated fields not applicable to the effective node kind fail with success=false and message. "
3132
"Edge labels: EXTENDS, IMPLEMENTS, INJECTS, DECLARES, DECLARES_CLIENT, CALLS, EXPOSES, HTTP_CALLS, ASYNC_CALLS. "
3233
"Reprocess/init, meta, tables, diagnose-ignore, analyze-pr: use java-codebase-rag CLI — not MCP."
3334
)
@@ -348,7 +349,8 @@ async def search(
348349
filter: dict[str, Any] | str | None = Field(
349350
default=None,
350351
description=(
351-
"Optional NodeFilter (symbol-oriented keys) applied to each hit after search. "
352+
"Optional NodeFilter (symbol applicability). Unknown keys and populated non-symbol fields return success=false "
353+
"with a teaching message. "
352354
"Prefer a JSON object; a JSON-encoded string is accepted as a fallback."
353355
),
354356
),
@@ -376,8 +378,9 @@ async def find(
376378
filter: dict[str, Any] | str = Field(
377379
...,
378380
description=(
379-
"Required NodeFilter (shared schema; irrelevant keys ignored per kind). "
380-
"Symbol filters also support symbol_kind and symbol_kinds. "
381+
"Required NodeFilter (shared schema, strict extras). Unknown keys and populated fields not applicable to "
382+
"the selected kind return success=false with a teaching message. Symbol filters also support symbol_kind "
383+
"and symbol_kinds. "
381384
"Prefer a JSON object; a JSON-encoded string is accepted as a fallback."
382385
),
383386
),
@@ -430,7 +433,8 @@ async def neighbors(
430433
filter: dict[str, Any] | str | None = Field(
431434
default=None,
432435
description=(
433-
"Optional NodeFilter applied to the other endpoint of each edge. "
436+
"Optional NodeFilter applied to the other endpoint of each edge. Unknown keys and populated fields not "
437+
"applicable to an evaluated neighbor kind return success=false with a teaching message. "
434438
"Prefer a JSON object; a JSON-encoded string is accepted as a fallback."
435439
),
436440
),

tests/test_mcp_v2.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,36 @@ def test_find_client_by_path_prefix(kuzu_graph) -> None:
198198
assert bits[-1].startswith(prefix)
199199

200200

201-
def test_find_silent_ignore_irrelevant_filter_keys(kuzu_graph) -> None:
201+
def test_find_cross_kind_filter_fields_return_failure(kuzu_graph) -> None:
202202
out = find_v2("symbol", {"path_prefix": "/api"}, graph=kuzu_graph)
203-
assert out.success is True
204-
assert isinstance(out.results, list)
203+
assert out.success is False
204+
assert out.message is not None
205+
assert "path_prefix" in out.message
206+
assert "kind='symbol'" in out.message
207+
208+
209+
def test_find_unknown_filter_key_returns_failure(kuzu_graph) -> None:
210+
out = find_v2("symbol", {"typo_key": "x"}, graph=kuzu_graph)
211+
assert out.success is False
212+
assert out.message is not None
213+
assert "Invalid filter" in out.message
214+
assert "typo_key" in out.message
215+
216+
217+
def test_find_symbol_only_field_with_kind_client_returns_failure(kuzu_graph) -> None:
218+
out = find_v2("client", {"fqn_prefix": "com.example"}, graph=kuzu_graph)
219+
assert out.success is False
220+
assert out.message is not None
221+
assert "fqn_prefix" in out.message
222+
assert "kind='client'" in out.message
223+
224+
225+
def test_find_client_only_field_with_kind_symbol_returns_failure(kuzu_graph) -> None:
226+
out = find_v2("symbol", {"client_kind": "feign_method"}, graph=kuzu_graph)
227+
assert out.success is False
228+
assert out.message is not None
229+
assert "client_kind" in out.message
230+
assert "kind='symbol'" in out.message
205231

206232

207233
async def test_find_missing_filter_rejected(mcp_server) -> None:
@@ -433,6 +459,24 @@ def test_search_filter_accepts_json_string(monkeypatch, kuzu_graph) -> None:
433459
assert out_dict.results == out_str.results
434460

435461

462+
def test_search_unknown_filter_key_returns_failure(monkeypatch, kuzu_graph) -> None:
463+
monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows())
464+
out = search_v2("ChatService", filter={"typo_key": "x"}, graph=kuzu_graph)
465+
assert out.success is False
466+
assert out.message is not None
467+
assert "Invalid filter" in out.message
468+
assert "typo_key" in out.message
469+
470+
471+
def test_search_cross_kind_filter_returns_failure(monkeypatch, kuzu_graph) -> None:
472+
monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows())
473+
out = search_v2("ChatService", filter={"path_prefix": "/api"}, graph=kuzu_graph)
474+
assert out.success is False
475+
assert out.message is not None
476+
assert "path_prefix" in out.message
477+
assert "kind='symbol'" in out.message
478+
479+
436480
def test_search_filter_empty_string_treated_as_none(monkeypatch, kuzu_graph) -> None:
437481
monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows())
438482
baseline = search_v2("ChatService", graph=kuzu_graph)
@@ -487,6 +531,30 @@ def test_neighbors_filter_accepts_json_string(kuzu_graph) -> None:
487531
assert out_dict.results == out_str.results
488532

489533

534+
def test_neighbors_filter_unknown_key_returns_failure(kuzu_graph) -> None:
535+
mid = _method_id_with_calls(kuzu_graph, "out")
536+
out = neighbors_v2(mid, direction="out", edge_types=["CALLS"], filter={"typo_key": "x"}, graph=kuzu_graph)
537+
assert out.success is False
538+
assert out.message is not None
539+
assert "Invalid filter" in out.message
540+
assert "typo_key" in out.message
541+
542+
543+
def test_neighbors_filter_cross_kind_on_neighbor_returns_failure(kuzu_graph) -> None:
544+
mid = _method_id_with_calls(kuzu_graph, "out")
545+
out = neighbors_v2(mid, direction="out", edge_types=["CALLS"], filter={"path_prefix": "/api"}, graph=kuzu_graph)
546+
assert out.success is False
547+
assert out.message is not None
548+
assert "path_prefix" in out.message
549+
assert "kind='symbol'" in out.message
550+
551+
552+
def test_neighbors_validate_call_still_raises(kuzu_graph) -> None:
553+
mid = _method_id_with_calls(kuzu_graph, "out")
554+
with pytest.raises(ValidationError):
555+
neighbors_v2(mid, direction="upstream", edge_types=["CALLS"], graph=kuzu_graph)
556+
557+
490558
def test_filter_invalid_json_returns_failure(monkeypatch, kuzu_graph) -> None:
491559
monkeypatch.setattr("mcp_v2.run_search", lambda *args, **kwargs: _fake_search_rows())
492560
out = search_v2("ChatService", filter="{not json", graph=kuzu_graph)

0 commit comments

Comments
 (0)