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
10fn append_content_dedup_stats(lines: &mut Vec<String>) {
11 let dedup = crate::tools::ctx_read::dedup_hook::summary();
12 if dedup.total_reads > 0 {
13 lines.push(format!(
14 "content dedup: {} hit(s)/{} read(s) ({:.1}%), {} tokens saved",
15 dedup.dedup_hits,
16 dedup.total_reads,
17 dedup.hit_rate * 100.0,
18 dedup.tokens_saved
19 ));
20 }
21}
22
23impl McpTool for CtxCacheTool {
24 fn name(&self) -> &'static str {
25 "ctx_cache"
26 }
27
28 fn tool_def(&self) -> Tool {
29 tool_def(
30 "ctx_cache",
31 "Cache operations — inspect, clear, or invalidate the read cache.\n\
32 Actions: status lists cached files; clear empties all (recover token budget);\n\
33 invalidate path=... refreshes a single entry.\n\
34 Use to diagnose stale content or recover budget after large reads.\n\
35 ANTIPATTERN: does NOT affect disk files — only cached read content.",
36 json!({
37 "type": "object",
38 "properties": {
39 "action": {
40 "type": "string",
41 "enum": ["status", "clear", "invalidate"],
42 "description": "status|clear|invalidate"
43 },
44 "path": {
45 "type": "string",
46 "description": "File path for invalidate action (only used with action=invalidate)"
47 }
48 },
49 "allOf": [
50 {
51 "if": {
52 "properties": { "action": { "const": "invalidate" } },
53 "required": ["action"]
54 },
55 "then": { "required": ["action", "path"] }
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 let invalidate_path = if action == "invalidate" {
72 Some(require_resolved_path(ctx, args, "path")?)
73 } else {
74 None
75 };
76
77 let cache = ctx
78 .cache
79 .as_ref()
80 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
81 let Some(mut guard) = crate::server::bounded_lock::write(cache, "ctx_cache") else {
82 return Ok(ToolOutput::simple(
83 "[cache lock temporarily unavailable — retry in a moment]".to_string(),
84 ));
85 };
86
87 let result = match action.as_str() {
88 "status" => {
89 let entries = guard.get_all_entries();
90 let mut lines = if entries.is_empty() {
91 vec!["Cache empty — no files tracked.".to_string()]
92 } else {
93 let mut lines = vec![format!("Cache: {} file(s)", entries.len())];
94 for (path, entry) in &entries {
95 let fref = guard
96 .file_ref_map()
97 .get(*path)
98 .map_or("F?", std::string::String::as_str);
99 lines.push(format!(
100 " {fref}={} [{}L, {}t, read {}x]",
101 crate::core::protocol::shorten_path(path),
102 entry.line_count,
103 entry.original_tokens,
104 entry.read_count()
105 ));
106 }
107 lines
108 };
109 append_content_dedup_stats(&mut lines);
110 let t = crate::core::cache_telemetry::snapshot();
113 if t.total() > 0 {
114 lines.push(format!(
115 "re-deliveries forced: compaction={} idle={} eviction={} conversation={} (total {})",
116 t.compaction, t.idle, t.eviction, t.conversation, t.total()
117 ));
118 }
119 if t.raw_cap_count > 0 {
120 lines.push(format!(
121 "raw-cap fallbacks: {} (prevented {} tokens inflation)",
122 t.raw_cap_count, t.raw_cap_prevented
123 ));
124 }
125 lines.join("\n")
126 }
127 "clear" => {
128 let count = guard.clear();
129 format!(
130 "Cache cleared — {count} file(s) removed. Next ctx_read will return full content."
131 )
132 }
133 "invalidate" => {
134 let Some(path) = invalidate_path else {
135 return Ok(ToolOutput::simple(
136 "Missing path for invalidate action.".to_string(),
137 ));
138 };
139 if guard.invalidate(&path) {
140 format!(
141 "Invalidated cache for {}. Next ctx_read will return full content.",
142 crate::core::protocol::shorten_path(&path)
143 )
144 } else {
145 format!(
146 "{} was not in cache.",
147 crate::core::protocol::shorten_path(&path)
148 )
149 }
150 }
151 _ => "Unknown action. Use: status, clear, invalidate".to_string(),
152 };
153
154 Ok(ToolOutput {
155 text: result,
156 original_tokens: 0,
157 saved_tokens: 0,
158 mode: Some(action),
159 path: None,
160 changed: false,
161 shell_outcome: None,
162 content_blocks: None,
163 })
164 }
165}