lean_ctx_server/server/
resources.rs1use rmcp::model::{Resource, ResourceContents};
3
4const URI_SUMMARY: &str = "lean-ctx://context/summary";
5const URI_PINNED: &str = "lean-ctx://context/pinned";
6const URI_PRESSURE: &str = "lean-ctx://context/pressure";
7const URI_PLAN: &str = "lean-ctx://context/plan";
8const URI_BOUNCE: &str = "lean-ctx://context/bounce";
9
10pub fn list_resources() -> Vec<Resource> {
11 vec![
12 make_resource(
13 URI_SUMMARY,
14 "Context Summary",
15 "Ledger compact: items, pressure, budget",
16 ),
17 make_resource(
18 URI_PINNED,
19 "Pinned Items",
20 "Pinned context items with compressed content",
21 ),
22 make_resource(
23 URI_PRESSURE,
24 "Context Pressure",
25 "Budget utilization and recommendations",
26 ),
27 make_resource(
28 URI_PLAN,
29 "Context Plan",
30 "Current context plan with modes per file",
31 ),
32 make_resource(
33 URI_BOUNCE,
34 "Bounce Stats",
35 "Bounce detection statistics and wasted tokens",
36 ),
37 ]
38}
39
40pub(super) fn text_resource(text: &str, uri: &str) -> ResourceContents {
41 ResourceContents::text(crate::server::policy_guard::redact_model_text(text), uri)
42}
43
44pub fn read_resource(
45 uri: &str,
46 ledger: &lean_ctx::core::context_ledger::ContextLedger,
47) -> Option<Vec<ResourceContents>> {
48 match uri {
49 URI_SUMMARY => Some(vec![text_resource(&build_summary(ledger), uri)]),
50 URI_PRESSURE => Some(vec![text_resource(&build_pressure(ledger), uri)]),
51 URI_PLAN => Some(vec![text_resource(&build_plan(ledger), uri)]),
52 URI_PINNED => Some(vec![text_resource(&build_pinned(ledger), uri)]),
53 URI_BOUNCE => Some(vec![text_resource(&build_bounce(), uri)]),
54 _ => None,
55 }
56}
57
58pub fn unknown_resource_message(uri: &str) -> String {
64 let looks_like_file = uri.starts_with("file:")
65 || uri.starts_with('/')
66 || (uri.len() > 2 && uri.as_bytes()[1] == b':' && uri.as_bytes()[0].is_ascii_alphabetic())
67 || uri.contains("/.claude/projects/")
68 || uri.contains("\\memory\\")
69 || uri.contains("/memory/");
70
71 if looks_like_file {
72 format!(
73 "Unknown resource: {uri}. File not readable — it may not exist, \
74 be outside the project root, or be too large (>2 MiB). \
75 For project files use ctx_read / ctx_patch. \
76 For Claude auto memory (~/.claude/projects/<slug>/memory/) use native Read/Edit."
77 )
78 } else {
79 format!("Unknown resource: {uri}")
80 }
81}
82
83fn make_resource(uri: &str, name: &str, desc: &str) -> Resource {
84 Resource::new(uri, name)
85 .with_description(desc)
86 .with_mime_type("text/plain")
87}
88
89fn build_summary(ledger: &lean_ctx::core::context_ledger::ContextLedger) -> String {
90 let pressure = ledger.pressure();
91 let adjusted = ledger.adjusted_total_saved();
92 format!(
93 "files:{} | sent:{} | saved:{} (adj:{}) | pressure:{:.0}% | action:{:?}",
94 ledger.entries.len(),
95 ledger.total_tokens_sent,
96 ledger.total_tokens_saved,
97 adjusted,
98 pressure.utilization * 100.0,
99 pressure.recommendation,
100 )
101}
102
103fn build_pressure(ledger: &lean_ctx::core::context_ledger::ContextLedger) -> String {
104 let p = ledger.pressure();
105 let mut lines = vec![
106 format!("utilization: {:.1}%", p.utilization * 100.0),
107 format!("remaining: {} tokens", p.remaining_tokens),
108 format!("entries: {}", p.entries_count),
109 format!("action: {:?}", p.recommendation),
110 ];
111
112 if p.utilization > 0.8 {
113 let evict = ledger.eviction_candidates_by_phi(3);
114 if !evict.is_empty() {
115 let names: Vec<_> = evict
116 .iter()
117 .take(5)
118 .map(|p| lean_ctx::core::protocol::shorten_path(p))
119 .collect();
120 lines.push(format!("eviction_candidates: {}", names.join(", ")));
121 }
122 }
123
124 lines.join("\n")
125}
126
127fn build_plan(ledger: &lean_ctx::core::context_ledger::ContextLedger) -> String {
128 let mut lines = Vec::new();
129 for entry in &ledger.entries {
130 let short = lean_ctx::core::protocol::shorten_path(&entry.path);
131 let phi_str = entry.phi.map_or("?".to_string(), |p| format!("{p:.2}"));
132 let state_str = entry.state.as_ref().map_or("?", |s| match s {
133 lean_ctx::core::context_field::ContextState::Included => "incl",
134 lean_ctx::core::context_field::ContextState::Pinned => "pin",
135 lean_ctx::core::context_field::ContextState::Excluded => "excl",
136 lean_ctx::core::context_field::ContextState::Candidate => "cand",
137 lean_ctx::core::context_field::ContextState::Stale => "stale",
138 lean_ctx::core::context_field::ContextState::Shadowed => "shadow",
139 });
140 lines.push(format!(
141 "{short} mode={} tok={} phi={phi_str} state={state_str}",
142 entry.mode, entry.sent_tokens,
143 ));
144 }
145 if lines.is_empty() {
146 "No context items tracked yet.".to_string()
147 } else {
148 lines.join("\n")
149 }
150}
151
152fn build_pinned(ledger: &lean_ctx::core::context_ledger::ContextLedger) -> String {
153 let pinned: Vec<_> = ledger
154 .entries
155 .iter()
156 .filter(|e| e.state == Some(lean_ctx::core::context_field::ContextState::Pinned))
157 .collect();
158 if pinned.is_empty() {
159 return "No pinned items.".to_string();
160 }
161 let mut lines = Vec::new();
162 for entry in pinned {
163 let short = lean_ctx::core::protocol::shorten_path(&entry.path);
164 lines.push(format!(
165 "{short} mode={} tok={}",
166 entry.mode, entry.sent_tokens
167 ));
168 }
169 lines.join("\n")
170}
171
172fn build_bounce() -> String {
173 match lean_ctx::core::bounce_tracker::global().lock() {
174 Ok(bt) => bt.format_summary(),
175 _ => "Bounce tracker unavailable.".to_string(),
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn list_returns_five_resources() {
185 let resources = list_resources();
186 assert_eq!(resources.len(), 5);
187 }
188
189 #[test]
190 fn read_summary_returns_content() {
191 let ledger = lean_ctx::core::context_ledger::ContextLedger::new();
192 let result = read_resource(URI_SUMMARY, &ledger);
193 assert!(result.is_some());
194 }
195
196 #[test]
197 fn read_unknown_uri_returns_none() {
198 let ledger = lean_ctx::core::context_ledger::ContextLedger::new();
199 let result = read_resource("lean-ctx://unknown", &ledger);
200 assert!(result.is_none());
201 }
202
203 #[test]
204 fn read_pressure_returns_content() {
205 let ledger = lean_ctx::core::context_ledger::ContextLedger::new();
206 let result = read_resource(URI_PRESSURE, &ledger);
207 assert!(result.is_some());
208 }
209
210 #[test]
211 fn read_bounce_returns_content() {
212 let ledger = lean_ctx::core::context_ledger::ContextLedger::new();
213 let result = read_resource(URI_BOUNCE, &ledger);
214 assert!(result.is_some());
215 }
216
217 #[test]
218 fn unknown_file_uri_message_guides_to_ctx_read_and_native_memory() {
219 let msg = unknown_resource_message(
220 "file:///home/jules/.claude/projects/-home-jules-Projects-blockposters/memory/MEMORY.md",
221 );
222 assert!(msg.contains("ctx_read"), "{msg}");
223 assert!(msg.contains("native Read/Edit"), "{msg}");
224 assert!(msg.contains("auto memory"), "{msg}");
225 }
226
227 #[test]
228 fn unknown_lean_ctx_uri_stays_short() {
229 let msg = unknown_resource_message("lean-ctx://unknown");
230 assert_eq!(msg, "Unknown resource: lean-ctx://unknown");
231 }
232}