Skip to main content

lean_ctx/core/
client_capabilities.rs

1use std::sync::{Mutex, OnceLock};
2
3#[derive(Debug, Clone)]
4pub struct ClientMcpCapabilities {
5    pub client_id: String,
6    pub resources: bool,
7    pub prompts: bool,
8    pub elicitation: bool,
9    pub sampling: bool,
10    pub dynamic_tools: bool,
11    pub max_tools: Option<usize>,
12}
13
14impl Default for ClientMcpCapabilities {
15    fn default() -> Self {
16        Self {
17            client_id: "unknown".to_string(),
18            resources: false,
19            prompts: false,
20            elicitation: false,
21            sampling: false,
22            dynamic_tools: false,
23            max_tools: None,
24        }
25    }
26}
27
28impl ClientMcpCapabilities {
29    pub fn detect(client_name: &str) -> Self {
30        let hint = std::env::var("LEAN_CTX_CLIENT_HINT").ok();
31        Self::detect_with_hint(client_name, hint.as_deref())
32    }
33
34    fn detect_with_hint(client_name: &str, hint: Option<&str>) -> Self {
35        let effective = match hint {
36            Some(h) if !h.trim().is_empty() => h.trim().to_lowercase(),
37            _ => client_name.to_lowercase(),
38        };
39        let id = identify_client(&effective);
40
41        match id.as_str() {
42            "cursor" | "kiro" => Self {
43                client_id: id,
44                resources: true,
45                prompts: true,
46                elicitation: true,
47                sampling: false,
48                dynamic_tools: true,
49                max_tools: None,
50            },
51            "claude-code" => Self {
52                client_id: id,
53                resources: true,
54                prompts: true,
55                elicitation: true,
56                sampling: true,
57                dynamic_tools: true,
58                max_tools: None,
59            },
60            "windsurf" => Self {
61                client_id: id,
62                resources: false,
63                prompts: false,
64                elicitation: false,
65                sampling: false,
66                dynamic_tools: true,
67                max_tools: Some(100),
68            },
69            "zed" => Self {
70                client_id: id,
71                resources: false,
72                prompts: true,
73                elicitation: false,
74                sampling: false,
75                dynamic_tools: true,
76                max_tools: None,
77            },
78            "vscode-copilot" => Self {
79                client_id: id,
80                resources: true,
81                prompts: true,
82                elicitation: false,
83                sampling: false,
84                dynamic_tools: true,
85                max_tools: None,
86            },
87            "codex" => Self {
88                client_id: id,
89                resources: true,
90                prompts: false,
91                elicitation: false,
92                sampling: false,
93                dynamic_tools: true,
94                max_tools: None,
95            },
96            "antigravity" | "gemini-cli" => Self {
97                client_id: id,
98                resources: false,
99                prompts: false,
100                elicitation: false,
101                sampling: false,
102                dynamic_tools: false,
103                max_tools: None,
104            },
105            _ => Self {
106                client_id: id,
107                ..Default::default()
108            },
109        }
110    }
111
112    pub fn tier(&self) -> u8 {
113        let score = [
114            self.resources,
115            self.prompts,
116            self.elicitation,
117            self.sampling,
118            self.dynamic_tools,
119        ]
120        .iter()
121        .filter(|&&v| v)
122        .count();
123
124        match score {
125            4..=5 => 1,
126            2..=3 => 2,
127            1 => 3,
128            _ => 4,
129        }
130    }
131
132    pub fn format_summary(&self) -> String {
133        let features: Vec<&str> = [
134            ("resources", self.resources),
135            ("prompts", self.prompts),
136            ("elicitation", self.elicitation),
137            ("sampling", self.sampling),
138            ("dynamic_tools", self.dynamic_tools),
139        ]
140        .iter()
141        .filter(|(_, v)| *v)
142        .map(|(k, _)| *k)
143        .collect();
144
145        let tools_note = self
146            .max_tools
147            .map(|n| format!(" (max {n} tools)"))
148            .unwrap_or_default();
149
150        format!(
151            "{} (tier {}): [{}]{}",
152            self.client_id,
153            self.tier(),
154            features.join(", "),
155            tools_note,
156        )
157    }
158}
159
160fn identify_client(lower: &str) -> String {
161    if lower.contains("cursor") {
162        "cursor".to_string()
163    } else if lower.contains("codebuddy") {
164        "codebuddy".to_string()
165    } else if lower.contains("claude") {
166        "claude-code".to_string()
167    } else if lower.contains("windsurf") || lower.contains("codeium") {
168        "windsurf".to_string()
169    } else if lower.contains("zed") {
170        "zed".to_string()
171    } else if lower.contains("copilot")
172        || lower.contains("github")
173        || lower.contains("visual studio code")
174        || lower.contains("vscode")
175    {
176        "vscode-copilot".to_string()
177    } else if lower.contains("kiro") {
178        "kiro".to_string()
179    } else if lower.contains("codex") || lower.contains("openai") {
180        "codex".to_string()
181    } else if lower.contains("antigravity") {
182        "antigravity".to_string()
183    } else if lower.contains("gemini") {
184        "gemini-cli".to_string()
185    } else {
186        "unknown".to_string()
187    }
188}
189
190static GLOBAL: OnceLock<Mutex<ClientMcpCapabilities>> = OnceLock::new();
191
192pub fn global() -> &'static Mutex<ClientMcpCapabilities> {
193    GLOBAL.get_or_init(|| Mutex::new(ClientMcpCapabilities::default()))
194}
195
196pub fn set_detected(caps: &ClientMcpCapabilities) {
197    if let Ok(mut g) = global().lock() {
198        *g = caps.clone();
199    }
200    persist_to_disk(caps);
201}
202
203pub fn current() -> ClientMcpCapabilities {
204    global().lock().map(|g| g.clone()).unwrap_or_default()
205}
206
207/// Load persisted client info from disk (for cross-process use, e.g. dashboard).
208/// Returns `None` if file missing or older than `max_age_secs`.
209pub fn load_persisted(max_age_secs: u64) -> Option<ClientMcpCapabilities> {
210    let path = persisted_path()?;
211    let content = std::fs::read_to_string(&path).ok()?;
212    let val: serde_json::Value = serde_json::from_str(&content).ok()?;
213
214    let ts = val.get("ts").and_then(serde_json::Value::as_u64)?;
215    let now = std::time::SystemTime::now()
216        .duration_since(std::time::UNIX_EPOCH)
217        .map_or(0, |d| d.as_secs());
218    if now.saturating_sub(ts) > max_age_secs {
219        return None;
220    }
221
222    let client_id = val
223        .get("client_id")
224        .and_then(|v| v.as_str())
225        .unwrap_or("unknown")
226        .to_string();
227
228    if client_id == "unknown" {
229        return None;
230    }
231
232    Some(ClientMcpCapabilities::detect(&client_id))
233}
234
235fn persisted_path() -> Option<std::path::PathBuf> {
236    Some(
237        super::data_dir::lean_ctx_data_dir()
238            .ok()?
239            .join("client-id.json"),
240    )
241}
242
243fn persist_to_disk(caps: &ClientMcpCapabilities) {
244    let Some(path) = persisted_path() else {
245        return;
246    };
247    let ts = std::time::SystemTime::now()
248        .duration_since(std::time::UNIX_EPOCH)
249        .map_or(0, |d| d.as_secs());
250    let payload = serde_json::json!({
251        "client_id": caps.client_id,
252        "tier": caps.tier(),
253        "features": caps.format_summary(),
254        "ts": ts,
255    });
256    let tmp = path.with_extension("tmp");
257    if let Ok(json) = serde_json::to_string_pretty(&payload)
258        && std::fs::write(&tmp, &json).is_ok()
259    {
260        let _ = std::fs::rename(&tmp, &path);
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn cursor_detection() {
270        let caps = ClientMcpCapabilities::detect("Cursor");
271        assert_eq!(caps.client_id, "cursor");
272        assert!(caps.resources);
273        assert!(caps.prompts);
274        assert!(caps.elicitation);
275        assert!(caps.dynamic_tools);
276        assert_eq!(caps.tier(), 1);
277    }
278
279    #[test]
280    fn claude_code_detection() {
281        let caps = ClientMcpCapabilities::detect("claude-code");
282        assert_eq!(caps.client_id, "claude-code");
283        assert!(caps.sampling);
284        assert_eq!(caps.tier(), 1);
285    }
286
287    #[test]
288    fn windsurf_detection() {
289        let caps = ClientMcpCapabilities::detect("Windsurf");
290        assert_eq!(caps.client_id, "windsurf");
291        assert!(!caps.resources);
292        assert!(!caps.prompts);
293        assert_eq!(caps.max_tools, Some(100));
294        assert_eq!(caps.tier(), 3);
295    }
296
297    #[test]
298    fn unknown_client_tier4() {
299        let caps = ClientMcpCapabilities::detect("random-editor");
300        assert_eq!(caps.client_id, "unknown");
301        assert_eq!(caps.tier(), 4);
302    }
303
304    #[test]
305    fn copilot_detection() {
306        let caps = ClientMcpCapabilities::detect("GitHub Copilot");
307        assert_eq!(caps.client_id, "vscode-copilot");
308        assert!(caps.resources);
309        assert!(caps.prompts);
310        assert!(caps.dynamic_tools);
311        assert_eq!(caps.tier(), 2);
312    }
313
314    #[test]
315    fn vscode_plain_detection() {
316        let caps = ClientMcpCapabilities::detect("Visual Studio Code");
317        assert_eq!(caps.client_id, "vscode-copilot");
318        assert_eq!(caps.tier(), 2);
319    }
320
321    #[test]
322    fn vscode_lowercase_detection() {
323        let caps = ClientMcpCapabilities::detect("vscode");
324        assert_eq!(caps.client_id, "vscode-copilot");
325        assert_eq!(caps.tier(), 2);
326    }
327
328    #[test]
329    fn client_hint_override() {
330        let caps = ClientMcpCapabilities::detect_with_hint(
331            "random-unknown-editor",
332            Some("vscode-copilot"),
333        );
334        assert_eq!(caps.client_id, "vscode-copilot");
335        assert_eq!(caps.tier(), 2);
336    }
337
338    #[test]
339    fn client_hint_empty_falls_back() {
340        let caps = ClientMcpCapabilities::detect_with_hint("Cursor", Some(""));
341        assert_eq!(caps.client_id, "cursor");
342        assert_eq!(caps.tier(), 1);
343    }
344
345    #[test]
346    fn client_hint_none_falls_back() {
347        let caps = ClientMcpCapabilities::detect_with_hint("Cursor", None);
348        assert_eq!(caps.client_id, "cursor");
349        assert_eq!(caps.tier(), 1);
350    }
351
352    #[test]
353    fn format_summary() {
354        let caps = ClientMcpCapabilities::detect("Cursor");
355        let s = caps.format_summary();
356        assert!(s.contains("cursor"));
357        assert!(s.contains("tier 1"));
358    }
359}