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 lower == "muse"
214 || lower.starts_with("muse/")
215 || has(&["muse code", "muse-code", "muse_code"])
216 {
217 "muse"
220 } else if has(&["claude-ai", "claude desktop", "claude-desktop"]) {
221 "claude-desktop"
222 } else if lower.contains("claude") {
223 "claude-code"
224 } else if lower.contains("windsurf") || lower.contains("codeium") {
225 "windsurf"
226 } else if lower.contains("zed") {
227 "zed"
228 } else if has(&["copilot-cli", "copilot cli"]) {
229 "copilot-cli"
230 } else if has(&["visual-studio", "visualstudio"])
231 || (lower.contains("visual studio") && !lower.contains("visual studio code"))
232 {
233 "visual-studio"
234 } else if lower.contains("kilo") {
235 "kilo-code"
236 } else if has(&["roo-code", "roo code", "roocode", "roo-cline"]) {
237 "roo-code"
238 } else if lower.contains("cline") {
239 "cline"
240 } else if lower.contains("copilot")
241 || lower.contains("github")
242 || lower.contains("visual studio code")
243 || lower.contains("vscode")
244 {
245 "vscode-copilot"
246 } else if lower.contains("kiro") {
247 "kiro"
248 } else if lower.contains("chatgpt") {
249 "chatgpt"
250 } else if lower.contains("codex") || lower.contains("openai") {
251 "codex"
252 } else if lower.contains("antigravity") {
253 "antigravity"
254 } else if lower.contains("gemini") {
255 "gemini-cli"
256 } else if lower.contains("qwen") {
257 "qwen-code"
258 } else if lower.contains("opencode") {
259 "opencode"
260 } else if lower.contains("goose") {
261 "goose"
262 } else if lower == "omp" || lower.starts_with("omp-") || has(&["oh-my-pi", "oh my pi"]) {
263 "oh-my-pi"
265 } else if lower == "pi"
266 || lower.starts_with("pi-")
267 || lower.starts_with("pi ")
268 || has(&["pi coding agent", "pi-coding-agent"])
269 {
270 "pi"
273 } else if lower == "amp" || lower.starts_with("amp-") || lower.starts_with("amp ") {
274 "amp"
275 } else if lower.contains("augment") {
276 "augment"
277 } else if has(&[
278 "jetbrains",
279 "intellij",
280 "pycharm",
281 "webstorm",
282 "goland",
283 "junie",
284 ]) {
285 "jetbrains"
286 } else if lower.contains("warp") {
287 "warp"
288 } else if lower == "trae" || lower.starts_with("trae-") || lower.starts_with("trae ") {
289 "trae"
290 } else if lower.contains("crush") {
291 "crush"
292 } else if has(&["lm studio", "lm-studio", "lmstudio"]) {
293 "lm-studio"
294 } else if has(&["neovim", "nvim", "mcphub"]) {
295 "neovim"
296 } else if has(&["emacs", "gptel"]) {
297 "emacs"
298 } else if has(&["factory-droid", "factory droid", "factory.ai"]) || lower == "factory" {
299 "factory"
300 } else if lower.contains("continue") {
301 "continue"
302 } else {
303 "unknown"
304 };
305 id.to_string()
306}
307
308pub(crate) fn presence_host_id(client_name: &str) -> String {
314 let hint = std::env::var("LEAN_CTX_CLIENT_HINT").ok();
315 presence_host_id_with_hint(client_name, hint.as_deref())
316}
317
318fn presence_host_id_with_hint(client_name: &str, hint: Option<&str>) -> String {
319 let effective = match hint {
320 Some(h) if !h.trim().is_empty() => h.trim().to_lowercase(),
321 _ => client_name.to_lowercase(),
322 };
323 let id = identify_client(&effective);
324 if id == "vscode-copilot" && !effective.contains("copilot") && !effective.contains("github") {
325 "vscode".to_string()
326 } else {
327 id
328 }
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct ClientHandshake {
334 pub host: String,
335 pub name: String,
336 pub version: Option<String>,
337}
338
339const HANDSHAKE_FIELD_MAX_CHARS: usize = 128;
342
343fn sanitize_handshake_field(value: &str) -> Option<String> {
344 let cleaned: String = value
345 .chars()
346 .filter(|c| !c.is_control())
347 .take(HANDSHAKE_FIELD_MAX_CHARS)
348 .collect();
349 let trimmed = cleaned.trim();
350 (!trimmed.is_empty()).then(|| trimmed.to_string())
351}
352
353static HANDSHAKE: OnceLock<Mutex<Option<ClientHandshake>>> = OnceLock::new();
354
355pub fn record_handshake(name: &str, version: &str) -> ClientHandshake {
357 let handshake = ClientHandshake {
358 host: presence_host_id(name),
359 name: sanitize_handshake_field(name).unwrap_or_else(|| "unknown".to_string()),
360 version: sanitize_handshake_field(version),
361 };
362 if let Ok(mut slot) = HANDSHAKE.get_or_init(|| Mutex::new(None)).lock() {
363 *slot = Some(handshake.clone());
364 }
365 handshake
366}
367
368pub(crate) fn handshake() -> Option<ClientHandshake> {
370 HANDSHAKE
371 .get()
372 .and_then(|slot| slot.lock().ok().and_then(|guard| guard.clone()))
373}
374
375pub(crate) fn handshake_seen(max_age_secs: u64) -> bool {
379 let Some(content) = persisted_path().and_then(|path| std::fs::read_to_string(path).ok()) else {
380 return false;
381 };
382 let now = std::time::SystemTime::now()
383 .duration_since(std::time::UNIX_EPOCH)
384 .map_or(0, |d| d.as_secs());
385 serde_json::from_str::<serde_json::Value>(&content)
386 .ok()
387 .and_then(|val| val.get("ts").and_then(serde_json::Value::as_u64))
388 .is_some_and(|ts| now.saturating_sub(ts) <= max_age_secs)
389}
390
391static GLOBAL: OnceLock<Mutex<ClientMcpCapabilities>> = OnceLock::new();
392
393pub(crate) fn global() -> &'static Mutex<ClientMcpCapabilities> {
394 GLOBAL.get_or_init(|| Mutex::new(ClientMcpCapabilities::default()))
395}
396
397pub fn set_detected(caps: &ClientMcpCapabilities) {
398 if let Ok(mut g) = global().lock() {
399 *g = caps.clone();
400 }
401 persist_to_disk(caps);
402}
403
404pub fn current() -> ClientMcpCapabilities {
405 global().lock().map(|g| g.clone()).unwrap_or_default()
406}
407
408pub fn load_persisted(max_age_secs: u64) -> Option<ClientMcpCapabilities> {
411 let path = persisted_path()?;
412 let content = std::fs::read_to_string(&path).ok()?;
413 let val: serde_json::Value = serde_json::from_str(&content).ok()?;
414
415 let ts = val.get("ts").and_then(serde_json::Value::as_u64)?;
416 let now = std::time::SystemTime::now()
417 .duration_since(std::time::UNIX_EPOCH)
418 .map_or(0, |d| d.as_secs());
419 if now.saturating_sub(ts) > max_age_secs {
420 return None;
421 }
422
423 let client_id = val
424 .get("client_id")
425 .and_then(|v| v.as_str())
426 .unwrap_or("unknown")
427 .to_string();
428
429 if client_id == "unknown" {
430 return None;
431 }
432
433 Some(ClientMcpCapabilities::detect(&client_id))
434}
435
436fn persisted_path() -> Option<std::path::PathBuf> {
437 Some(
438 super::data_dir::lean_ctx_data_dir()
439 .ok()?
440 .join("client-id.json"),
441 )
442}
443
444fn persist_to_disk(caps: &ClientMcpCapabilities) {
445 let Some(path) = persisted_path() else {
446 return;
447 };
448 let ts = std::time::SystemTime::now()
449 .duration_since(std::time::UNIX_EPOCH)
450 .map_or(0, |d| d.as_secs());
451 let payload = serde_json::json!({
452 "client_id": caps.client_id,
453 "tier": caps.tier(),
454 "features": caps.format_summary(),
455 "ts": ts,
456 });
457 let tmp = path.with_extension("tmp");
458 if let Ok(json) = serde_json::to_string_pretty(&payload)
459 && std::fs::write(&tmp, &json).is_ok()
460 {
461 let _ = std::fs::rename(&tmp, &path);
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn cursor_detection() {
471 let caps = ClientMcpCapabilities::detect("Cursor");
472 assert_eq!(caps.client_id, "cursor");
473 assert!(caps.resources);
474 assert!(caps.prompts);
475 assert!(caps.elicitation);
476 assert!(caps.dynamic_tools);
477 assert_eq!(caps.tier(), 1);
478 }
479
480 #[test]
481 fn claude_code_detection() {
482 let caps = ClientMcpCapabilities::detect("claude-code");
483 assert_eq!(caps.client_id, "claude-code");
484 assert!(caps.sampling);
485 assert_eq!(caps.tier(), 1);
486 }
487
488 #[test]
489 fn windsurf_detection() {
490 let caps = ClientMcpCapabilities::detect("Windsurf");
491 assert_eq!(caps.client_id, "windsurf");
492 assert!(!caps.resources);
493 assert!(!caps.prompts);
494 assert_eq!(caps.max_tools, Some(100));
495 assert_eq!(caps.tier(), 3);
496 }
497
498 #[test]
499 fn codewhale_detection_is_distinct_and_not_confused_with_deepseek_models() {
500 assert_eq!(identify_client("codewhale"), "codewhale");
501 assert_eq!(identify_client("codewhale/0.9.9"), "codewhale");
502 assert_eq!(identify_client("deepseek-v3"), "unknown");
504 }
505
506 #[test]
507 fn muse_code_is_identified_by_its_product_name_only() {
508 for name in [
509 "muse",
510 "muse/1.4.0",
511 "muse code",
512 "muse-code-cli",
513 "meta muse_code",
514 ] {
515 assert_eq!(identify_client(name), "muse", "{name}");
516 }
517 for name in ["museum-client", "amuser"] {
518 assert_eq!(identify_client(name), "unknown", "{name}");
519 }
520 }
521
522 #[test]
523 fn pi_detection_needs_the_pi_name_not_the_letters() {
524 for name in ["pi", "pi-lean-ctx", "pi-mcp-probe", "pi coding agent"] {
525 assert_eq!(identify_client(name), "pi", "{name}");
526 }
527 for name in ["omp", "omp-coding-agent", "oh-my-pi"] {
528 assert_eq!(identify_client(name), "oh-my-pi", "{name}");
529 }
530 for name in ["pipeline-runner", "api-client", "spinnaker", "compose-mcp"] {
532 assert_eq!(identify_client(name), "unknown", "{name}");
533 }
534 }
535
536 #[test]
537 fn unknown_client_tier4() {
538 let caps = ClientMcpCapabilities::detect("random-editor");
539 assert_eq!(caps.client_id, "unknown");
540 assert_eq!(caps.tier(), 4);
541 }
542
543 #[test]
544 fn copilot_detection() {
545 let caps = ClientMcpCapabilities::detect("GitHub Copilot");
546 assert_eq!(caps.client_id, "vscode-copilot");
547 assert!(caps.resources);
548 assert!(caps.prompts);
549 assert!(caps.dynamic_tools);
550 assert_eq!(caps.tier(), 2);
551 }
552
553 #[test]
554 fn vscode_plain_detection() {
555 let caps = ClientMcpCapabilities::detect("Visual Studio Code");
556 assert_eq!(caps.client_id, "vscode-copilot");
557 assert_eq!(caps.tier(), 2);
558 }
559
560 #[test]
561 fn vscode_lowercase_detection() {
562 let caps = ClientMcpCapabilities::detect("vscode");
563 assert_eq!(caps.client_id, "vscode-copilot");
564 assert_eq!(caps.tier(), 2);
565 }
566
567 #[test]
568 fn client_hint_override() {
569 let caps = ClientMcpCapabilities::detect_with_hint(
570 "random-unknown-editor",
571 Some("vscode-copilot"),
572 );
573 assert_eq!(caps.client_id, "vscode-copilot");
574 assert_eq!(caps.tier(), 2);
575 }
576
577 #[test]
578 fn client_hint_empty_falls_back() {
579 let caps = ClientMcpCapabilities::detect_with_hint("Cursor", Some(""));
580 assert_eq!(caps.client_id, "cursor");
581 assert_eq!(caps.tier(), 1);
582 }
583
584 #[test]
585 fn more_hosts_are_named_without_changing_their_capabilities() {
586 for (name, id, same_caps_as) in [
587 ("Cline", "cline", "unknown-editor"),
588 ("Roo Code", "roo-code", "unknown-editor"),
589 ("Kilo Code", "kilo-code", "unknown-editor"),
590 ("continue-client", "continue", "unknown-editor"),
591 ("opencode", "opencode", "unknown-editor"),
592 ("goose", "goose", "unknown-editor"),
593 ("amp", "amp", "unknown-editor"),
594 ("JetBrains AI Assistant", "jetbrains", "unknown-editor"),
595 ("claude-ai", "claude-desktop", "claude-code"),
596 ("GitHub Copilot CLI", "copilot-cli", "vscode-copilot"),
597 ("Visual Studio", "visual-studio", "unknown-editor"),
598 ("LM Studio", "lm-studio", "unknown-editor"),
599 ("Qwen Code", "qwen-code", "unknown-editor"),
600 ("pi-lean-ctx", "pi", "unknown-editor"),
601 ("pi-mcp-adapter", "pi", "unknown-editor"),
602 ("Pi Coding Agent", "pi", "unknown-editor"),
603 ("omp-coding-agent", "oh-my-pi", "unknown-editor"),
604 ] {
605 let caps = ClientMcpCapabilities::detect_with_hint(name, None);
606 let reference = ClientMcpCapabilities::detect_with_hint(same_caps_as, None);
607 assert_eq!(caps.client_id, id, "{name}");
608 assert_eq!(
609 (
610 caps.resources,
611 caps.prompts,
612 caps.elicitation,
613 caps.sampling,
614 caps.dynamic_tools,
615 caps.max_tools
616 ),
617 (
618 reference.resources,
619 reference.prompts,
620 reference.elicitation,
621 reference.sampling,
622 reference.dynamic_tools,
623 reference.max_tools
624 ),
625 "{name}"
626 );
627 assert!(
628 crate::core::telemetry_v2::ClientFamily::from_client_id(id).is_some(),
629 "{id} has a wire family"
630 );
631 assert_eq!(identify_client(id), id, "{id}");
633 }
634 assert_eq!(
635 ClientMcpCapabilities::detect_with_hint("Visual Studio Code", None).client_id,
636 "vscode-copilot"
637 );
638 assert_eq!(
639 ClientMcpCapabilities::detect_with_hint("claude-code", None).client_id,
640 "claude-code"
641 );
642 }
643
644 #[test]
645 fn presence_host_does_not_claim_copilot_for_generic_vscode() {
646 for (name, host) in [
647 ("Visual Studio Code", "vscode"),
648 ("Visual Studio Code - Insiders", "vscode"),
649 ("vscode", "vscode"),
650 ("GitHub Copilot", "vscode-copilot"),
651 ("github-copilot-cli", "copilot-cli"),
652 ("claude-code", "claude-code"),
653 ("codex-mcp-client", "codex"),
654 ("cursor-vscode", "cursor"),
655 ("gemini-cli-mcp-client", "gemini-cli"),
656 ("windsurf-client", "windsurf"),
657 ("Cline", "cline"),
658 ("Roo Code", "roo-code"),
659 ("opencode", "opencode"),
660 ("random-editor", "unknown"),
661 ] {
662 assert_eq!(presence_host_id_with_hint(name, None), host, "{name}");
663 }
664 assert_eq!(
666 ClientMcpCapabilities::detect_with_hint("Visual Studio Code", None).client_id,
667 "vscode-copilot"
668 );
669 assert_eq!(
670 presence_host_id_with_hint("Visual Studio Code", Some("vscode-copilot")),
671 "vscode-copilot"
672 );
673 }
674
675 #[test]
676 fn handshake_fields_are_bounded_and_stripped_of_control_characters() {
677 assert_eq!(
678 sanitize_handshake_field(" claude-code\n\u{1b}[31m ").as_deref(),
679 Some("claude-code[31m")
680 );
681 assert_eq!(sanitize_handshake_field(" \t "), None);
682 let long = "x".repeat(4 * HANDSHAKE_FIELD_MAX_CHARS);
683 assert_eq!(
684 sanitize_handshake_field(&long).map(|value| value.chars().count()),
685 Some(HANDSHAKE_FIELD_MAX_CHARS)
686 );
687
688 let recorded = record_handshake("Visual Studio Code", "1.105.0");
689 assert_eq!(recorded.host, "vscode");
690 assert_eq!(recorded.name, "Visual Studio Code");
691 assert_eq!(recorded.version.as_deref(), Some("1.105.0"));
692 assert!(handshake().is_some());
693 }
694
695 #[test]
696 fn client_hint_none_falls_back() {
697 let caps = ClientMcpCapabilities::detect_with_hint("Cursor", None);
698 assert_eq!(caps.client_id, "cursor");
699 assert_eq!(caps.tier(), 1);
700 }
701
702 #[test]
703 fn format_summary() {
704 let caps = ClientMcpCapabilities::detect("Cursor");
705 let s = caps.format_summary();
706 assert!(s.contains("cursor"));
707 assert!(s.contains("tier 1"));
708 }
709}