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
8 changes: 7 additions & 1 deletion docs/AGENT-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,13 @@ One object shape everywhere. **For `find`, `filter` is required** — use at lea
| `microservice`, `module`, `source_layer` | All kinds (`source_layer` mainly **client**: `builtin` / brownfield) |
| `role`, `exclude_roles`, `annotation`, `capability`, `fqn_prefix`, `symbol_kind`, `symbol_kinds` | **symbol** (ignored for route/client) |
| `http_method`, `path_prefix`, `framework` | **route** |
| `client_kind`, `target_service`, `target_path_prefix`, `client_method` | **client** |
| `client_kind`, `target_service`, `target_path_prefix`, `http_method` | **client** |

The same `http_method` key filters HTTP verbs on **routes** (server-side declared method) and on **clients** (caller-side method on the outbound call). It is not applicable to **symbol** rows.

**`source_layer` vs `role`:** On **Client** nodes, `source_layer` records which brownfield or built-in layer produced the client declaration (`builtin`, `layer_a_meta`, `layer_b_ann`, `layer_c_source`, `layer_b_fqn`, …). On **Symbol** nodes, `role` is the inferred architectural stereotype (`CONTROLLER`, `SERVICE`, `REPO`, …). They answer different questions; names stay distinct.

**`target_service` vs `microservice`:** `microservice` is the service **where the node lives** (home service / owning module). `target_service` (clients only) is the **remote service being called**. A client in `operator-api` may list `partner-api` as `target_service`.

Exact allowed values for roles, capabilities, client kinds, etc. live in `java_ontology.py`.

Expand Down
7 changes: 3 additions & 4 deletions mcp_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ class NodeFilter(BaseModel):
client_kind: str | None = None
target_service: str | None = None
target_path_prefix: str | None = None
client_method: str | None = None


_NODEFILTER_FIELD_ORDER: tuple[str, ...] = tuple(NodeFilter.model_fields.keys())
Expand Down Expand Up @@ -106,7 +105,7 @@ class NodeFilter(BaseModel):
"client_kind",
"target_service",
"target_path_prefix",
"client_method",
"http_method",
),
}

Expand Down Expand Up @@ -475,7 +474,7 @@ def _node_matches_filter(kind: Literal["symbol", "route", "client"], row: dict[s
path = str(row.get("path") or "")
if not path.startswith(f.target_path_prefix):
return False
if f.client_method and str(row.get("method") or "") != f.client_method:
if f.http_method and str(row.get("method") or "") != f.http_method:
return False
return True

Expand Down Expand Up @@ -581,7 +580,7 @@ def find_v2(
client_kind=nf.client_kind,
target_service=nf.target_service,
path_prefix=nf.target_path_prefix,
method=nf.client_method,
method=nf.http_method,
limit=max(500, limit + offset),
)
rows = [r for r in rows if _node_matches_filter("client", r, nf)]
Expand Down
37 changes: 37 additions & 0 deletions tests/test_mcp_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,43 @@ def test_nodefilter_applicability_table_covers_all_fields() -> None:
assert declared == covered


def test_http_method_field_applies_to_route_kind(kuzu_graph) -> None:
routes = kuzu_graph.list_routes(limit=2000)
post_ids = {str(r["id"]) for r in routes if str(r.get("method") or "").upper() == "POST"}
if not post_ids:
pytest.skip("fixture has no POST routes")
out = find_v2("route", {"http_method": "POST"}, graph=kuzu_graph, limit=500)
assert out.success is True
assert out.results
assert {r.id for r in out.results} <= post_ids
assert all(r.fqn == "POST" or r.fqn.startswith("POST ") for r in out.results)


def test_http_method_field_applies_to_client_kind(kuzu_graph) -> None:
clients = kuzu_graph.list_clients(limit=2000)
post_ids = {str(r["id"]) for r in clients if str(r.get("method") or "").upper() == "POST"}
if not post_ids:
pytest.skip("fixture has no POST clients")
out = find_v2("client", {"http_method": "POST"}, graph=kuzu_graph, limit=500)
assert out.success is True
assert out.results
assert {r.id for r in out.results} <= post_ids


def test_http_method_field_inapplicable_to_symbol(kuzu_graph) -> None:
out = find_v2("symbol", {"http_method": "POST"}, graph=kuzu_graph)
assert out.success is False
assert out.message is not None
assert "http_method" in out.message
assert "kind='symbol'" in out.message


def test_nodefilter_rejects_old_client_method_field() -> None:
with pytest.raises(ValidationError) as excinfo:
NodeFilter.model_validate({"client_method": "POST"})
assert any("client_method" in str(e.get("loc", ())) for e in excinfo.value.errors())


async def test_find_missing_filter_rejected(mcp_server) -> None:
with pytest.raises(ToolError, match="Field required"):
await mcp_server.call_tool("find", {"kind": "symbol"})
Expand Down
Loading