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
96 changes: 80 additions & 16 deletions mcp_v2.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Expand All @@ -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 ""):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -590,18 +625,45 @@ 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; 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:
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))
Expand Down Expand Up @@ -639,6 +701,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:
Expand Down
15 changes: 11 additions & 4 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
184 changes: 183 additions & 1 deletion tests/test_mcp_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -606,3 +606,185 @@ 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_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)
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_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
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
Loading