diff --git a/mcp_hints.py b/mcp_hints.py index c1e9c2d8..3ec7d0b1 100644 --- a/mcp_hints.py +++ b/mcp_hints.py @@ -570,24 +570,33 @@ def _trace_structured_hints(payload: dict[str, Any]) -> tuple[list[_StructuredHi # (c) Cross-service boundary hint (no stats dependency). xs_edges = [e for e in edges if e.get("cross_service_boundary")] if xs_edges: - for xe in xs_edges[:3]: - to_id = str(xe.get("to_id") or "") - from_id = str(xe.get("from_id") or "") - confidence = xe.get("attrs", {}).get("confidence") if isinstance(xe.get("attrs"), dict) else None - conf_str = f"confidence={confidence}" if confidence is not None else "low confidence" + # When cross_service=True, BFS already continued through boundaries. + # Emit a lighter informational advisory instead of an action hint. + was_seamless = bool(payload.get("cross_service")) + if was_seamless: advisories.append(( PRIORITY_META, - f"Cross-service boundary: {from_id} -> {to_id} ({conf_str}). " - f"Use trace('{to_id}', 'out', ['EXPOSES','CALLS'], max_depth=4) to continue in the downstream service, " - f"or describe('{to_id}') for route details.", - )) - struct_pairs.append(_StructuredHint( - "trace", - {"ids": [to_id], "direction": "out", "edge_types": ["EXPOSES", "CALLS"], "max_depth": 4}, - True, PRIORITY_LEAF_FOLLOWUP, - LABEL_CROSS_SERVICE_BOUNDARY, - f"cross-service boundary: {from_id} -> {to_id}", + f"trace crossed {len(xs_edges)} service boundary(ies).", )) + else: + for xe in xs_edges[:3]: + to_id = str(xe.get("to_id") or "") + from_id = str(xe.get("from_id") or "") + confidence = xe.get("attrs", {}).get("confidence") if isinstance(xe.get("attrs"), dict) else None + conf_str = f"confidence={confidence}" if confidence is not None else "low confidence" + advisories.append(( + PRIORITY_META, + f"Cross-service boundary: {from_id} -> {to_id} ({conf_str}). " + f"Use trace('{to_id}', 'out', ['EXPOSES','CALLS'], max_depth=4) to continue in the downstream service, " + f"or describe('{to_id}') for route details.", + )) + struct_pairs.append(_StructuredHint( + "trace", + {"ids": [to_id], "direction": "out", "edge_types": ["EXPOSES", "CALLS"], "max_depth": 4}, + True, PRIORITY_LEAF_FOLLOWUP, + LABEL_CROSS_SERVICE_BOUNDARY, + f"cross-service boundary: {from_id} -> {to_id}", + )) return (finalize_structured_hints(struct_pairs), finalize_advisories(advisories)) diff --git a/mcp_trace.py b/mcp_trace.py index 99c3ab99..9f4d68af 100644 --- a/mcp_trace.py +++ b/mcp_trace.py @@ -498,6 +498,7 @@ def trace_v2( fan_out_cap: int = 5, collapse_trivial: bool = True, include_unresolved: bool = False, + cross_service: bool = False, graph: KuzuGraph | None = None, ) -> TraceOutput: """Multi-hop BFS traversal with pruning.""" @@ -601,6 +602,12 @@ def trace_v2( # Determine if cross-service detection is active. cross_service_active = bool(set(edge_types) & _CROSS_SERVICE_EDGE_TYPES) + # Effective scaffolding set: when cross_service=True, EXPOSES is also scaffolding + # so Route -> Handler is followed automatically in downstream services. + effective_scaffolding = _SCAFFOLDING_EDGE_TYPES + if cross_service: + effective_scaffolding = _SCAFFOLDING_EDGE_TYPES | frozenset({"EXPOSES"}) + # BFS state. visited: set[str] = set(seed_ids) frontier: list[str] = list(seed_ids) @@ -639,7 +646,7 @@ def trace_v2( query_edge_types = list(edge_types) # Cross-service: also query scaffolding edges when cross-service is active. if cross_service_active: - for scaffold_et in _SCAFFOLDING_EDGE_TYPES: + for scaffold_et in effective_scaffolding: if scaffold_et not in query_edge_types: query_edge_types.append(scaffold_et) @@ -672,7 +679,7 @@ def trace_v2( for row in src_rows: et = str(row.get("edge_type") or "") - if et in _SCAFFOLDING_EDGE_TYPES: + if et in effective_scaffolding: scaffolding_rows.append(row) else: signal_rows.append(row) @@ -703,7 +710,7 @@ def trace_v2( edge_type = str(row.get("edge_type") or "") # --- Cross-service boundary detection --- - if edge_type in _SCAFFOLDING_EDGE_TYPES and cross_service_active: + if edge_type in effective_scaffolding and cross_service_active: # Follow scaffolding edge to Client/Producer node. # Record the scaffolding edge and include the node. try: @@ -791,9 +798,16 @@ def trace_v2( edges.append(cross_edge) edge_id_map[cross_edge_id] = cross_edge visited.add(cross_target_id) - # Do NOT add downstream node to frontier — boundary-stop. - - # Do NOT add Client/Producer to frontier either. + # Track incoming edge for downstream node. + if cross_target_id not in node_to_incoming_edge_id: + node_to_incoming_edge_id[cross_target_id] = cross_edge_id + # When cross_service=True, add downstream node to frontier + # so BFS continues into the downstream service. + if cross_service: + new_frontier.add(cross_target_id) + + # Do NOT add Client/Producer to frontier — its cross-service + # edges were already queried inline above. continue # --- Standard edge processing --- diff --git a/server.py b/server.py index 98de8e96..d9957ade 100644 --- a/server.py +++ b/server.py @@ -587,8 +587,8 @@ async def resolve( "are exempt. `collapse_trivial` merges wrapper chains (A→B→C where B is trivial). " "Result: `nodes` dict (id → NodeRef), `edges` list with BFS metadata (hop, parent_edge_id, " "collapsed, cross_service_boundary), ranked `paths` (root-to-leaf), and `stats` with pruning counts. " - "Cross-service boundary: BFS records the cross-service edge and includes the downstream Route/Producer " - "in `nodes` but stops the frontier — the agent decides whether to continue." + "Cross-service boundary: by default BFS stops at service boundaries. " + "Set `cross_service=True` to continue traversal through HTTP_CALLS/ASYNC_CALLS boundaries." ), ) async def trace( @@ -628,6 +628,10 @@ async def trace( default=False, description="Include UnresolvedCallSite edges (CALLS out only)", ), + cross_service: bool = Field( + default=False, + description="Continue BFS through service boundaries (HTTP_CALLS/ASYNC_CALLS). Default: stop at boundaries.", + ), ) -> mcp_trace.TraceOutput: return await asyncio.to_thread( mcp_trace.trace_v2, @@ -643,6 +647,7 @@ async def trace( fan_out_cap if fan_out_cap is not None else 5, collapse_trivial, include_unresolved, + cross_service, None, ) diff --git a/skills/explore-codebase/SKILL.md b/skills/explore-codebase/SKILL.md index 638efedd..b0cbcd0e 100644 --- a/skills/explore-codebase/SKILL.md +++ b/skills/explore-codebase/SKILL.md @@ -178,7 +178,7 @@ Prefer **`resolve` -> `describe(id=...)`** over **`describe(fqn=...)`** when an | "What happens when route R is called?" | `find(kind="route")` then `trace(route_id, "out", ["EXPOSES","CALLS"], max_depth=4)` | `describe` on key nodes | | "Impact of changing method M" | `resolve` / `find` then `trace(id, "in", ["CALLS","OVERRIDES"], max_depth=3)` | `describe` on callers | | "Trace from X to database" | `trace(id, "out", ["CALLS"], max_depth=4, prune_roles=["DTO","EXCEPTION"])` | `neighbors` for pruned detail | -| "What calls this across services?" | `trace(id, "out", ["CALLS","HTTP_CALLS","ASYNC_CALLS"], max_depth=5)` | `trace` on downstream route_id if needed | +| "What calls this across services?" | `trace(id, "out", ["CALLS","HTTP_CALLS","ASYNC_CALLS"], max_depth=5, cross_service=True)` | `describe` on downstream routes | **Rules of thumb:** @@ -216,7 +216,7 @@ Returns **edges** with `attrs` (`confidence`, `strategy`, `match`, ... on cross- ### `trace` -Multi-hop BFS with pruning. Args: `ids` (string or list), **`direction`**, **`edge_types`** (stored labels only — no composed dot-keys), `max_depth` (default 3, clamped 1–5), `max_paths` (default 20), `max_nodes_discovered` (default 500, clamped 100–2000), optional `filter` (NodeFilter), optional `edge_filter` (CALLS only), optional `prune_roles` (soft gate — edges recorded, frontier stops), `fan_out_cap` (default 5, scaffolding edges exempt), `collapse_trivial` (default true), `include_unresolved` (default false). +Multi-hop BFS with pruning. Args: `ids` (string or list), **`direction`**, **`edge_types`** (stored labels only — no composed dot-keys), `max_depth` (default 3, clamped 1–5), `max_paths` (default 20), `max_nodes_discovered` (default 500, clamped 100–2000), optional `filter` (NodeFilter), optional `edge_filter` (CALLS only), optional `prune_roles` (soft gate — edges recorded, frontier stops), `fan_out_cap` (default 5, scaffolding edges exempt), `collapse_trivial` (default true), `include_unresolved` (default false), `cross_service` (default false — set true to continue BFS through HTTP_CALLS/ASYNC_CALLS boundaries into downstream services). Returns `TraceOutput`: `success`, `seed_ids`, `direction`, `edge_types`, `actual_depth`, `nodes` (dict of id→NodeRef), `edges` (list of `TraceEdge`), `paths` (list of `TracePath`), `stats` (`TraceStats`), `advisories`. diff --git a/tests/test_mcp_trace.py b/tests/test_mcp_trace.py index ffadd4eb..1f63e6d5 100644 --- a/tests/test_mcp_trace.py +++ b/tests/test_mcp_trace.py @@ -923,9 +923,123 @@ def test_trace_cross_service_boundary_stops(kuzu_graph: KuzuGraph) -> None: assert len(downstream_edges) == 0 -# --------------------------------------------------------------------------- -# PR-TRACE-2 tests: MCP tool registration -# --------------------------------------------------------------------------- +def test_trace_cross_service_seamless_http(kuzu_graph: KuzuGraph) -> None: + """cross_service=True: BFS continues through HTTP_CALLS boundary into downstream service.""" + seed_id = _find_method_with_declares_client(kuzu_graph) + if seed_id is None: + pytest.skip("No method with DECLARES_CLIENT in fixture") + + out = trace_v2( + ids=seed_id, + direction="out", + edge_types=["CALLS", "HTTP_CALLS"], + max_depth=5, + fan_out_cap=0, + cross_service=True, + graph=kuzu_graph, + ) + assert out.success is True + + # Should have cross-service boundary edges. + xs_edges = [e for e in out.edges if e.cross_service_boundary] + if not xs_edges: + pytest.skip("No cross-service edges in result") + + for xe in xs_edges: + assert xe.edge_type in ("HTTP_CALLS", "ASYNC_CALLS") + # Downstream node should be in nodes. + assert xe.to_id in out.nodes + + # Key difference from boundary-stop: downstream Route should have edges FROM it + # (EXPOSES to handler, then CALLS from handler) because BFS continued through. + for xe in xs_edges: + downstream_edges = [e for e in out.edges if e.from_id == xe.to_id] + # At least one edge (EXPOSES to handler) should exist from the downstream Route. + if downstream_edges: + exposes_edges = [e for e in downstream_edges if e.edge_type == "EXPOSES"] + assert len(exposes_edges) >= 1, ( + f"Expected EXPOSES edges from {xe.to_id}, got: {[e.edge_type for e in downstream_edges]}" + ) + + +def test_trace_cross_service_seamless_async(kuzu_graph: KuzuGraph) -> None: + """cross_service=True: BFS continues through ASYNC_CALLS boundary into downstream service.""" + seed_id = _find_method_with_declares_producer(kuzu_graph) + if seed_id is None: + pytest.skip("No method with DECLARES_PRODUCER in fixture") + + out = trace_v2( + ids=seed_id, + direction="out", + edge_types=["CALLS", "ASYNC_CALLS"], + max_depth=5, + fan_out_cap=0, + cross_service=True, + graph=kuzu_graph, + ) + assert out.success is True + + xs_edges = [e for e in out.edges if e.cross_service_boundary] + if not xs_edges: + pytest.skip("No cross-service edges in result") + + for xe in xs_edges: + assert xe.edge_type in ("HTTP_CALLS", "ASYNC_CALLS") + assert xe.to_id in out.nodes + + +def test_trace_cross_service_seamless_respects_budget(kuzu_graph: KuzuGraph) -> None: + """cross_service=True still respects max_nodes_discovered budget.""" + seed_id = _find_method_with_declares_client(kuzu_graph) + if seed_id is None: + pytest.skip("No method with DECLARES_CLIENT in fixture") + + out = trace_v2( + ids=seed_id, + direction="out", + edge_types=["CALLS", "HTTP_CALLS"], + max_depth=5, + max_nodes_discovered=100, + fan_out_cap=0, + cross_service=True, + graph=kuzu_graph, + ) + assert out.success is True + # Budget may or may not have been hit depending on graph size, + # but if it was, the stats should reflect it. + if out.stats.budget_hit: + assert out.stats.total_nodes_discovered >= 100 + + +def test_trace_cross_service_seamless_exposes_as_scaffolding(kuzu_graph: KuzuGraph) -> None: + """EXPOSES edges from downstream Routes are exempt from fan_out_cap when cross_service=True.""" + seed_id = _find_method_with_declares_client(kuzu_graph) + if seed_id is None: + pytest.skip("No method with DECLARES_CLIENT in fixture") + + # Use fan_out_cap=1 — very tight, but EXPOSES should still come through. + out = trace_v2( + ids=seed_id, + direction="out", + edge_types=["CALLS", "HTTP_CALLS"], + max_depth=5, + fan_out_cap=1, + cross_service=True, + graph=kuzu_graph, + ) + assert out.success is True + + xs_edges = [e for e in out.edges if e.cross_service_boundary] + if xs_edges: + # Even with fan_out_cap=1, EXPOSES edges from downstream Routes should appear + # (they're scaffolding, exempt from cap). + for xe in xs_edges: + downstream_edges = [e for e in out.edges if e.from_id == xe.to_id] + if downstream_edges: + exposes_edges = [e for e in downstream_edges if e.edge_type == "EXPOSES"] + assert len(exposes_edges) >= 1, ( + f"EXPOSES should be exempt from fan_out_cap, but got: {[e.edge_type for e in downstream_edges]}" + ) async def test_trace_registered_as_mcp_tool(mcp_server) -> None: