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 let class = capability_class(&effective);
43
44 match class {
45 "cursor" | "kiro" => Self {
46 client_id: id,
47 resources: true,
48 prompts: true,
49 elicitation: true,
50 sampling: false,
51 dynamic_tools: true,
52 max_tools: None,
53 },
54 "claude-code" => Self {
55 client_id: id,
56 resources: true,
57 prompts: true,
58 elicitation: true,
59 sampling: true,
60 dynamic_tools: true,
61 max_tools: None,
62 },
63 "windsurf" => Self {
64 client_id: id,
65 resources: false,
66 prompts: false,
67 elicitation: false,
68 sampling: false,
69 dynamic_tools: true,
70 max_tools: Some(100),
71 },
72 "zed" => Self {
73 client_id: id,
74 resources: false,
75 prompts: true,
76 elicitation: false,
77 sampling: false,
78 dynamic_tools: true,
79 max_tools: None,
80 },
81 "vscode-copilot" => Self {
82 client_id: id,
83 resources: true,
84 prompts: true,
85 elicitation: false,
86 sampling: false,
87 dynamic_tools: true,
88 max_tools: None,
89 },
90 "codex" => Self {
91 client_id: id,
92 resources: true,
93 prompts: false,
94 elicitation: false,
95 sampling: false,
96 dynamic_tools: true,
97 max_tools: None,
98 },
99 "antigravity" | "gemini-cli" => Self {
100 client_id: id,
101 resources: false,
102 prompts: false,
103 elicitation: false,
104 sampling: false,
105 dynamic_tools: false,
106 max_tools: None,
107 },
108 _ => Self {
109 client_id: id,
110 ..Default::default()
111 },
112 }
113 }
114
115 pub fn tier(&self) -> u8 {
116 let score = [
117 self.resources,
118 self.prompts,
119 self.elicitation,
120 self.sampling,
121 self.dynamic_tools,
122 ]
123 .iter()
124 .filter(|&&v| v)
125 .count();
126
127 match score {
128 4..=5 => 1,
129 2..=3 => 2,
130 1 => 3,
131 _ => 4,
132 }
133 }
134
135 pub fn format_summary(&self) -> String {
136 let features: Vec<&str> = [
137 ("resources", self.resources),
138 ("prompts", self.prompts),
139 ("elicitation", self.elicitation),
140 ("sampling", self.sampling),
141 ("dynamic_tools", self.dynamic_tools),
142 ]
143 .iter()
144 .filter(|(_, v)| *v)
145 .map(|(k, _)| *k)
146 .collect();
147
148 let tools_note = self
149 .max_tools
150 .map(|n| format!(" (max {n} tools)"))
151 .unwrap_or_default();
152
153 format!(
154 "{} (tier {}): [{}]{}",
155 self.client_id,
156 self.tier(),
157 features.join(", "),
158 tools_note,
159 )
160 }
161}
162
163fn capability_class(lower: &str) -> &'static str {
167 if lower.contains("cursor") {
168 "cursor"
169 } else if lower.contains("codebuddy") {
170 "codebuddy"
171 } else if lower.contains("codewhale") {
172 "codewhale"
173 } else if lower.contains("claude") {
174 "claude-code"
175 } else if lower.contains("windsurf") || lower.contains("codeium") {
176 "windsurf"
177 } else if lower.contains("zed") {
178 "zed"
179 } else if lower.contains("copilot")
180 || lower.contains("github")
181 || lower.contains("visual studio code")
182 || lower.contains("vscode")
183 {
184 "vscode-copilot"
185 } else if lower.contains("kiro") {
186 "kiro"
187 } else if lower.contains("codex") || lower.contains("openai") {
188 "codex"
189 } else if lower.contains("antigravity") {
190 "antigravity"
191 } else if lower.contains("gemini") {
192 "gemini-cli"
193 } else {
194 "unknown"
195 }
196}
197
198fn identify_client(lower: &str) -> String {
199 let has = |words: &[&str]| words.iter().any(|word| lower.contains(word));
203 let id = if lower.contains("cursor") {
204 "cursor"
205 } else if lower.contains("codebuddy") {
206 "codebuddy"
207 } else if lower.contains("codewhale") {
208 "codewhale"
213 } else if has(&["claude-ai", "claude desktop", "claude-desktop"]) {
214 "claude-desktop"
215 } else if lower.contains("claude") {
216 "claude-code"
217 } else if lower.contains("windsurf") || lower.contains("codeium") {
218 "windsurf"
219 } else if lower.contains("zed") {
220 "zed"
221 } else if has(&["copilot-cli", "copilot cli"]) {
222 "copilot-cli"
223 } else if has(&["visual-studio", "visualstudio"])
224 || (lower.contains("visual studio") && !lower.contains("visual studio code"))
225 {
226 "visual-studio"
227 } else if lower.contains("kilo") {
228 "kilo-code"
229 } else if has(&["roo-code", "roo code", "roocode", "roo-cline"]) {
230 "roo-code"
231 } else if lower.contains("cline") {
232 "cline"
233 } else if lower.contains("copilot")
234 || lower.contains("github")
235 || lower.contains("visual studio code")
236 || lower.contains("vscode")
237 {
238 "vscode-copilot"
239 } else if lower.contains("kiro") {
240 "kiro"
241 } else if lower.contains("chatgpt") {
242 "chatgpt"
243 } else if lower.contains("codex") || lower.contains("openai") {
244 "codex"
245 } else if lower.contains("antigravity") {
246 "antigravity"
247 } else if lower.contains("gemini") {
248 "gemini-cli"
249 } else if lower.contains("qwen") {
250 "qwen-code"
251 } else if lower.contains("opencode") {
252 "opencode"
253 } else if lower.contains("goose") {
254 "goose"
255 } else if lower == "omp" || lower.starts_with("omp-") || has(&["oh-my-pi", "oh my pi"]) {
256 "oh-my-pi"
258 } else if lower == "pi"
259 || lower.starts_with("pi-")
260 || lower.starts_with("pi ")
261 || has(&["pi coding agent", "pi-coding-agent"])
262 {
263 "pi"
266 } else if lower == "amp" || lower.starts_with("amp-") || lower.starts_with("amp ") {
267 "amp"
268 } else if lower.contains("augment") {
269 "augment"
270 } else if has(&[
271 "jetbrains",
272 "intellij",
273 "pycharm",
274 "webstorm",
275 "goland",
276 "junie",
277 ]) {
278 "jetbrains"
279 } else if lower.contains("warp") {
280 "warp"
281 } else if lower == "trae" || lower.starts_with("trae-") || lower.starts_with("trae ") {
282 "trae"
283 } else if lower.contains("crush") {
284 "crush"
285 } else if has(&["lm studio", "lm-studio", "lmstudio"]) {
286 "lm-studio"
287 } else if has(&["neovim", "nvim", "mcphub"]) {
288 "neovim"
289 } else if has(&["emacs", "gptel"]) {
290 "emacs"
291 } else if has(&["factory-droid", "factory droid", "factory.ai"]) || lower == "factory" {
292 "factory"
293 } else if lower.contains("continue") {
294 "continue"
295 } else {
296 "unknown"
297 };
298 id.to_string()
299}
300
301pub(crate) fn presence_host_id(client_name: &str) -> String {
307 let hint = std::env::var("LEAN_CTX_CLIENT_HINT").ok();
308 presence_host_id_with_hint(client_name, hint.as_deref())
309}
310
311fn presence_host_id_with_hint(client_name: &str, hint: Option<&str>) -> String {
312 let effective = match hint {
313 Some(h) if !h.trim().is_empty() => h.trim().to_lowercase(),
314 _ => client_name.to_lowercase(),
315 };
316 let id = identify_client(&effective);
317 if id == "vscode-copilot" && !effective.contains("copilot") && !effective.contains("github") {
318 "vscode".to_string()
319 } else {
320 id
321 }
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct ClientHandshake {
327 pub host: String,
328 pub name: String,
329 pub version: Option<String>,
330}
331
332const HANDSHAKE_FIELD_MAX_CHARS: usize = 128;
335
336fn sanitize_handshake_field(value: &str) -> Option<String> {
337 let cleaned: String = value
338 .chars()
339 .filter(|c| !c.is_control())
340 .take(HANDSHAKE_FIELD_MAX_CHARS)
341 .collect();
342 let trimmed = cleaned.trim();
343 (!trimmed.is_empty()).then(|| trimmed.to_string())
344}
345
346static HANDSHAKE: OnceLock<Mutex<Option<ClientHandshake>>> = OnceLock::new();
347
348pub fn record_handshake(name: &str, version: &str) -> ClientHandshake {
350 let handshake = ClientHandshake {
351 host: presence_host_id(name),
352 name: sanitize_handshake_field(name).unwrap_or_else(|| "unknown".to_string()),
353 version: sanitize_handshake_field(version),
354 };
355 if let Ok(mut slot) = HANDSHAKE.get_or_init(|| Mutex::new(None)).lock() {
356 *slot = Some(handshake.clone());
357 }
358 handshake
359}
360
361pub(crate) fn handshake() -> Option<ClientHandshake> {
363 HANDSHAKE
364 .get()
365 .and_then(|slot| slot.lock().ok().and_then(|guard| guard.clone()))
366}
367
368pub(crate) fn handshake_seen(max_age_secs: u64) -> bool {
372 let Some(content) = persisted_path().and_then(|path| std::fs::read_to_string(path).ok()) else {
373 return false;
374 };
375 let now = std::time::SystemTime::now()
376 .duration_since(std::time::UNIX_EPOCH)
377 .map_or(0, |d| d.as_secs());
378 serde_json::from_str::<serde_json::Value>(&content)
379 .ok()
380 .and_then(|val| val.get("ts").and_then(serde_json::Value::as_u64))
381 .is_some_and(|ts| now.saturating_sub(ts) <= max_age_secs)
382}
383
384static GLOBAL: OnceLock<Mutex<ClientMcpCapabilities>> = OnceLock::new();
385
386pub(crate) fn global() -> &'static Mutex<ClientMcpCapabilities> {
387 GLOBAL.get_or_init(|| Mutex::new(ClientMcpCapabilities::default()))
388}
389
390pub fn set_detected(caps: &ClientMcpCapabilities) {
391 if let Ok(mut g) = global().lock() {
392 *g = caps.clone();
393 }
394 persist_to_disk(caps);
395}
396
397pub fn current() -> ClientMcpCapabilities {
398 global().lock().map(|g| g.clone()).unwrap_or_default()
399}
400
401pub fn load_persisted(max_age_secs: u64) -> Option<ClientMcpCapabilities> {
404 let path = persisted_path()?;
405 let content = std::fs::read_to_string(&path).ok()?;
406 let val: serde_json::Value = serde_json::from_str(&content).ok()?;
407
408 let ts = val.get("ts").and_then(serde_json::Value::as_u64)?;
409 let now = std::time::SystemTime::now()
410 .duration_since(std::time::UNIX_EPOCH)
411 .map_or(0, |d| d.as_secs());
412 if now.saturating_sub(ts) > max_age_secs {
413 return None;
414 }
415
416 let client_id = val
417 .get("client_id")
418 .and_then(|v| v.as_str())
419 .unwrap_or("unknown")
420 .to_string();
421
422 if client_id == "unknown" {
423 return None;
424 }
425
426 Some(ClientMcpCapabilities::detect(&client_id))
427}
428
429fn persisted_path() -> Option<std::path::PathBuf> {
430 Some(
431 super::data_dir::lean_ctx_data_dir()
432 .ok()?
433 .join("client-id.json"),
434 )
435}
436
437fn persist_to_disk(caps: &ClientMcpCapabilities) {
438 let Some(path) = persisted_path() else {
439 return;
440 };
441 let ts = std::time::SystemTime::now()
442 .duration_since(std::time::UNIX_EPOCH)
443 .map_or(0, |d| d.as_secs());
444 let payload = serde_json::json!({
445 "client_id": caps.client_id,
446 "tier": caps.tier(),
447 "features": caps.format_summary(),
448 "ts": ts,
449 });
450 let tmp = path.with_extension("tmp");
451 if let Ok(json) = serde_json::to_string_pretty(&payload)
452 && std::fs::write(&tmp, &json).is_ok()
453 {
454 let _ = std::fs::rename(&tmp, &path);
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 #[test]
463 fn cursor_detection() {
464 let caps = ClientMcpCapabilities::detect("Cursor");
465 assert_eq!(caps.client_id, "cursor");
466 assert!(caps.resources);
467 assert!(caps.prompts);
468 assert!(caps.elicitation);
469 assert!(caps.dynamic_tools);
470 assert_eq!(caps.tier(), 1);
471 }
472
473 #[test]
474 fn claude_code_detection() {
475 let caps = ClientMcpCapabilities::detect("claude-code");
476 assert_eq!(caps.client_id, "claude-code");
477 assert!(caps.sampling);
478 assert_eq!(caps.tier(), 1);
479 }
480
481 #[test]
482 fn windsurf_detection() {
483 let caps = ClientMcpCapabilities::detect("Windsurf");
484 assert_eq!(caps.client_id, "windsurf");
485 assert!(!caps.resources);
486 assert!(!caps.prompts);
487 assert_eq!(caps.max_tools, Some(100));
488 assert_eq!(caps.tier(), 3);
489 }
490
491 #[test]
492 fn codewhale_detection_is_distinct_and_not_confused_with_deepseek_models() {
493 assert_eq!(identify_client("codewhale"), "codewhale");
494 assert_eq!(identify_client("codewhale/0.9.9"), "codewhale");
495 assert_eq!(identify_client("deepseek-v3"), "unknown");
497 }
498
499 #[test]
500 fn pi_detection_needs_the_pi_name_not_the_letters() {
501 for name in ["pi", "pi-lean-ctx", "pi-mcp-probe", "pi coding agent"] {
502 assert_eq!(identify_client(name), "pi", "{name}");
503 }
504 for name in ["omp", "omp-coding-agent", "oh-my-pi"] {
505 assert_eq!(identify_client(name), "oh-my-pi", "{name}");
506 }
507 for name in ["pipeline-runner", "api-client", "spinnaker", "compose-mcp"] {
509 assert_eq!(identify_client(name), "unknown", "{name}");
510 }
511 }
512
513 #[test]
514 fn unknown_client_tier4() {
515 let caps = ClientMcpCapabilities::detect("random-editor");
516 assert_eq!(caps.client_id, "unknown");
517 assert_eq!(caps.tier(), 4);
518 }
519
520 #[test]
521 fn copilot_detection() {
522 let caps = ClientMcpCapabilities::detect("GitHub Copilot");
523 assert_eq!(caps.client_id, "vscode-copilot");
524 assert!(caps.resources);
525 assert!(caps.prompts);
526 assert!(caps.dynamic_tools);
527 assert_eq!(caps.tier(), 2);
528 }
529
530 #[test]
531 fn vscode_plain_detection() {
532 let caps = ClientMcpCapabilities::detect("Visual Studio Code");
533 assert_eq!(caps.client_id, "vscode-copilot");
534 assert_eq!(caps.tier(), 2);
535 }
536
537 #[test]
538 fn vscode_lowercase_detection() {
539 let caps = ClientMcpCapabilities::detect("vscode");
540 assert_eq!(caps.client_id, "vscode-copilot");
541 assert_eq!(caps.tier(), 2);
542 }
543
544 #[test]
545 fn client_hint_override() {
546 let caps = ClientMcpCapabilities::detect_with_hint(
547 "random-unknown-editor",
548 Some("vscode-copilot"),
549 );
550 assert_eq!(caps.client_id, "vscode-copilot");
551 assert_eq!(caps.tier(), 2);
552 }
553
554 #[test]
555 fn client_hint_empty_falls_back() {
556 let caps = ClientMcpCapabilities::detect_with_hint("Cursor", Some(""));
557 assert_eq!(caps.client_id, "cursor");
558 assert_eq!(caps.tier(), 1);
559 }
560
561 #[test]
562 fn more_hosts_are_named_without_changing_their_capabilities() {
563 for (name, id, same_caps_as) in [
564 ("Cline", "cline", "unknown-editor"),
565 ("Roo Code", "roo-code", "unknown-editor"),
566 ("Kilo Code", "kilo-code", "unknown-editor"),
567 ("continue-client", "continue", "unknown-editor"),
568 ("opencode", "opencode", "unknown-editor"),
569 ("goose", "goose", "unknown-editor"),
570 ("amp", "amp", "unknown-editor"),
571 ("JetBrains AI Assistant", "jetbrains", "unknown-editor"),
572 ("claude-ai", "claude-desktop", "claude-code"),
573 ("GitHub Copilot CLI", "copilot-cli", "vscode-copilot"),
574 ("Visual Studio", "visual-studio", "unknown-editor"),
575 ("LM Studio", "lm-studio", "unknown-editor"),
576 ("Qwen Code", "qwen-code", "unknown-editor"),
577 ("pi-lean-ctx", "pi", "unknown-editor"),
578 ("pi-mcp-adapter", "pi", "unknown-editor"),
579 ("Pi Coding Agent", "pi", "unknown-editor"),
580 ("omp-coding-agent", "oh-my-pi", "unknown-editor"),
581 ] {
582 let caps = ClientMcpCapabilities::detect_with_hint(name, None);
583 let reference = ClientMcpCapabilities::detect_with_hint(same_caps_as, None);
584 assert_eq!(caps.client_id, id, "{name}");
585 assert_eq!(
586 (
587 caps.resources,
588 caps.prompts,
589 caps.elicitation,
590 caps.sampling,
591 caps.dynamic_tools,
592 caps.max_tools
593 ),
594 (
595 reference.resources,
596 reference.prompts,
597 reference.elicitation,
598 reference.sampling,
599 reference.dynamic_tools,
600 reference.max_tools
601 ),
602 "{name}"
603 );
604 assert!(
605 crate::core::telemetry_v2::ClientFamily::from_client_id(id).is_some(),
606 "{id} has a wire family"
607 );
608 assert_eq!(identify_client(id), id, "{id}");
610 }
611 assert_eq!(
612 ClientMcpCapabilities::detect_with_hint("Visual Studio Code", None).client_id,
613 "vscode-copilot"
614 );
615 assert_eq!(
616 ClientMcpCapabilities::detect_with_hint("claude-code", None).client_id,
617 "claude-code"
618 );
619 }
620
621 #[test]
622 fn presence_host_does_not_claim_copilot_for_generic_vscode() {
623 for (name, host) in [
624 ("Visual Studio Code", "vscode"),
625 ("Visual Studio Code - Insiders", "vscode"),
626 ("vscode", "vscode"),
627 ("GitHub Copilot", "vscode-copilot"),
628 ("github-copilot-cli", "copilot-cli"),
629 ("claude-code", "claude-code"),
630 ("codex-mcp-client", "codex"),
631 ("cursor-vscode", "cursor"),
632 ("gemini-cli-mcp-client", "gemini-cli"),
633 ("windsurf-client", "windsurf"),
634 ("Cline", "cline"),
635 ("Roo Code", "roo-code"),
636 ("opencode", "opencode"),
637 ("random-editor", "unknown"),
638 ] {
639 assert_eq!(presence_host_id_with_hint(name, None), host, "{name}");
640 }
641 assert_eq!(
643 ClientMcpCapabilities::detect_with_hint("Visual Studio Code", None).client_id,
644 "vscode-copilot"
645 );
646 assert_eq!(
647 presence_host_id_with_hint("Visual Studio Code", Some("vscode-copilot")),
648 "vscode-copilot"
649 );
650 }
651
652 #[test]
653 fn handshake_fields_are_bounded_and_stripped_of_control_characters() {
654 assert_eq!(
655 sanitize_handshake_field(" claude-code\n\u{1b}[31m ").as_deref(),
656 Some("claude-code[31m")
657 );
658 assert_eq!(sanitize_handshake_field(" \t "), None);
659 let long = "x".repeat(4 * HANDSHAKE_FIELD_MAX_CHARS);
660 assert_eq!(
661 sanitize_handshake_field(&long).map(|value| value.chars().count()),
662 Some(HANDSHAKE_FIELD_MAX_CHARS)
663 );
664
665 let recorded = record_handshake("Visual Studio Code", "1.105.0");
666 assert_eq!(recorded.host, "vscode");
667 assert_eq!(recorded.name, "Visual Studio Code");
668 assert_eq!(recorded.version.as_deref(), Some("1.105.0"));
669 assert!(handshake().is_some());
670 }
671
672 #[test]
673 fn client_hint_none_falls_back() {
674 let caps = ClientMcpCapabilities::detect_with_hint("Cursor", None);
675 assert_eq!(caps.client_id, "cursor");
676 assert_eq!(caps.tier(), 1);
677 }
678
679 #[test]
680 fn format_summary() {
681 let caps = ClientMcpCapabilities::detect("Cursor");
682 let s = caps.format_summary();
683 assert!(s.contains("cursor"));
684 assert!(s.contains("tier 1"));
685 }
686}