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