Skip to main content

lean_ctx/tools/registered/
ctx_graph.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str, get_usize};
6use crate::tool_defs::tool_def;
7
8pub struct CtxGraphTool;
9
10impl McpTool for CtxGraphTool {
11    fn name(&self) -> &'static str {
12        "ctx_graph"
13    }
14
15    fn tool_def(&self) -> Tool {
16        tool_def(
17            "ctx_graph",
18            "Graph queries — find dependencies, relationships, and symbols.\n\
19             action=symbol path=\"file.rs::fnName\" returns the source (NOT usages).\n\
20             action=neighbors path=\"file.rs\" shows import neighbors with direction & confidence.\n\
21             action=impact path=\"file.rs\" shows reverse dependency tree (blast radius).\n\
22             action=path from→to shows shortest dependency chain between two files.\n\
23             action=diff since=HEAD~1 for git change impact.\n\
24             action=diagram kind=deps|calls renders a Mermaid diagram.\n\
25             For understanding code, use ctx_compose FIRST. Use ctx_graph for targeted structural queries.\n\
26             ANTIPATTERN: symbol returns only the DEFINITION — not usages. For REFERENCES use grep or ctx_compose.",
27            json!({
28                "type": "object",
29                "properties": {
30                    "action": {
31                        "type": "string",
32                        "description": "build|related|symbol|impact|status|enrich|context|diagram|neighbors|path|explain|diff"
33                    },
34                    "path": {
35                        "type": "string",
36                        "description": "Path; file::symbol for symbol action"
37                    },
38                    "to": { "type": "string", "description": "Target file (action=path)" },
39                    "depth": { "type": "integer", "description": "Traversal depth" },
40                    "kind": { "type": "string", "description": "diagram: deps|calls" },
41                    "format": { "type": "string", "description": "text|json" },
42                    "since": { "type": "string", "description": "Git ref for action=diff (default HEAD~1)" },
43                    "project_root": { "type": "string", "description": "Project root" }
44                },
45                "required": ["action"]
46            }),
47        )
48    }
49
50    fn handle(
51        &self,
52        args: &Map<String, Value>,
53        ctx: &ToolContext,
54    ) -> Result<ToolOutput, ErrorData> {
55        let action = get_str(args, "action")
56            .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
57
58        let path = if action == "diagram" {
59            get_str(args, "path")
60        } else if let Some(p) = ctx.resolved_path("path") {
61            Some(p.to_string())
62        } else if let Some(err) = ctx
63            .path_error("path")
64            .filter(|_| get_str(args, "path").is_some())
65        {
66            return Err(ErrorData::invalid_params(format!("path: {err}"), None));
67        } else {
68            None
69        };
70
71        let root = if let Some(p) = ctx.resolved_path("project_root") {
72            p.to_string()
73        } else if let Some(err) = ctx.path_error("project_root") {
74            return Err(ErrorData::invalid_params(
75                format!("project_root: {err}"),
76                None,
77            ));
78        } else {
79            ctx.project_root.clone()
80        };
81        let depth = get_usize(args, "depth").map(|d| d.min(64));
82        let kind = get_str(args, "kind");
83        let format = get_str(args, "format");
84        // `since` is a git ref, not a filesystem path — read it raw (no PathJail).
85        let since = get_str(args, "since");
86        let to = if let Some(p) = ctx.resolved_path("to") {
87            Some(p.to_string())
88        } else if let Some(err) = ctx
89            .path_error("to")
90            .filter(|_| get_str(args, "to").is_some())
91        {
92            return Err(ErrorData::invalid_params(format!("to: {err}"), None));
93        } else {
94            None
95        };
96
97        let cache = ctx
98            .cache
99            .as_ref()
100            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
101        let Some(mut guard) = crate::server::bounded_lock::write(cache, "ctx_graph") else {
102            return Ok(ToolOutput::simple(
103                "[graph cache temporarily unavailable — retry in a moment]".to_string(),
104            ));
105        };
106        let result = crate::tools::ctx_graph::handle(
107            &action,
108            path.as_deref(),
109            &root,
110            &mut guard,
111            ctx.crp_mode,
112            depth,
113            kind.as_deref(),
114            to.as_deref(),
115            format.as_deref(),
116            since.as_deref(),
117        );
118
119        Ok(ToolOutput {
120            text: result,
121            original_tokens: 0,
122            saved_tokens: 0,
123            mode: Some(action),
124            path: None,
125            changed: false,
126            shell_outcome: None,
127        })
128    }
129}