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        // Capabilities follow the established classes; the finer `id` only
41        // names the host (telemetry, summaries) and never changes behaviour.
42        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
163/// The capability class a client name has always mapped to. Kept separate
164/// from [`identify_client`] so naming more hosts never changes what LeanCTX
165/// offers them over MCP.
166fn 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    // Hosts whose names contain another host's keyword come first: Kilo Code
200    // and Roo Code before Cline, the Copilot CLI before VS Code Copilot,
201    // Claude Desktop before Claude Code, Visual Studio before VS Code.
202    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        // #1402. Matched on the product name only — CodeWhale's legacy config
209        // dir is `~/.deepseek`, but "deepseek" is a *model* family name that
210        // shows up in unrelated clients' identifiers, so it is not a client
211        // discriminator here.
212        "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 (`omp-coding-agent`) is a Pi fork with its own MCP client.
257        "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 has no built-in MCP client: it connects through extensions that
264        // announce themselves as `pi-lean-ctx`, `pi-mcp-adapter`, `pi-mcp-probe`.
265        "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
301/// The host label for presence and dashboards. Unlike the capability class,
302/// it must not claim more than the handshake proves: VS Code's built-in MCP
303/// client announces itself as "Visual Studio Code" for every chat participant
304/// and extension that uses it, so only a name that mentions Copilot/GitHub is
305/// labelled `vscode-copilot`; the generic name is `vscode`.
306pub(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/// The MCP `clientInfo` this process received, for presence attribution.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct ClientHandshake {
327    pub host: String,
328    pub name: String,
329    pub version: Option<String>,
330}
331
332/// `clientInfo` strings are peer-controlled: bound them and drop control
333/// characters before they reach the shared registry or the dashboard.
334const 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
348/// Records the `initialize` handshake of this process and returns it.
349pub 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
361/// The last `initialize` handshake this process recorded, if any.
362pub(crate) fn handshake() -> Option<ClientHandshake> {
363    HANDSHAKE
364        .get()
365        .and_then(|slot| slot.lock().ok().and_then(|guard| guard.clone()))
366}
367
368/// Whether any process recorded an MCP handshake within `max_age_secs`,
369/// recognised or not. Separates "no MCP client" (CLI and shell hooks only)
370/// from "a client LeanCTX does not recognise" for telemetry.
371pub(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
401/// Load persisted client info from disk (for cross-process use, e.g. dashboard).
402/// Returns `None` if file missing or older than `max_age_secs`.
403pub 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        // "deepseek" alone is a model family, not the CodeWhale client (#1402).
496        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        // Words that merely contain "pi" or "omp" are other clients.
508        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            // A persisted id is re-detected as the same host.
609            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        // The capability class is unchanged: VS Code keeps Copilot parity.
642        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}