1use anyhow::{Context, Result};
2use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::time::Duration;
6
7const CONTEXT7_MCP_URL: &str = "https://mcp.context7.com/mcp";
8const REQUEST_TIMEOUT: u64 = 30;
9
10#[derive(Debug, Clone)]
11pub struct Context7Client {
12 client: Client,
13 api_key: Option<String>,
14}
15
16#[derive(Debug, Serialize)]
17struct JsonRpcRequest {
18 jsonrpc: String,
19 method: String,
20 params: serde_json::Value,
21 id: u64,
22}
23
24#[derive(Debug, Deserialize)]
25struct JsonRpcResponse {
26 #[allow(dead_code)]
27 jsonrpc: String,
28 #[allow(dead_code)]
29 id: u64,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 result: Option<serde_json::Value>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 error: Option<JsonRpcError>,
34}
35
36#[derive(Debug, Deserialize)]
37struct JsonRpcError {
38 code: i32,
39 message: String,
40 #[allow(dead_code)]
41 #[serde(skip_serializing_if = "Option::is_none")]
42 data: Option<serde_json::Value>,
43}
44
45#[derive(Debug, Deserialize, Serialize)]
46pub struct LibraryInfo {
47 pub id: String,
48 pub name: String,
49 pub version: Option<String>,
50 pub description: Option<String>,
51}
52
53#[derive(Debug, Deserialize, Serialize)]
54pub struct Documentation {
55 pub library: LibraryInfo,
56 pub sections: Vec<DocSection>,
57}
58
59#[derive(Debug, Deserialize, Serialize)]
60pub struct DocSection {
61 pub id: String,
62 pub title: String,
63 pub content: String,
64 pub code_examples: Vec<CodeExample>,
65 pub url: Option<String>,
66}
67
68#[derive(Debug, Deserialize, Serialize)]
69pub struct CodeExample {
70 pub language: String,
71 pub code: String,
72 pub description: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct SearchResult {
77 pub id: String,
78 pub library: String,
79 pub title: String,
80 pub excerpt: String,
81 pub url: Option<String>,
82 pub relevance_score: f32,
83}
84
85impl Context7Client {
86 pub fn new(api_key: Option<String>) -> Result<Self> {
87 let client = Client::builder()
88 .timeout(Duration::from_secs(REQUEST_TIMEOUT))
89 .user_agent(format!("manx/{}", env!("CARGO_PKG_VERSION")))
90 .build()
91 .context("Failed to create HTTP client")?;
92
93 Ok(Self { client, api_key })
94 }
95
96 fn get_base_url(&self) -> &str {
97 CONTEXT7_MCP_URL
99 }
100
101 pub async fn resolve_library(&self, library_name: &str) -> Result<(String, String)> {
102 let request = JsonRpcRequest {
104 jsonrpc: "2.0".to_string(),
105 method: "tools/call".to_string(),
106 params: json!({
107 "name": "resolve-library-id",
108 "arguments": {
109 "libraryName": library_name
110 }
111 }),
112 id: 1,
113 };
114
115 let response = self.send_request(request).await?;
116
117 if let Some(error) = response.error {
118 anyhow::bail!("API error: {} (code: {})", error.message, error.code);
119 }
120
121 let result = response.result.context("No result in response")?;
122
123 let content = result
125 .get("content")
126 .and_then(|c| c.as_array())
127 .and_then(|arr| arr.first())
128 .and_then(|item| item.get("text"))
129 .and_then(|text| text.as_str())
130 .context("Failed to extract content from response")?;
131
132 let lines: Vec<&str> = content.lines().collect();
137 let mut libraries = Vec::new();
138
139 let mut current_lib: Option<(String, String, f64, u32)> = None; for line in &lines {
143 if let Some(stripped) = line.strip_prefix("- Title: ") {
145 let title = stripped.trim().to_string();
146 current_lib = Some((String::new(), title, 0.0, 0));
147 }
148 else if line.contains("Context7-compatible library ID:") {
150 if let Some((_, title, trust, snippets)) = current_lib.as_mut() {
151 if let Some(start) = line.find('/') {
152 let id_part = &line[start..];
153 let end = id_part.find(char::is_whitespace).unwrap_or(id_part.len());
154 *title = title.clone(); libraries.push((
156 id_part[..end].trim().to_string(),
157 title.clone(),
158 *trust,
159 *snippets,
160 ));
161 }
162 }
163 }
164 else if line.contains("Code Snippets:") {
166 if let Some((_, _, _, snippets)) = current_lib.as_mut() {
167 if let Some(count_str) = line.split("Code Snippets:").nth(1) {
168 if let Ok(count) = count_str.trim().parse::<u32>() {
169 *snippets = count;
170 }
171 }
172 }
173 }
174 else if line.contains("Trust Score:") {
176 if let Some((_, _, trust, _)) = current_lib.as_mut() {
177 if let Some(score_str) = line.split("Trust Score:").nth(1) {
178 if let Ok(score) = score_str.trim().parse::<f64>() {
179 *trust = score;
180 }
181 }
182 }
183 }
184 }
185
186 log::debug!(
187 "Found {} library candidates for '{}'",
188 libraries.len(),
189 library_name
190 );
191 for (i, (id, title, trust, snippets)) in libraries.iter().enumerate() {
192 log::debug!(
193 " {}: {} ({}) - Trust: {}, Snippets: {}",
194 i + 1,
195 title,
196 id,
197 trust,
198 snippets
199 );
200 }
201
202 let selected_library = libraries.iter().enumerate().max_by_key(
204 |(index, (_id, title, trust_score, snippet_count))| {
205 let mut score = 0;
206
207 score += (1000 - index) * 100;
209
210 if title.to_lowercase() == library_name.to_lowercase() {
212 score += 500;
213 }
214
215 if title.to_lowercase().contains(&library_name.to_lowercase()) {
217 score += 200;
218 }
219
220 if *trust_score >= 7.0 {
222 score += (*trust_score * 10.0) as usize;
223 }
224
225 score += (*snippet_count as usize).min(100);
227
228 log::debug!(
229 "Library '{}' score: {} (index: {}, trust: {}, snippets: {})",
230 title,
231 score,
232 index,
233 trust_score,
234 snippet_count
235 );
236
237 score
238 },
239 );
240
241 if let Some((index, (library_id, title, trust_score, snippet_count))) = selected_library {
242 log::debug!(
243 "Selected library: '{}' ({}), Trust: {}, Snippets: {}, Position: {}",
244 title,
245 library_id,
246 trust_score,
247 snippet_count,
248 index + 1
249 );
250 Ok((library_id.clone(), title.clone()))
251 } else {
252 let available_libraries: Vec<String> = lines
254 .iter()
255 .filter_map(|line| {
256 if line.contains("- Title: ") {
257 Some(line.replace("- Title: ", "").trim().to_string())
258 } else {
259 None
260 }
261 })
262 .collect();
263
264 if !available_libraries.is_empty() {
265 let suggestions =
266 crate::search::fuzzy_find_libraries(library_name, &available_libraries);
267 if !suggestions.is_empty() {
268 let suggestion_text: Vec<String> =
269 suggestions.iter().map(|(name, _)| name.clone()).collect();
270 anyhow::bail!(
271 "Library '{}' not found. Did you mean one of: {}?",
272 library_name,
273 suggestion_text.join(", ")
274 );
275 }
276 }
277
278 anyhow::bail!(
279 "No library ID found in response for '{}': {}",
280 library_name,
281 content
282 );
283 }
284 }
285
286 pub async fn get_documentation(&self, library_id: &str, topic: Option<&str>) -> Result<String> {
287 let mut params = json!({
288 "context7CompatibleLibraryID": library_id
289 });
290
291 if let Some(topic_str) = topic {
292 params["topic"] = json!(topic_str);
293 }
294
295 let request = JsonRpcRequest {
297 jsonrpc: "2.0".to_string(),
298 method: "tools/call".to_string(),
299 params: json!({
300 "name": "get-library-docs",
301 "arguments": params
302 }),
303 id: 2,
304 };
305
306 let response = self.send_request(request).await?;
307
308 if let Some(error) = response.error {
309 anyhow::bail!("API error: {} (code: {})", error.message, error.code);
310 }
311
312 let result = response.result.context("No result in response")?;
313
314 let content = result
316 .get("content")
317 .and_then(|c| c.as_array())
318 .and_then(|arr| arr.first())
319 .and_then(|item| item.get("text"))
320 .and_then(|text| text.as_str())
321 .context("Failed to extract documentation from response")?;
322
323 Ok(content.to_string())
324 }
325
326 async fn send_request(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse> {
327 let base_url = self.get_base_url();
328 let mut req = self
329 .client
330 .post(base_url)
331 .header("Accept", "application/json, text/event-stream")
332 .header("Content-Type", "application/json")
333 .json(&request);
334
335 if let Some(key) = &self.api_key {
336 req = req.header("CONTEXT7_API_KEY", key);
337 }
338
339 let response = req
340 .send()
341 .await
342 .context("Failed to send request to Context7")?;
343
344 if !response.status().is_success() {
345 let status = response.status();
346 let error_text = response
347 .text()
348 .await
349 .unwrap_or_else(|_| "Unknown error".to_string());
350 anyhow::bail!("HTTP {} error: {}", status, error_text);
351 }
352
353 let content_type = response
354 .headers()
355 .get("content-type")
356 .and_then(|v| v.to_str().ok())
357 .unwrap_or("");
358
359 if content_type.contains("text/event-stream") {
361 let text = response.text().await?;
363 log::debug!("SSE Response: {}", text);
364
365 if let Some(json_line) = text.lines().find(|line| line.starts_with("data: ")) {
367 let json_data = &json_line[6..]; serde_json::from_str(json_data).context("Failed to parse SSE JSON data")
369 } else {
370 anyhow::bail!("No JSON data found in SSE response");
371 }
372 } else {
373 response
375 .json::<JsonRpcResponse>()
376 .await
377 .context("Failed to parse JSON-RPC response")
378 }
379 }
380}