Skip to content

Commit 492b268

Browse files
HumanBean17claude
andcommitted
register trace as sixth MCP tool (PR-TRACE-2) (#247)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f504687 commit 492b268

4 files changed

Lines changed: 109 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ py-modules = [
6060
"java_ontology",
6161
"kuzu_queries",
6262
"mcp_hints",
63+
"mcp_trace",
6364
"mcp_v2",
6465
"path_filtering",
6566
"pr_analysis",

server.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from pathlib import Path
1010
from typing import Any, Literal
1111

12+
import mcp_trace
1213
import mcp_v2
1314
from index_common import SBERT_MODEL
1415
from java_codebase_rag.cli_progress import (
@@ -27,6 +28,7 @@
2728
"Java codebase graph navigator (LanceDB + Kuzu). "
2829
"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), "
2930
"neighbors (one hop; you MUST pass direction in|out AND edge_types list — no defaults), "
31+
"trace (multi-hop BFS with server-side pruning; direction + edge_types required; use for path/impact/cross-service questions where neighbors loops exceed 2 hops), "
3032
"resolve (identifier-shaped lookup for symbol/route/client/producer — three statuses one|many|none). "
3133
"NodeFilter `filter` is a JSON object (preferred); a JSON-encoded string is also accepted as a fallback. "
3234
"Unknown filter keys and populated fields not applicable to the effective node kind fail with success=false and message. "
@@ -567,6 +569,83 @@ async def resolve(
567569
) -> mcp_v2.ResolveOutput:
568570
return await asyncio.to_thread(mcp_v2.resolve_v2, identifier, hint_kind, None)
569571

572+
@mcp.tool(
573+
name="trace",
574+
description=(
575+
"Multi-hop BFS traversal with server-side pruning. Returns pruned path structure in a single call. "
576+
"Use `trace` instead of multiple `neighbors` calls when: (a) the question implies a path or chain "
577+
"(e.g. 'trace from controller to database', 'what happens when POST /api/orders is called'), "
578+
"(b) you need impact analysis ('who depends on X'), (c) you need to cross service boundaries "
579+
"(HTTP_CALLS / ASYNC_CALLS), or (d) a `neighbors` loop has exceeded 2 hops without converging. "
580+
"For simple one-hop adjacency ('what does M call?', 'who calls M?'), use `neighbors` instead — "
581+
"it returns full unfiltered results. After `trace` returns a pruned result, use `neighbors` on "
582+
"specific nodes to drill into edges that `trace` collapsed or pruned. "
583+
"`direction` and `edge_types` are required. Stored edge labels only — no composed dot-keys. "
584+
"`prune_roles` is a soft gate: edges to pruned-role nodes are recorded but BFS stops traversing "
585+
"through them (agent sees the connection but traversal focuses on higher-signal paths). "
586+
"`fan_out_cap` limits per-node edge expansion; scaffolding edges (DECLARES_CLIENT, DECLARES_PRODUCER) "
587+
"are exempt. `collapse_trivial` merges wrapper chains (A→B→C where B is trivial). "
588+
"Result: `nodes` dict (id → NodeRef), `edges` list with BFS metadata (hop, parent_edge_id, "
589+
"collapsed, cross_service_boundary), ranked `paths` (root-to-leaf), and `stats` with pruning counts. "
590+
"Cross-service boundary: BFS records the cross-service edge and includes the downstream Route/Producer "
591+
"in `nodes` but stops the frontier — the agent decides whether to continue."
592+
),
593+
)
594+
async def trace(
595+
ids: str | list[str] = Field(
596+
description="Seed node IDs (single string or list). Differs from neighbors (single ID) — trace supports multi-seed for impact analysis.",
597+
),
598+
direction: Literal["in", "out"] = Field(
599+
description="Traversal direction: in (callers/dependents) or out (callees/dependencies). Required — no default.",
600+
),
601+
edge_types: list[str] = Field(
602+
description="Edge types to traverse (stored labels only: CALLS, IMPLEMENTS, OVERRIDES, EXPOSES, HTTP_CALLS, ASYNC_CALLS, etc.). Required non-empty. No composed dot-keys.",
603+
),
604+
max_depth: int = Field(default=3, description="Max BFS hops (1-5, default 3)"),
605+
max_paths: int = Field(default=20, description="Max root-to-leaf paths to return"),
606+
max_nodes_discovered: int = Field(
607+
default=500, description="Node discovery budget before pruning (100-2000)",
608+
),
609+
filter: dict[str, Any] | str | None = Field(
610+
default=None,
611+
description="NodeFilter as JSON object or string. Hard gate — nodes failing filter are excluded entirely.",
612+
),
613+
edge_filter: dict[str, Any] | str | None = Field(
614+
default=None,
615+
description="EdgeFilter for CALLS edges (min_confidence, strategies, etc.). Same contract as neighbors edge_filter.",
616+
),
617+
prune_roles: list[str] | None = Field(
618+
default=None,
619+
description="Roles to prune (edges recorded, frontier stops through these roles). Soft gate — differs from NodeFilter exclude_roles (hard gate).",
620+
),
621+
fan_out_cap: int | None = Field(
622+
default=5, description="Per-node edge cap (scaffolding edges exempt). Set to 0 to disable.",
623+
),
624+
collapse_trivial: bool = Field(
625+
default=True, description="Collapse wrapper chains (A→B→C where B is trivial intermediate)",
626+
),
627+
include_unresolved: bool = Field(
628+
default=False,
629+
description="Include UnresolvedCallSite edges (CALLS out only)",
630+
),
631+
) -> mcp_trace.TraceOutput:
632+
return await asyncio.to_thread(
633+
mcp_trace.trace_v2,
634+
ids,
635+
direction,
636+
edge_types,
637+
max_depth,
638+
max_paths,
639+
max_nodes_discovered,
640+
filter,
641+
edge_filter,
642+
prune_roles,
643+
fan_out_cap if fan_out_cap is not None else 5,
644+
collapse_trivial,
645+
include_unresolved,
646+
None,
647+
)
648+
570649
return mcp
571650

572651

tests/test_mcp_tools.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ async def test_registered_tool_surface_is_v2_navigation_only(mcp_server) -> None
2525
"describe",
2626
"neighbors",
2727
"resolve",
28+
"trace",
2829
}
2930
assert names == expected
30-
assert len(names) == 5
31+
assert len(names) == 6
3132

3233

3334
async def test_all_tools_have_non_empty_description(mcp_server) -> None:

tests/test_mcp_trace.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,3 +921,30 @@ def test_trace_cross_service_boundary_stops(kuzu_graph: KuzuGraph) -> None:
921921
# No edges FROM the downstream node (frontier stops at boundary).
922922
downstream_edges = [e for e in out.edges if e.from_id == xe.to_id]
923923
assert len(downstream_edges) == 0
924+
925+
926+
# ---------------------------------------------------------------------------
927+
# PR-TRACE-2 tests: MCP tool registration
928+
# ---------------------------------------------------------------------------
929+
930+
931+
async def test_trace_registered_as_mcp_tool(mcp_server) -> None:
932+
"""create_mcp_server() tool list includes 'trace'."""
933+
tools = await mcp_server.list_tools()
934+
names = {tool.name for tool in tools}
935+
assert "trace" in names
936+
937+
938+
async def test_trace_tool_description_mentions_six_tools(mcp_server) -> None:
939+
"""_INSTRUCTIONS contains 'trace' and lists six tools."""
940+
import server
941+
942+
instructions = server._INSTRUCTIONS
943+
assert "trace" in instructions
944+
assert instructions.count("trace") >= 1
945+
# Six tools mentioned: search, find, describe, neighbors, trace, resolve.
946+
assert "search" in instructions
947+
assert "find" in instructions
948+
assert "describe" in instructions
949+
assert "neighbors" in instructions
950+
assert "resolve" in instructions

0 commit comments

Comments
 (0)