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(
102 &self,
103 library_name: &str,
104 query: &str,
105 ) -> Result<(String, String)> {
106 let request = JsonRpcRequest {
108 jsonrpc: "2.0".to_string(),
109 method: "tools/call".to_string(),
110 params: json!({
111 "name": "resolve-library-id",
112 "arguments": {
113 "libraryName": library_name,
114 "query": query
115 }
116 }),
117 id: 1,
118 };
119
120 let response = self.send_request(request).await?;
121
122 if let Some(error) = response.error {
123 anyhow::bail!("API error: {} (code: {})", error.message, error.code);
124 }
125
126 let result = response.result.context("No result in response")?;
127
128 let content = result
130 .get("content")
131 .and_then(|c| c.as_array())
132 .and_then(|arr| arr.first())
133 .and_then(|item| item.get("text"))
134 .and_then(|text| text.as_str())
135 .context("Failed to extract content from response")?;
136
137 let lines: Vec<&str> = content.lines().collect();
142 let mut libraries = Vec::new();
143
144 let mut current_id = String::new();
146 let mut current_title = String::new();
147 let mut current_score: f64 = 0.0;
148 let mut current_snippets: u32 = 0;
149 let mut has_current = false;
150
151 for line in &lines {
152 if let Some(stripped) = line.strip_prefix("- Title: ") {
154 if has_current && !current_id.is_empty() {
156 libraries.push((
157 current_id.clone(),
158 current_title.clone(),
159 current_score,
160 current_snippets,
161 ));
162 }
163 current_title = stripped.trim().to_string();
164 current_id = String::new();
165 current_score = 0.0;
166 current_snippets = 0;
167 has_current = true;
168 }
169 else if has_current && line.contains("Context7-compatible library ID:") {
171 if let Some(start) = line.find('/') {
172 let id_part = &line[start..];
173 let end = id_part.find(char::is_whitespace).unwrap_or(id_part.len());
174 current_id = id_part[..end].trim().to_string();
175 }
176 }
177 else if has_current && line.contains("Code Snippets:") {
179 if let Some(count_str) = line.split("Code Snippets:").nth(1) {
180 if let Ok(count) = count_str.trim().parse::<u32>() {
181 current_snippets = count;
182 }
183 }
184 }
185 else if has_current && line.contains("Benchmark Score:") {
187 if let Some(score_str) = line.split("Benchmark Score:").nth(1) {
188 if let Ok(score) = score_str.trim().parse::<f64>() {
189 current_score = score;
190 }
191 }
192 }
193 }
194 if has_current && !current_id.is_empty() {
196 libraries.push((current_id, current_title, current_score, current_snippets));
197 }
198
199 log::debug!(
200 "Found {} library candidates for '{}'",
201 libraries.len(),
202 library_name
203 );
204 for (i, (id, title, benchmark, snippets)) in libraries.iter().enumerate() {
205 log::debug!(
206 " {}: {} ({}) - Benchmark: {}, Snippets: {}",
207 i + 1,
208 title,
209 id,
210 benchmark,
211 snippets
212 );
213 }
214
215 let selected_library = libraries.iter().enumerate().max_by_key(
217 |(index, (_id, title, benchmark_score, snippet_count))| {
218 let mut score = 0;
219
220 score += (1000 - index) * 100;
222
223 if title.to_lowercase() == library_name.to_lowercase() {
225 score += 500;
226 }
227
228 if title.to_lowercase().contains(&library_name.to_lowercase()) {
230 score += 200;
231 }
232
233 score += *benchmark_score as usize;
235
236 score += (*snippet_count as usize).min(100);
238
239 log::debug!(
240 "Library '{}' score: {} (index: {}, benchmark: {}, snippets: {})",
241 title,
242 score,
243 index,
244 benchmark_score,
245 snippet_count
246 );
247
248 score
249 },
250 );
251
252 if let Some((index, (library_id, title, benchmark_score, snippet_count))) = selected_library
253 {
254 log::debug!(
255 "Selected library: '{}' ({}), Benchmark: {}, Snippets: {}, Position: {}",
256 title,
257 library_id,
258 benchmark_score,
259 snippet_count,
260 index + 1
261 );
262 Ok((library_id.clone(), title.clone()))
263 } else {
264 let available_libraries: Vec<String> = lines
266 .iter()
267 .filter_map(|line| {
268 if line.contains("- Title: ") {
269 Some(line.replace("- Title: ", "").trim().to_string())
270 } else {
271 None
272 }
273 })
274 .collect();
275
276 if !available_libraries.is_empty() {
277 let suggestions =
278 crate::search::fuzzy_find_libraries(library_name, &available_libraries);
279 if !suggestions.is_empty() {
280 let suggestion_text: Vec<String> =
281 suggestions.iter().map(|(name, _)| name.clone()).collect();
282 anyhow::bail!(
283 "Library '{}' not found. Did you mean one of: {}?",
284 library_name,
285 suggestion_text.join(", ")
286 );
287 }
288 }
289
290 anyhow::bail!(
291 "No library ID found in response for '{}': {}",
292 library_name,
293 content
294 );
295 }
296 }
297
298 pub async fn get_documentation(&self, library_id: &str, topic: Option<&str>) -> Result<String> {
299 let mut params = json!({
300 "libraryId": library_id
301 });
302
303 params["query"] = json!(topic.unwrap_or_default());
304
305 let request = JsonRpcRequest {
307 jsonrpc: "2.0".to_string(),
308 method: "tools/call".to_string(),
309 params: json!({
310 "name": "query-docs",
311 "arguments": params
312 }),
313 id: 2,
314 };
315
316 let response = self.send_request(request).await?;
317
318 if let Some(error) = response.error {
319 anyhow::bail!("API error: {} (code: {})", error.message, error.code);
320 }
321
322 let result = response.result.context("No result in response")?;
323
324 let content = result
326 .get("content")
327 .and_then(|c| c.as_array())
328 .and_then(|arr| arr.first())
329 .and_then(|item| item.get("text"))
330 .and_then(|text| text.as_str())
331 .context("Failed to extract documentation from response")?;
332
333 log::debug!("{:?}", content);
334 Ok(content.to_string())
335 }
336
337 async fn send_request(&self, request: JsonRpcRequest) -> Result<JsonRpcResponse> {
338 let base_url = self.get_base_url();
339 let mut req = self
340 .client
341 .post(base_url)
342 .header("Accept", "application/json, text/event-stream")
343 .header("Content-Type", "application/json")
344 .json(&request);
345
346 if let Some(key) = &self.api_key {
347 req = req.header("CONTEXT7_API_KEY", key);
348 }
349
350 let response = req
351 .send()
352 .await
353 .context("Failed to send request to Context7")?;
354
355 if !response.status().is_success() {
356 let status = response.status();
357 let error_text = response
358 .text()
359 .await
360 .unwrap_or_else(|_| "Unknown error".to_string());
361 anyhow::bail!("HTTP {} error: {}", status, error_text);
362 }
363
364 let content_type = response
365 .headers()
366 .get("content-type")
367 .and_then(|v| v.to_str().ok())
368 .unwrap_or("");
369
370 if content_type.contains("text/event-stream") {
372 let text = response.text().await?;
374 log::debug!("SSE Response: {}", text);
375
376 if let Some(json_line) = text.lines().find(|line| line.starts_with("data: ")) {
378 let json_data = &json_line[6..]; serde_json::from_str(json_data).context("Failed to parse SSE JSON data")
380 } else {
381 anyhow::bail!("No JSON data found in SSE response");
382 }
383 } else {
384 response
386 .json::<JsonRpcResponse>()
387 .await
388 .context("Failed to parse JSON-RPC response")
389 }
390 }
391}