lean_ctx/tools/registered/
ctx_cache.rs1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str, require_resolved_path};
6use crate::tool_defs::tool_def;
7
8pub struct CtxCacheTool;
9
10impl McpTool for CtxCacheTool {
11 fn name(&self) -> &'static str {
12 "ctx_cache"
13 }
14
15 fn tool_def(&self) -> Tool {
16 tool_def(
17 "ctx_cache",
18 "Cache operations — inspect, clear, or invalidate the read cache.\n\
19 Actions: status lists cached files; clear empties all (recover token budget);\n\
20 invalidate path=... refreshes a single entry.\n\
21 Use to diagnose stale content or recover budget after large reads.\n\
22 ANTIPATTERN: does NOT affect disk files — only cached read content.",
23 json!({
24 "type": "object",
25 "properties": {
26 "action": {
27 "type": "string",
28 "enum": ["status", "clear", "invalidate"],
29 "description": "status|clear|invalidate"
30 },
31 "path": {
32 "type": "string",
33 "description": "File path for invalidate action (only used with action=invalidate)"
34 }
35 },
36 "required": ["action"]
37 }),
38 )
39 }
40
41 fn handle(
42 &self,
43 args: &Map<String, Value>,
44 ctx: &ToolContext,
45 ) -> Result<ToolOutput, ErrorData> {
46 let action = get_str(args, "action")
47 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
48
49 let invalidate_path = if action == "invalidate" {
50 Some(require_resolved_path(ctx, args, "path")?)
51 } else {
52 None
53 };
54
55 let cache = ctx
56 .cache
57 .as_ref()
58 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
59 let Some(mut guard) = crate::server::bounded_lock::write(cache, "ctx_cache") else {
60 return Ok(ToolOutput::simple(
61 "[cache lock temporarily unavailable — retry in a moment]".to_string(),
62 ));
63 };
64
65 let result = match action.as_str() {
66 "status" => {
67 let entries = guard.get_all_entries();
68 let mut lines = if entries.is_empty() {
69 vec!["Cache empty — no files tracked.".to_string()]
70 } else {
71 let mut lines = vec![format!("Cache: {} file(s)", entries.len())];
72 for (path, entry) in &entries {
73 let fref = guard
74 .file_ref_map()
75 .get(*path)
76 .map_or("F?", std::string::String::as_str);
77 lines.push(format!(
78 " {fref}={} [{}L, {}t, read {}x]",
79 crate::core::protocol::shorten_path(path),
80 entry.line_count,
81 entry.original_tokens,
82 entry.read_count()
83 ));
84 }
85 lines
86 };
87 let t = crate::core::cache_telemetry::snapshot();
90 if t.total() > 0 {
91 lines.push(format!(
92 "re-deliveries forced: compaction={} idle={} eviction={} conversation={} (total {})",
93 t.compaction, t.idle, t.eviction, t.conversation, t.total()
94 ));
95 }
96 if t.raw_cap_count > 0 {
97 lines.push(format!(
98 "raw-cap fallbacks: {} (prevented {} tokens inflation)",
99 t.raw_cap_count, t.raw_cap_prevented
100 ));
101 }
102 lines.join("\n")
103 }
104 "clear" => {
105 let count = guard.clear();
106 format!(
107 "Cache cleared — {count} file(s) removed. Next ctx_read will return full content."
108 )
109 }
110 "invalidate" => {
111 let Some(path) = invalidate_path else {
112 return Ok(ToolOutput::simple(
113 "Missing path for invalidate action.".to_string(),
114 ));
115 };
116 if guard.invalidate(&path) {
117 format!(
118 "Invalidated cache for {}. Next ctx_read will return full content.",
119 crate::core::protocol::shorten_path(&path)
120 )
121 } else {
122 format!(
123 "{} was not in cache.",
124 crate::core::protocol::shorten_path(&path)
125 )
126 }
127 }
128 _ => "Unknown action. Use: status, clear, invalidate".to_string(),
129 };
130
131 Ok(ToolOutput {
132 text: result,
133 original_tokens: 0,
134 saved_tokens: 0,
135 mode: Some(action),
136 path: None,
137 changed: false,
138 shell_outcome: None,
139 content_blocks: None,
140 })
141 }
142}