lean_ctx/tools/registered/
ctx_graph.rs1use 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 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}