1use crate::tools::http_client::shared_http_client;
17use crate::tools::{AgentTool, AgentToolResult, ToolContext};
18use async_trait::async_trait;
19use serde::Deserialize;
20use serde_json::Value;
21use std::sync::OnceLock;
22use tokio::sync::oneshot;
23
24const API_BASE_URL: &str = "https://context7.com/api";
27const KEY_FILE_NAME: &str = "context7";
28
29fn api_base_url() -> &'static str {
31 static URL: OnceLock<String> = OnceLock::new();
34 URL.get_or_init(|| {
35 std::env::var("CONTEXT7_API_URL").unwrap_or_else(|_| API_BASE_URL.to_string())
36 })
37}
38
39static API_KEY: OnceLock<Option<String>> = OnceLock::new();
43
44fn client() -> &'static reqwest::Client {
46 shared_http_client()
47}
48
49fn api_key() -> &'static Option<String> {
55 API_KEY.get_or_init(|| {
56 if let Some(dir) = dirs::config_dir() {
58 let path = dir.join("oxi").join("keys").join(KEY_FILE_NAME);
59 if path.exists()
60 && let Ok(content) = std::fs::read_to_string(&path)
61 && let Some(line) = content.lines().next()
62 {
63 let key = line.trim().to_string();
64 if !key.is_empty() {
65 tracing::debug!("Context7: loaded API key from {}", path.display());
66 return Some(key);
67 }
68 }
69 }
70
71 if let Ok(key) = std::env::var("CONTEXT7_API_KEY")
73 && !key.is_empty()
74 {
75 tracing::debug!("Context7: loaded API key from CONTEXT7_API_KEY env var");
76 return Some(key);
77 }
78
79 tracing::debug!("Context7: no API key found (anonymous access)");
80 None
81 })
82}
83
84fn key_location_hint() -> String {
86 match dirs::config_dir() {
87 Some(_) => "~/.config/oxi/keys/context7 or CONTEXT7_API_KEY env var".to_string(),
88 None => "CONTEXT7_API_KEY env var".to_string(),
89 }
90}
91
92#[derive(Debug, Deserialize)]
95struct SearchResponse {
96 results: Vec<LibraryResult>,
97 error: Option<String>,
98}
99
100#[derive(Debug, Deserialize)]
101struct LibraryResult {
102 id: String,
103 title: String,
104 description: String,
105 total_snippets: Option<u64>,
106 benchmark_score: Option<u64>,
107 versions: Option<Vec<String>>,
108 trust_score: Option<f64>,
109}
110
111pub struct Context7ResolveLibraryIdTool;
115
116impl Default for Context7ResolveLibraryIdTool {
117 fn default() -> Self {
118 Self::new()
119 }
120}
121
122impl Context7ResolveLibraryIdTool {
123 pub fn new() -> Self {
125 Self
126 }
127}
128
129#[async_trait]
130impl AgentTool for Context7ResolveLibraryIdTool {
131 fn name(&self) -> &str {
132 "context7_resolve-library-id"
133 }
134
135 fn label(&self) -> &str {
136 "Context7: Resolve Library ID"
137 }
138
139 fn description(&self) -> &str {
140 "Resolves a package/product name to a Context7-compatible library ID and returns matching libraries.\n\n\
141 You MUST call this function before 'Query Documentation' tool to obtain a valid Context7-compatible library ID UNLESS the user explicitly provides a library ID in the format '/org/project' or '/org/project/version' in their query.\n\n\
142 Each result includes:\n\
143 - Library ID: Context7-compatible identifier (format: /org/project)\n\
144 - Name: Library or package name\n\
145 - Description: Short summary\n\
146 - Code Snippets: Number of available code examples\n\
147 - Source Reputation: Authority indicator (High, Medium, Low, or Unknown)\n\
148 - Benchmark Score: Quality indicator (100 is the highest score)\n\
149 - Versions: List of versions if available. Use one of those versions if the user provides a version in their query. The format of the version is /org/project/version.\n\n\
150 For best results, select libraries based on name match, source reputation, snippet coverage, benchmark score, and relevance to your use case.\n\n\
151 Selection Process:\n\
152 1. Analyze the query to understand what library/package the user is looking for\n\
153 2. Return the most relevant match based on:\n\
154 - Name similarity to the query (exact matches prioritized)\n\
155 - Description relevance to the query's intent\n\
156 - Documentation coverage (prioritize libraries with higher Code Snippet counts)\n\
157 - Source reputation (consider libraries with High or Medium reputation more authoritative)\n\
158 - Benchmark Score: Quality indicator (100 is the highest score)\n\n\
159 IMPORTANT: Do not call this tool more than 3 times per question. If you cannot find what you need after 3 calls, use the best result you have."
160 }
161
162 fn parameters_schema(&self) -> Value {
163 serde_json::json!({
164 "type": "object",
165 "properties": {
166 "query": {
167 "type": "string",
168 "description": "The question or task you need help with. This is used to rank library results by relevance to what the user is trying to accomplish. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
169 },
170 "libraryName": {
171 "type": "string",
172 "description": "Library name to search for and retrieve a Context7-compatible library ID. Use the official library name with proper punctuation — e.g. 'Next.js' instead of 'nextjs', 'Customer.io' instead of 'customerio', 'Three.js' instead of 'threejs'."
173 }
174 },
175 "required": ["query", "libraryName"],
176 "additionalProperties": false
177 })
178 }
179
180 async fn execute(
181 &self,
182 _tool_call_id: &str,
183 params: Value,
184 _signal: Option<oneshot::Receiver<()>>,
185 _ctx: &ToolContext,
186 ) -> Result<AgentToolResult, String> {
187 let query = params
188 .get("query")
189 .and_then(|v| v.as_str())
190 .ok_or("Missing required parameter: query")?;
191 let library_name = params
192 .get("libraryName")
193 .and_then(|v| v.as_str())
194 .ok_or("Missing required parameter: libraryName")?;
195
196 let mut request = client()
197 .get(format!("{}/v2/libs/search", api_base_url()))
198 .query(&[("query", query), ("libraryName", library_name)]);
199
200 if let Some(ref key) = *api_key() {
201 request = request.bearer_auth(key);
202 }
203
204 let response = request
205 .send()
206 .await
207 .map_err(|e| format!("Context7 API request failed: {}", e))?;
208
209 if !response.status().is_success() {
210 return Ok(map_error(response).await);
211 }
212
213 let search: SearchResponse = response
214 .json()
215 .await
216 .map_err(|e| format!("Failed to parse Context7 response: {}", e))?;
217
218 if let Some(error) = search.error {
219 return Ok(AgentToolResult::error(error));
220 }
221
222 if search.results.is_empty() {
223 return Ok(AgentToolResult::success(format!(
224 "No libraries found matching \"{}\". Try a different search term.",
225 library_name
226 )));
227 }
228
229 Ok(AgentToolResult::success(format_search_results(
230 &search.results,
231 )))
232 }
233}
234
235pub struct Context7QueryDocsTool;
239
240impl Default for Context7QueryDocsTool {
241 fn default() -> Self {
242 Self::new()
243 }
244}
245
246impl Context7QueryDocsTool {
247 pub fn new() -> Self {
249 Self
250 }
251}
252
253#[async_trait]
254impl AgentTool for Context7QueryDocsTool {
255 fn name(&self) -> &str {
256 "context7_query-docs"
257 }
258
259 fn label(&self) -> &str {
260 "Context7: Query Documentation"
261 }
262
263 fn description(&self) -> &str {
264 "Retrieves and queries up-to-date documentation and code examples from Context7 for any programming library or framework.\n\n\
265 You must call 'Resolve Context7 Library ID' tool first to obtain the exact Context7-compatible library ID required to use this tool, UNLESS the user explicitly provides a library ID in the format '/org/project' or '/org/project/version' in their query.\n\n\
266 Workflow: call first without researchMode. If that doesn't answer the question, retry with researchMode: true. Do not call each tool more than 3 times per question"
267 }
268
269 fn parameters_schema(&self) -> Value {
270 serde_json::json!({
271 "type": "object",
272 "properties": {
273 "libraryId": {
274 "type": "string",
275 "description": "Exact Context7-compatible library ID (e.g. '/mongodb/docs', '/vercel/next.js', '/supabase/supabase', '/vercel/next.js/v14.3.0-canary.87') retrieved from 'resolve-library-id' or directly from user query in the format '/org/project' or '/org/project/version'."
276 },
277 "query": {
278 "type": "string",
279 "description": "The question or task you need help with. Be specific and include relevant details. Good: 'How to set up authentication with JWT in Express.js' or 'React useEffect cleanup function examples'. Bad: 'auth' or 'hooks'. The query is sent to the Context7 API for processing. Do not include any sensitive or confidential information such as API keys, passwords, credentials, personal data, or proprietary code in your query."
280 },
281 "researchMode": {
282 "type": "boolean",
283 "description": "Retry the query with deep research: spins up sandboxed agents that read the actual source repos and runs a live web search, then synthesizes a fresh answer. Set true on retry if you weren't satisfied with the first answer and want a more thorough one. Requires an API key — you can get one free at https://context7.com/."
284 }
285 },
286 "required": ["libraryId", "query"],
287 "additionalProperties": false
288 })
289 }
290
291 async fn execute(
292 &self,
293 _tool_call_id: &str,
294 params: Value,
295 _signal: Option<oneshot::Receiver<()>>,
296 _ctx: &ToolContext,
297 ) -> Result<AgentToolResult, String> {
298 let library_id = params
299 .get("libraryId")
300 .and_then(|v| v.as_str())
301 .ok_or("Missing required parameter: libraryId")?;
302 let query = params
303 .get("query")
304 .and_then(|v| v.as_str())
305 .ok_or("Missing required parameter: query")?;
306 let research_mode = params
307 .get("researchMode")
308 .and_then(|v| v.as_bool())
309 .unwrap_or(false);
310
311 let mut request = client()
312 .get(format!("{}/v2/context", api_base_url()))
313 .query(&[("query", query), ("libraryId", library_id)]);
314
315 if let Some(ref key) = *api_key() {
316 request = request.bearer_auth(key);
317 }
318
319 if research_mode {
320 request = request.query(&[("researchMode", "true")]);
321 }
322
323 let response = request
324 .send()
325 .await
326 .map_err(|e| format!("Context7 API request failed: {}", e))?;
327
328 if !response.status().is_success() {
329 return Ok(map_error(response).await);
330 }
331
332 let text = response
333 .text()
334 .await
335 .map_err(|e| format!("Failed to read Context7 response: {}", e))?;
336
337 if text.is_empty() {
338 return Ok(AgentToolResult::success(format!(
339 "No documentation found for library \"{}\". \
340 This might be because the library ID is invalid. \
341 Use context7_resolve-library-id to get a valid ID.",
342 library_id
343 )));
344 }
345
346 Ok(AgentToolResult::success(text))
347 }
348}
349
350async fn map_error(response: reqwest::Response) -> AgentToolResult {
354 let status = response.status();
355 let body = response.text().await.unwrap_or_default();
356 let hint = key_location_hint();
357
358 let msg = match status.as_u16() {
359 429 => format!(
360 "Rate limited or quota exceeded. Add an API key for higher limits: {}",
361 hint
362 ),
363 401 => format!("Invalid API key. Check your key at: {}", hint),
364 404 => "Library not found. Use context7_resolve-library-id to get a valid ID.".to_string(),
365 _ => format!(
366 "Context7 API error ({}): {}",
367 status,
368 body.chars().take(200).collect::<String>()
369 ),
370 };
371
372 AgentToolResult::error(msg)
373}
374
375fn format_search_results(results: &[LibraryResult]) -> String {
377 let mut text = String::from("Available Libraries:\n\n");
378 for lib in results {
379 text.push_str(&format!("**{}**\n", lib.title));
380 text.push_str(&format!(" Library ID: {}\n", lib.id));
381 if let Some(snippets) = lib.total_snippets {
382 text.push_str(&format!(" Code Snippets: {}\n", snippets));
383 }
384 if let Some(score) = lib.benchmark_score {
385 text.push_str(&format!(" Benchmark Score: {}/100\n", score));
386 }
387 if let Some(trust) = lib.trust_score {
388 let label = if trust >= 0.8 {
389 "High"
390 } else if trust >= 0.5 {
391 "Medium"
392 } else {
393 "Low"
394 };
395 text.push_str(&format!(" Source Reputation: {}\n", label));
396 }
397 if let Some(ref versions) = lib.versions
398 && !versions.is_empty()
399 {
400 text.push_str(&format!(" Versions: {}\n", versions.join(", ")));
401 }
402 text.push_str(&format!(" {}\n\n", lib.description));
403 }
404 text.trim_end().to_string()
405}