Skip to main content

lean_ctx_server/tools/registered/
ctx_graph.rs

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