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 lower = client_name.to_lowercase();
31        let id = identify_client(&lower);
32
33        match id.as_str() {
34            "cursor" | "kiro" => Self {
35                client_id: id,
36                resources: true,
37                prompts: true,
38                elicitation: true,
39                sampling: false,
40                dynamic_tools: true,
41                max_tools: None,
42            },
43            "claude-code" => Self {
44                client_id: id,
45                resources: true,
46                prompts: true,
47                elicitation: true,
48                sampling: true,
49                dynamic_tools: true,
50                max_tools: None,
51            },
52            "windsurf" => Self {
53                client_id: id,
54                resources: false,
55                prompts: false,
56                elicitation: false,
57                sampling: false,
58                dynamic_tools: true,
59                max_tools: Some(100),
60            },
61            "zed" => Self {
62                client_id: id,
63                resources: false,
64                prompts: true,
65                elicitation: false,
66                sampling: false,
67                dynamic_tools: true,
68                max_tools: None,
69            },
70            "vscode-copilot" => Self {
71                client_id: id,
72                resources: true,
73                prompts: true,
74                elicitation: false,
75                sampling: false,
76                dynamic_tools: true,
77                max_tools: None,
78            },
79            "codex" => Self {
80                client_id: id,
81                resources: true,
82                prompts: false,
83                elicitation: false,
84                sampling: false,
85                dynamic_tools: true,
86                max_tools: None,
87            },
88            "antigravity" | "gemini-cli" => Self {
89                client_id: id,
90                resources: false,
91                prompts: false,
92                elicitation: false,
93                sampling: false,
94                dynamic_tools: false,
95                max_tools: None,
96            },
97            _ => Self {
98                client_id: id,
99                ..Default::default()
100            },
101        }
102    }
103
104    pub fn tier(&self) -> u8 {
105        let score = [
106            self.resources,
107            self.prompts,
108            self.elicitation,
109            self.sampling,
110            self.dynamic_tools,
111        ]
112        .iter()
113        .filter(|&&v| v)
114        .count();
115
116        match score {
117            4..=5 => 1,
118            2..=3 => 2,
119            1 => 3,
120            _ => 4,
121        }
122    }
123
124    pub fn format_summary(&self) -> String {
125        let features: Vec<&str> = [
126            ("resources", self.resources),
127            ("prompts", self.prompts),
128            ("elicitation", self.elicitation),
129            ("sampling", self.sampling),
130            ("dynamic_tools", self.dynamic_tools),
131        ]
132        .iter()
133        .filter(|(_, v)| *v)
134        .map(|(k, _)| *k)
135        .collect();
136
137        let tools_note = self
138            .max_tools
139            .map(|n| format!(" (max {n} tools)"))
140            .unwrap_or_default();
141
142        format!(
143            "{} (tier {}): [{}]{}",
144            self.client_id,
145            self.tier(),
146            features.join(", "),
147            tools_note,
148        )
149    }
150}
151
152fn identify_client(lower: &str) -> String {
153    if lower.contains("cursor") {
154        "cursor".to_string()
155    } else if lower.contains("claude") {
156        "claude-code".to_string()
157    } else if lower.contains("windsurf") || lower.contains("codeium") {
158        "windsurf".to_string()
159    } else if lower.contains("zed") {
160        "zed".to_string()
161    } else if lower.contains("copilot") || lower.contains("github") {
162        "vscode-copilot".to_string()
163    } else if lower.contains("kiro") {
164        "kiro".to_string()
165    } else if lower.contains("codex") || lower.contains("openai") {
166        "codex".to_string()
167    } else if lower.contains("antigravity") {
168        "antigravity".to_string()
169    } else if lower.contains("gemini") {
170        "gemini-cli".to_string()
171    } else {
172        "unknown".to_string()
173    }
174}
175
176static GLOBAL: OnceLock<Mutex<ClientMcpCapabilities>> = OnceLock::new();
177
178pub fn global() -> &'static Mutex<ClientMcpCapabilities> {
179    GLOBAL.get_or_init(|| Mutex::new(ClientMcpCapabilities::default()))
180}
181
182pub fn set_detected(caps: ClientMcpCapabilities) {
183    if let Ok(mut g) = global().lock() {
184        *g = caps;
185    }
186}
187
188pub fn current() -> ClientMcpCapabilities {
189    global().lock().map(|g| g.clone()).unwrap_or_default()
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn cursor_detection() {
198        let caps = ClientMcpCapabilities::detect("Cursor");
199        assert_eq!(caps.client_id, "cursor");
200        assert!(caps.resources);
201        assert!(caps.prompts);
202        assert!(caps.elicitation);
203        assert!(caps.dynamic_tools);
204        assert_eq!(caps.tier(), 1);
205    }
206
207    #[test]
208    fn claude_code_detection() {
209        let caps = ClientMcpCapabilities::detect("claude-code");
210        assert_eq!(caps.client_id, "claude-code");
211        assert!(caps.sampling);
212        assert_eq!(caps.tier(), 1);
213    }
214
215    #[test]
216    fn windsurf_detection() {
217        let caps = ClientMcpCapabilities::detect("Windsurf");
218        assert_eq!(caps.client_id, "windsurf");
219        assert!(!caps.resources);
220        assert!(!caps.prompts);
221        assert_eq!(caps.max_tools, Some(100));
222        assert_eq!(caps.tier(), 3);
223    }
224
225    #[test]
226    fn unknown_client_tier4() {
227        let caps = ClientMcpCapabilities::detect("random-editor");
228        assert_eq!(caps.client_id, "unknown");
229        assert_eq!(caps.tier(), 4);
230    }
231
232    #[test]
233    fn format_summary() {
234        let caps = ClientMcpCapabilities::detect("Cursor");
235        let s = caps.format_summary();
236        assert!(s.contains("cursor"));
237        assert!(s.contains("tier 1"));
238    }
239}