Skip to main content

lean_ctx/server/
resources.rs

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