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 lower == "muse"
214        || lower.starts_with("muse/")
215        || has(&["muse code", "muse-code", "muse_code"])
216    {
217        // #2198. Meta documents no MCP clientInfo name, so only the product
218        // name matches: "muse" as a substring would also hit e.g. "museum".
219        "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 (`omp-coding-agent`) is a Pi fork with its own MCP client.
264        "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 has no built-in MCP client: it connects through extensions that
271        // announce themselves as `pi-lean-ctx`, `pi-mcp-adapter`, `pi-mcp-probe`.
272        "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
308/// The host label for presence and dashboards. Unlike the capability class,
309/// it must not claim more than the handshake proves: VS Code's built-in MCP
310/// client announces itself as "Visual Studio Code" for every chat participant
311/// and extension that uses it, so only a name that mentions Copilot/GitHub is
312/// labelled `vscode-copilot`; the generic name is `vscode`.
313pub(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/// The MCP `clientInfo` this process received, for presence attribution.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct ClientHandshake {
334    pub host: String,
335    pub name: String,
336    pub version: Option<String>,
337}
338
339/// `clientInfo` strings are peer-controlled: bound them and drop control
340/// characters before they reach the shared registry or the dashboard.
341const 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
355/// Records the `initialize` handshake of this process and returns it.
356pub 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
368/// The last `initialize` handshake this process recorded, if any.
369pub(crate) fn handshake() -> Option<ClientHandshake> {
370    HANDSHAKE
371        .get()
372        .and_then(|slot| slot.lock().ok().and_then(|guard| guard.clone()))
373}
374
375/// Whether any process recorded an MCP handshake within `max_age_secs`,
376/// recognised or not. Separates "no MCP client" (CLI and shell hooks only)
377/// from "a client LeanCTX does not recognise" for telemetry.
378pub(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
408/// Load persisted client info from disk (for cross-process use, e.g. dashboard).
409/// Returns `None` if file missing or older than `max_age_secs`.
410pub 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        // "deepseek" alone is a model family, not the CodeWhale client (#1402).
503        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        // Words that merely contain "pi" or "omp" are other clients.
531        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            // A persisted id is re-detected as the same host.
632            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        // The capability class is unchanged: VS Code keeps Copilot parity.
665        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}