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}