|
9 | 9 | from pathlib import Path |
10 | 10 | from typing import Any, Literal |
11 | 11 |
|
| 12 | +import mcp_trace |
12 | 13 | import mcp_v2 |
13 | 14 | from index_common import SBERT_MODEL |
14 | 15 | from java_codebase_rag.cli_progress import ( |
|
27 | 28 | "Java codebase graph navigator (LanceDB + Kuzu). " |
28 | 29 | "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), " |
29 | 30 | "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), " |
30 | 32 | "resolve (identifier-shaped lookup for symbol/route/client/producer — three statuses one|many|none). " |
31 | 33 | "NodeFilter `filter` is a JSON object (preferred); a JSON-encoded string is also accepted as a fallback. " |
32 | 34 | "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( |
567 | 569 | ) -> mcp_v2.ResolveOutput: |
568 | 570 | return await asyncio.to_thread(mcp_v2.resolve_v2, identifier, hint_kind, None) |
569 | 571 |
|
| 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 | + |
570 | 649 | return mcp |
571 | 650 |
|
572 | 651 |
|
|
0 commit comments