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            "File-level dependency graph queries.\n\
19             action=symbol path=\"file.rs::fnName\" returns the DEFINITION (not usages — \
20             use ctx_search for references). neighbors=imports±direction, \
21             impact=reverse-dep blast radius, path from→to=dependency chain, \
22             diff since=HEAD~1=git change impact, diagram kind=deps|calls (Mermaid).\n\
23             For understanding code use ctx_compose FIRST.",
24            json!({
25                "type": "object",
26                "properties": {
27                    "action": {
28                        "type": "string",
29                        "description": "build|related|symbol|impact|status|enrich|context|diagram|neighbors|path|explain|diff"
30                    },
31                    "path": {
32                        "type": "string",
33                        "description": "Path; file::symbol for symbol action"
34                    },
35                    "to": { "type": "string", "description": "Target file (action=path)" },
36                    "depth": { "type": "integer" },
37                    "kind": { "type": "string", "description": "diagram: deps|calls" },
38                    "format": { "type": "string", "description": "text|json" },
39                    "since": { "type": "string", "description": "Git ref (default HEAD~1)" },
40                    "project_root": { "type": "string" }
41                },
42                "required": ["action"],
43                "allOf": [
44                    { "if": { "properties": { "action": { "enum": ["related", "symbol", "impact", "neighbors", "explain", "path"] } }, "required": ["action"] }, "then": { "required": ["path"] } },
45                    { "if": { "properties": { "action": { "const": "path" } }, "required": ["action"] }, "then": { "required": ["path", "to"] } }
46                ]
47            }),
48        )
49    }
50
51    fn handle(
52        &self,
53        args: &Map<String, Value>,
54        ctx: &ToolContext,
55    ) -> Result<ToolOutput, ErrorData> {
56        let action = get_str(args, "action")
57            .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
58
59        let path = if action == "diagram" {
60            get_str(args, "path")
61        } else if let Some(p) = ctx.resolved_path("path") {
62            Some(p.to_string())
63        } else if let Some(err) = ctx
64            .path_error("path")
65            .filter(|_| get_str(args, "path").is_some())
66        {
67            return Err(ErrorData::invalid_params(format!("path: {err}"), None));
68        } else {
69            None
70        };
71
72        let root = if let Some(p) = ctx.resolved_path("project_root") {
73            p.to_string()
74        } else if let Some(err) = ctx.path_error("project_root") {
75            return Err(ErrorData::invalid_params(
76                format!("project_root: {err}"),
77                None,
78            ));
79        } else {
80            ctx.project_root.clone()
81        };
82        let depth = get_usize(args, "depth").map(|d| d.min(64));
83        let kind = get_str(args, "kind");
84        let format = get_str(args, "format");
85        // `since` is a git ref, not a filesystem path — read it raw (no PathJail).
86        let since = get_str(args, "since");
87        let to = if let Some(p) = ctx.resolved_path("to") {
88            Some(p.to_string())
89        } else if let Some(err) = ctx
90            .path_error("to")
91            .filter(|_| get_str(args, "to").is_some())
92        {
93            return Err(ErrorData::invalid_params(format!("to: {err}"), None));
94        } else {
95            None
96        };
97
98        let cache = ctx
99            .cache
100            .as_ref()
101            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
102        let Some(mut guard) = crate::server::bounded_lock::write(cache, "ctx_graph") else {
103            return Ok(ToolOutput::simple(
104                "[graph cache temporarily unavailable — retry in a moment]".to_string(),
105            ));
106        };
107        let result = crate::tools::ctx_graph::handle(
108            &action,
109            path.as_deref(),
110            &root,
111            &mut guard,
112            ctx.crp_mode,
113            depth,
114            kind.as_deref(),
115            to.as_deref(),
116            format.as_deref(),
117            since.as_deref(),
118        );
119
120        Ok(ToolOutput {
121            text: result,
122            original_tokens: 0,
123            saved_tokens: 0,
124            mode: Some(action),
125            path: None,
126            changed: false,
127            shell_outcome: None,
128            content_blocks: None,
129        })
130    }
131}