Skip to main content

lean_ctx/tools/registered/
ctx_graph.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{get_str, get_usize, McpTool, ToolContext, ToolOutput};
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            "Unified code graph. Actions: build (index), related (connected files), symbol (def/usages), \
19impact (blast radius), status (stats), enrich (add commits+tests+knowledge), context (task-based query), diagram (Mermaid deps/calls), \
20neighbors (direct in/out edges of a file), path (shortest connection between two files), explain (why a file matters: degree/community/bridge), diff (files changed since a git ref + their blast radius).",
21            json!({
22                "type": "object",
23                "properties": {
24                    "action": {
25                        "type": "string",
26                        "enum": ["build", "related", "symbol", "impact", "status", "enrich", "context", "diagram", "neighbors", "path", "explain", "diff"],
27                        "description": "Graph operation"
28                    },
29                    "path": {
30                        "type": "string",
31                        "description": "File path (related/impact/neighbors/explain), file::symbol_name (symbol), or the FROM file (path)"
32                    },
33                    "to": {
34                        "type": "string",
35                        "description": "Target file for action=path (shortest path destination)"
36                    },
37                    "depth": {
38                        "type": "integer",
39                        "description": "Optional traversal depth for action=diagram (default 2) and action=neighbors (default 1)"
40                    },
41                    "kind": {
42                        "type": "string",
43                        "description": "Optional kind for action=diagram: deps|calls"
44                    },
45                    "format": {
46                        "type": "string",
47                        "description": "Output format for neighbors/path/explain/diff: text (default) or json"
48                    },
49                    "since": {
50                        "type": "string",
51                        "description": "Base git ref for action=diff (default HEAD~1), e.g. a commit SHA, tag or HEAD~5"
52                    },
53                    "project_root": {
54                        "type": "string",
55                        "description": "Project root directory (default: .)"
56                    }
57                },
58                "required": ["action"]
59            }),
60        )
61    }
62
63    fn handle(
64        &self,
65        args: &Map<String, Value>,
66        ctx: &ToolContext,
67    ) -> Result<ToolOutput, ErrorData> {
68        let action = get_str(args, "action")
69            .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
70
71        // For diagram action, pass the raw path; for others, use the resolved path.
72        let path = if action == "diagram" {
73            get_str(args, "path")
74        } else if let Some(p) = ctx.resolved_path("path") {
75            Some(p.to_string())
76        } else if let Some(err) = ctx
77            .path_error("path")
78            .filter(|_| get_str(args, "path").is_some())
79        {
80            return Err(ErrorData::invalid_params(format!("path: {err}"), None));
81        } else {
82            None
83        };
84
85        let root = if let Some(p) = ctx.resolved_path("project_root") {
86            p.to_string()
87        } else if let Some(err) = ctx.path_error("project_root") {
88            return Err(ErrorData::invalid_params(
89                format!("project_root: {err}"),
90                None,
91            ));
92        } else {
93            ctx.project_root.clone()
94        };
95        let depth = get_usize(args, "depth").map(|d| d.min(64));
96        let kind = get_str(args, "kind");
97        let format = get_str(args, "format");
98        // `since` is a git ref, not a filesystem path — read it raw (no PathJail).
99        let since = get_str(args, "since");
100        let to = if let Some(p) = ctx.resolved_path("to") {
101            Some(p.to_string())
102        } else if let Some(err) = ctx
103            .path_error("to")
104            .filter(|_| get_str(args, "to").is_some())
105        {
106            return Err(ErrorData::invalid_params(format!("to: {err}"), None));
107        } else {
108            None
109        };
110
111        let cache = ctx
112            .cache
113            .as_ref()
114            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
115        let Some(mut guard) = crate::server::bounded_lock::write(cache, "ctx_graph") else {
116            return Ok(ToolOutput::simple(
117                "[graph cache temporarily unavailable — retry in a moment]".to_string(),
118            ));
119        };
120        let result = crate::tools::ctx_graph::handle(
121            &action,
122            path.as_deref(),
123            &root,
124            &mut guard,
125            ctx.crp_mode,
126            depth,
127            kind.as_deref(),
128            to.as_deref(),
129            format.as_deref(),
130            since.as_deref(),
131        );
132
133        Ok(ToolOutput {
134            text: result,
135            original_tokens: 0,
136            saved_tokens: 0,
137            mode: Some(action),
138            path: None,
139            changed: false,
140            shell_outcome: None,
141        })
142    }
143}