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
207pub 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}