Skip to main content

lean_ctx_server/server/
resources.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
58/// Agent-facing explanation when `resources/read` is called with an unknown URI.
59///
60/// GH #1228: agents denied native Read for auto memory often retry via MCP
61/// `resources/read` with `file://…/memory/MEMORY.md` and get a bare
62/// "Unknown resource". Point them at the real surfaces instead.
63pub 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}