From 1ee49069dcea8076a3dd384d077bb5dfb12a80dc Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Thu, 14 May 2026 23:44:51 +0300 Subject: [PATCH 1/2] lock mcp filter frame edge decisions for PR-FRAME-2 Co-authored-by: Cursor --- mcp_v2.py | 93 +++++++++++++++++++++++++++++++------- server.py | 15 +++++-- tests/test_mcp_v2.py | 104 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 191 insertions(+), 21 deletions(-) diff --git a/mcp_v2.py b/mcp_v2.py index 6ac32fe..b6520eb 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -1,3 +1,20 @@ +"""MCP V2 graph query surface (``search`` / ``find`` / ``describe`` / ``neighbors``). + +Strict frame contract +--------------------- +NodeFilter is a typed predicate bag: each populated field maps to one stored graph +attribute for the selected kind; inapplicable fields fail loud with a teaching message. +The ``search`` tool's ``query`` parameter is the ranked-text carve-out; structured +prefix fields (``fqn_prefix``, ``path_prefix``, ``target_path_prefix``) reject ``*`` +and ``?`` — see ``_validate_no_wildcards``. + +Revisit trigger (``propose/MCP-FILTER-FRAME-PROPOSE.md`` section 3.4.6) +-------------------------------------------------------------- +If **three** legitimate issue-tracker workflows appear within **six months** of frame +lock where the strict frame has no clean analog under ``search``, deferred +``resolve``, or documented multi-call patterns, reopen the frame for revision. +""" + from __future__ import annotations import json @@ -144,6 +161,20 @@ def _nodefilter_applicability_error(kind: Literal["symbol", "route", "client"], ) +def _validate_no_wildcards(nf: NodeFilter) -> str | None: + """Reject ``*`` / ``?`` in prefix-match fields; wildcards belong in ``search(query=…)``.""" + for field_name in ("fqn_prefix", "path_prefix", "target_path_prefix"): + val = getattr(nf, field_name) + if val is None: + continue + if "*" in val or "?" in val: + return ( + f"Wildcards (* and ?) are not supported in structured filter field `{field_name}`; " + "use search(query=...) for ranked text match instead." + ) + return None + + def _filter_validation_error_message(exc: ValidationError) -> str: items: list[str] = [] for err in exc.errors(): @@ -490,6 +521,19 @@ def search_v2( graph: KuzuGraph | None = None, ) -> SearchOutput: try: + raw_filter = _coerce_filter(filter) + try: + nf = ( + NodeFilter.model_validate(raw_filter) + if raw_filter is not None and not isinstance(raw_filter, NodeFilter) + else raw_filter + ) + except ValidationError as exc: + return SearchOutput(success=False, message=_filter_validation_error_message(exc)) + if nf and (err := _nodefilter_applicability_error("symbol", nf)): + return SearchOutput(success=False, message=err) + if nf and (err := _validate_no_wildcards(nf)): + return SearchOutput(success=False, message=err) model_name = resolved_sbert_model_for_process_env(SBERT_MODEL) device = os.environ.get("SBERT_DEVICE") or None model = _get_sentence_transformer(model_name, device) @@ -512,17 +556,6 @@ def search_v2( device=device, model=model, ) - raw_filter = _coerce_filter(filter) - try: - nf = ( - NodeFilter.model_validate(raw_filter) - if raw_filter is not None and not isinstance(raw_filter, NodeFilter) - else raw_filter - ) - except ValidationError as exc: - return SearchOutput(success=False, message=_filter_validation_error_message(exc)) - if nf and (err := _nodefilter_applicability_error("symbol", nf)): - return SearchOutput(success=False, message=err) hits: list[SearchHit] = [] for row in rows: if path_contains and path_contains not in str(row.get("filename") or ""): @@ -555,6 +588,8 @@ def find_v2( return FindOutput(success=False, message=_filter_validation_error_message(exc)) if err := _nodefilter_applicability_error(kind, nf): return FindOutput(success=False, message=err) + if err := _validate_no_wildcards(nf): + return FindOutput(success=False, message=err) if kind == "symbol": where, params = _symbol_where_from_filter(nf) params["lim"] = int(limit) + int(offset) @@ -590,18 +625,42 @@ def find_v2( return FindOutput(success=False, message=str(exc)) -def describe_v2(id: str, graph: KuzuGraph | None = None) -> DescribeOutput: +def describe_v2( + id: str | None = None, + fqn: str | None = None, + graph: KuzuGraph | None = None, +) -> DescribeOutput: try: g = graph or KuzuGraph.get() - kind = _resolve_node_kind(g, id) - row = _load_node_record(g, id, kind) + has_id = bool(id and str(id).strip()) + has_fqn = bool(fqn and str(fqn).strip()) + if not has_id and not has_fqn: + return DescribeOutput(success=False, message="id or fqn required") + hint_message: str | None = None + node_id: str + if has_id: + node_id = str(id).strip() + else: + fqn_val = str(fqn).strip() + rows = g._rows( # noqa: SLF001 + "MATCH (s:Symbol) WHERE s.fqn = $fqn RETURN s.id AS id LIMIT 2", + {"fqn": fqn_val}, + ) + if not rows: + return DescribeOutput(success=False, message=f"No Symbol found for fqn='{fqn_val}'") + node_id = str(rows[0]["id"] or "") + if len(rows) > 1: + hint_message = "multiple symbols share this FQN; pass microservice to disambiguate" + kind = _resolve_node_kind(g, node_id) + row = _load_node_record(g, node_id, kind) if row is None: - return DescribeOutput(success=False, message=f"No node found for `{id}`") + return DescribeOutput(success=False, message=f"No node found for `{node_id}`") ref = _node_ref_from_row(kind, row) - edge_summary = _edge_summary_for_node(g, id, kind=kind, row=row) + edge_summary = _edge_summary_for_node(g, node_id, kind=kind, row=row) return DescribeOutput( success=True, record=NodeRecord(id=ref.id, kind=kind, fqn=ref.fqn, data=row, edge_summary=edge_summary), + message=hint_message, ) except ValueError as exc: return DescribeOutput(success=False, message=str(exc)) @@ -639,6 +698,8 @@ def neighbors_v2( ) except ValidationError as exc: return NeighborsOutput(success=False, message=_filter_validation_error_message(exc)) + if nf and (err := _validate_no_wildcards(nf)): + return NeighborsOutput(success=False, message=err) origins = [ids] if isinstance(ids, str) else list(ids) results: list[Edge] = [] for origin_id in origins: diff --git a/server.py b/server.py index 5e00ad0..3855dbd 100644 --- a/server.py +++ b/server.py @@ -396,18 +396,25 @@ async def find( "type Symbols may add composed keys DECLARES.DECLARES_CLIENT, DECLARES.EXPOSES " "(describe-time 2-hop member summaries; not valid in neighbors edge_types); " "method Symbols may add override-axis virtual keys OVERRIDDEN_BY, " - "OVERRIDDEN_BY.DECLARES_CLIENT, OVERRIDDEN_BY.EXPOSES, OVERRIDES (same restriction)" + "OVERRIDDEN_BY.DECLARES_CLIENT, OVERRIDDEN_BY.EXPOSES, OVERRIDES (same restriction). " + "Pass id for any node kind, or fqn as an alternative identifier for Symbol nodes only." ), ) async def describe( - id: str = Field( + id: str | None = Field( + default=None, description=( "Graph node id: sym:, route:, or client: prefix " - '(e.g. sym:com.bank.chat.core.api.ChatController#joinOperator(JoinOperatorRequest))' + '(e.g. sym:com.bank.chat.core.api.ChatController#joinOperator(JoinOperatorRequest)). ' + "When set, takes precedence over fqn." ), ), + fqn: str | None = Field( + default=None, + description="Exact FQN for Symbol lookup (alternative to id; Symbol kind only)", + ), ) -> mcp_v2.DescribeOutput: - return await asyncio.to_thread(mcp_v2.describe_v2, id, None) + return await asyncio.to_thread(mcp_v2.describe_v2, id, fqn, None) @mcp.tool(name="neighbors", description="one-hop walk; REQUIRED direction + edge_types") async def neighbors( diff --git a/tests/test_mcp_v2.py b/tests/test_mcp_v2.py index 797daf2..56b3852 100644 --- a/tests/test_mcp_v2.py +++ b/tests/test_mcp_v2.py @@ -116,7 +116,7 @@ def test_find_symbol_by_role(kuzu_graph) -> None: assert all(r.role == "CONTROLLER" for r in out.results if r.role is not None) -def test_find_symbol_empty_filter_handles_non_declaration_symbol_kinds(kuzu_graph) -> None: +def test_find_symbol_empty_filter_returns_results(kuzu_graph) -> None: out = find_v2("symbol", {}, graph=kuzu_graph) assert out.success is True assert out.results @@ -606,3 +606,105 @@ def test_filter_invalid_json_returns_failure(monkeypatch, kuzu_graph) -> None: assert out.success is False assert out.message is not None assert "JSON" in out.message + + +def test_wildcard_in_fqn_prefix_rejected(kuzu_graph) -> None: + out = find_v2("symbol", {"fqn_prefix": "com.foo.*"}, graph=kuzu_graph) + assert out.success is False + assert out.message + assert "fqn_prefix" in out.message + assert "search(query=..." in out.message + + +def test_wildcard_in_path_prefix_rejected(kuzu_graph) -> None: + out = find_v2("route", {"path_prefix": "/api/*"}, graph=kuzu_graph) + assert out.success is False + assert out.message + assert "path_prefix" in out.message + assert "search(query=..." in out.message + + +def test_wildcard_in_target_path_prefix_rejected(kuzu_graph) -> None: + out = find_v2("client", {"target_path_prefix": "/api/*"}, graph=kuzu_graph) + assert out.success is False + assert out.message + assert "target_path_prefix" in out.message + assert "search(query=..." in out.message + + +def test_wildcard_question_mark_in_fqn_prefix_rejected(kuzu_graph) -> None: + out = find_v2("symbol", {"fqn_prefix": "com.foo.?"}, graph=kuzu_graph) + assert out.success is False + assert out.message + assert "fqn_prefix" in out.message + + +def test_describe_by_fqn_returns_symbol(kuzu_graph) -> None: + symbol = kuzu_graph.list_by_role("SERVICE", limit=1)[0] + out = describe_v2(fqn=symbol.fqn, graph=kuzu_graph) + assert out.success is True + assert out.record is not None + assert out.record.id == symbol.id + assert out.record.kind == "symbol" + assert out.message is None + + +def test_describe_by_fqn_unknown_returns_error(kuzu_graph) -> None: + out = describe_v2(fqn="com.nonexistent.Foo", graph=kuzu_graph) + assert out.success is False + assert out.message == "No Symbol found for fqn='com.nonexistent.Foo'" + + +def test_describe_by_fqn_id_takes_precedence(kuzu_graph) -> None: + svc = kuzu_graph.list_by_role("SERVICE", limit=1)[0] + ctrl = kuzu_graph.list_by_role("CONTROLLER", limit=1)[0] + out = describe_v2(id=svc.id, fqn=ctrl.fqn, graph=kuzu_graph) + assert out.success is True + assert out.record is not None + assert out.record.id == svc.id + assert str(out.record.data.get("role") or "") == "SERVICE" + + +def test_describe_by_fqn_requires_id_or_fqn(kuzu_graph) -> None: + out = describe_v2(graph=kuzu_graph) + assert out.success is False + assert out.message == "id or fqn required" + + +def test_multi_value_symbol_kinds_or_semantics(kuzu_graph) -> None: + out = find_v2("symbol", {"symbol_kinds": ["class", "interface"]}, graph=kuzu_graph, limit=200) + assert out.success is True + assert out.results + assert all(r.symbol_kind in {"class", "interface"} for r in out.results) + + +def test_cross_field_and_semantics(kuzu_graph) -> None: + controllers = find_v2("symbol", {"role": "CONTROLLER"}, graph=kuzu_graph, limit=50) + assert controllers.success is True + assert controllers.results + ms = next((r.microservice for r in controllers.results if r.microservice), None) + if not ms: + pytest.skip("no controller with microservice in fixture") + out = find_v2( + "symbol", + {"microservice": ms, "role": "CONTROLLER"}, + graph=kuzu_graph, + limit=200, + ) + assert out.success is True + assert out.results + assert all((r.microservice or "") == ms for r in out.results) + assert all((r.role or "") == "CONTROLLER" for r in out.results) + + +def test_exclude_roles_negation_predicate(kuzu_graph) -> None: + out = find_v2("symbol", {"exclude_roles": ["CONTROLLER"]}, graph=kuzu_graph, limit=500) + assert out.success is True + assert out.results + assert not any(r.role == "CONTROLLER" for r in out.results) + + +def test_empty_filter_returns_full_result_set(kuzu_graph) -> None: + out = find_v2("client", {}, graph=kuzu_graph) + assert out.success is True + assert out.results From c69d205f3cd9fa3dd0c71f0510ea07df2b5cb02f Mon Sep 17 00:00:00 2001 From: Dmitry Teryaev Date: Thu, 14 May 2026 23:51:54 +0300 Subject: [PATCH 2/2] clarify describe FQN collision hint and cover wildcard branches Co-authored-by: Cursor --- mcp_v2.py | 5 ++- tests/test_mcp_v2.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/mcp_v2.py b/mcp_v2.py index b6520eb..e4d00ca 100644 --- a/mcp_v2.py +++ b/mcp_v2.py @@ -650,7 +650,10 @@ def describe_v2( return DescribeOutput(success=False, message=f"No Symbol found for fqn='{fqn_val}'") node_id = str(rows[0]["id"] or "") if len(rows) > 1: - hint_message = "multiple symbols share this FQN; pass microservice to disambiguate" + hint_message = ( + "multiple symbols share this FQN; narrow with find(kind='symbol', filter including " + "microservice and fqn_prefix), then describe(id=...), or use search(query=...) for ranked candidates" + ) kind = _resolve_node_kind(g, node_id) row = _load_node_record(g, node_id, kind) if row is None: diff --git a/tests/test_mcp_v2.py b/tests/test_mcp_v2.py index 56b3852..9e9bc93 100644 --- a/tests/test_mcp_v2.py +++ b/tests/test_mcp_v2.py @@ -639,6 +639,38 @@ def test_wildcard_question_mark_in_fqn_prefix_rejected(kuzu_graph) -> None: assert "fqn_prefix" in out.message +def test_search_wildcard_in_fqn_prefix_rejected_without_run_search(monkeypatch, kuzu_graph) -> None: + calls: list[int] = [] + + def boom(*_a, **_k): + calls.append(1) + return _fake_search_rows() + + monkeypatch.setattr("mcp_v2.run_search", boom) + out = search_v2("anything", filter={"fqn_prefix": "com.*"}, graph=kuzu_graph) + assert out.success is False + assert out.message + assert "fqn_prefix" in out.message + assert calls == [] + + +def test_neighbors_wildcard_in_filter_rejected_before_graph_query(kuzu_graph) -> None: + class ExplodeGraph: + def _rows(self, *_a, **_k) -> list: + raise AssertionError("graph must not be queried when wildcard rejects filter") + + out = neighbors_v2( + "sym:unused", + direction="out", + edge_types=["CALLS"], + filter={"fqn_prefix": "com.*"}, + graph=ExplodeGraph(), # type: ignore[arg-type] + ) + assert out.success is False + assert out.message + assert "fqn_prefix" in out.message + + def test_describe_by_fqn_returns_symbol(kuzu_graph) -> None: symbol = kuzu_graph.list_by_role("SERVICE", limit=1)[0] out = describe_v2(fqn=symbol.fqn, graph=kuzu_graph) @@ -665,6 +697,54 @@ def test_describe_by_fqn_id_takes_precedence(kuzu_graph) -> None: assert str(out.record.data.get("role") or "") == "SERVICE" +def test_describe_by_fqn_duplicate_returns_first_with_disambiguation_hint() -> None: + class DupFqnGraph: + def _rows(self, query: str, params: dict | None = None) -> list: + p = params or {} + if "WHERE s.fqn = $fqn" in query: + if p.get("fqn") == "com.fixture.DupeName": + return [{"id": "sym:dupe-a"}, {"id": "sym:dupe-b"}] + if "MATCH (n:Symbol)" in query and "WHERE n.id = $id" in query: + if p.get("id") == "sym:dupe-a": + return [ + { + "id": "sym:dupe-a", + "kind": "file", + "name": "DupeName", + "fqn": "com.fixture.DupeName", + "package": "com.fixture", + "module": "fixture", + "microservice": "svc-a", + "filename": "DupeName.java", + "start_line": 1, + "end_line": 1, + "start_byte": 0, + "end_byte": 0, + "modifiers": [], + "annotations": [], + "capabilities": [], + "role": "", + "signature": "", + "parent_id": "", + "resolved": True, + } + ] + return [] + + def edge_counts_for(self, node_id: str) -> dict[str, dict[str, int]]: + return {} + + out = describe_v2(fqn="com.fixture.DupeName", graph=DupFqnGraph()) # type: ignore[arg-type] + assert out.success is True + assert out.record is not None + assert out.record.id == "sym:dupe-a" + assert out.message + assert "multiple symbols share this FQN" in out.message + assert "find(kind='symbol'" in out.message + assert "describe(id=..." in out.message + assert "search(query=..." in out.message + + def test_describe_by_fqn_requires_id_or_fqn(kuzu_graph) -> None: out = describe_v2(graph=kuzu_graph) assert out.success is False