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 if let Ok(content) = std::fs::read_to_string(&path) {
61 if let Some(line) = content.lines().next() {
62 let key = line.trim().to_string();
63 if !key.is_empty() {
64 tracing::debug!("Context7: loaded API key from {}", path.display());
65 return Some(key);
66 }
67 }
68 }
69 }
70 }
71
72 if let Ok(key) = std::env::var("CONTEXT7_API_KEY") {
74 if !key.is_empty() {
75 tracing::debug!("Context7: loaded API key from CONTEXT7_API_KEY env var");
76 return Some(key);
77 }
78 }
79
80 tracing::debug!("Context7: no API key found (anonymous access)");
81 None
82 })
83}
84
85fn key_location_hint() -> String {
87 match dirs::config_dir() {
88 Some(_) => "~/.config/oxi/keys/context7 or CONTEXT7_API_KEY env var".to_string(),
89 None => "CONTEXT7_API_KEY env var".to_string(),
90 }
91}
92
93#[derive(Debug, Deserialize)]
96struct SearchResponse {
97 results: Vec<LibraryResult>,
98 error: Option<String>,
99}
100
101#[derive(Debug, Deserialize)]
102struct LibraryResult {
103 id: String,
104 title: String,
105 description: String,
106 total_snippets: Option<u64>,
107 benchmark_score: Option<u64>,
108 versions: Option<Vec<String>>,
109 trust_score: Option<f64>,
110}
111
112pub struct Context7ResolveLibraryIdTool;
116
117impl Default for Context7ResolveLibraryIdTool {
118 fn default() -> Self {
119 Self::new()
120 }
121}
122
123impl Context7ResolveLibraryIdTool {
124 pub fn new() -> Self {
126 Self
127 }
128}
129
130#[async_trait]
131impl AgentTool for Context7ResolveLibraryIdTool {
132 fn name(&self) -> &str {
133 "context7_resolve-library-id"
134 }
135
136 fn label(&self) -> &str {
137 "Context7: Resolve Library ID"
138 }
139
140 fn description(&self) -> &str {
141 "Resolves a package/product name to a Context7-compatible library ID and returns matching libraries.\n\n\
142 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\
143 Each result includes:\n\
144 - Library ID: Context7-compatible identifier (format: /org/project)\n\
145 - Name: Library or package name\n\
146 - Description: Short summary\n\
147 - Code Snippets: Number of available code examples\n\
148 - Source Reputation: Authority indicator (High, Medium, Low, or Unknown)\n\
149 - Benchmark Score: Quality indicator (100 is the highest score)\n\
150 - 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\
151 For best results, select libraries based on name match, source reputation, snippet coverage, benchmark score, and relevance to your use case.\n\n\
152 Selection Process:\n\
153 1. Analyze the query to understand what library/package the user is looking for\n\
154 2. Return the most relevant match based on:\n\
155 - Name similarity to the query (exact matches prioritized)\n\
156 - Description relevance to the query's intent\n\
157 - Documentation coverage (prioritize libraries with higher Code Snippet counts)\n\
158 - Source reputation (consider libraries with High or Medium reputation more authoritative)\n\
159 - Benchmark Score: Quality indicator (100 is the highest score)\n\n\
160 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."
161 }
162
163 fn parameters_schema(&self) -> Value {
164 serde_json::json!({
165 "type": "object",
166 "properties": {
167 "query": {
168 "type": "string",
169 "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."
170 },
171 "libraryName": {
172 "type": "string",
173 "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'."
174 }
175 },
176 "required": ["query", "libraryName"],
177 "additionalProperties": false
178 })
179 }
180
181 async fn execute(
182 &self,
183 _tool_call_id: &str,
184 params: Value,
185 _signal: Option<oneshot::Receiver<()>>,
186 _ctx: &ToolContext,
187 ) -> Result<AgentToolResult, String> {
188 let query = params
189 .get("query")
190 .and_then(|v| v.as_str())
191 .ok_or("Missing required parameter: query")?;
192 let library_name = params
193 .get("libraryName")
194 .and_then(|v| v.as_str())
195 .ok_or("Missing required parameter: libraryName")?;
196
197 let mut request = client()
198 .get(format!("{}/v2/libs/search", api_base_url()))
199 .query(&[("query", query), ("libraryName", library_name)]);
200
201 if let Some(ref key) = *api_key() {
202 request = request.bearer_auth(key);
203 }
204
205 let response = request
206 .send()
207 .await
208 .map_err(|e| format!("Context7 API request failed: {}", e))?;
209
210 if !response.status().is_success() {
211 return Ok(map_error(response).await);
212 }
213
214 let search: SearchResponse = response
215 .json()
216 .await
217 .map_err(|e| format!("Failed to parse Context7 response: {}", e))?;
218
219 if let Some(error) = search.error {
220 return Ok(AgentToolResult::error(error));
221 }
222
223 if search.results.is_empty() {
224 return Ok(AgentToolResult::success(format!(
225 "No libraries found matching \"{}\". Try a different search term.",
226 library_name
227 )));
228 }
229
230 Ok(AgentToolResult::success(format_search_results(
231 &search.results,
232 )))
233 }
234}
235
236pub struct Context7QueryDocsTool;
240
241impl Default for Context7QueryDocsTool {
242 fn default() -> Self {
243 Self::new()
244 }
245}
246
247impl Context7QueryDocsTool {
248 pub fn new() -> Self {
250 Self
251 }
252}
253
254#[async_trait]
255impl AgentTool for Context7QueryDocsTool {
256 fn name(&self) -> &str {
257 "context7_query-docs"
258 }
259
260 fn label(&self) -> &str {
261 "Context7: Query Documentation"
262 }
263
264 fn description(&self) -> &str {
265 "Retrieves and queries up-to-date documentation and code examples from Context7 for any programming library or framework.\n\n\
266 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\
267 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"
268 }
269
270 fn parameters_schema(&self) -> Value {
271 serde_json::json!({
272 "type": "object",
273 "properties": {
274 "libraryId": {
275 "type": "string",
276 "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'."
277 },
278 "query": {
279 "type": "string",
280 "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."
281 },
282 "researchMode": {
283 "type": "boolean",
284 "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/."
285 }
286 },
287 "required": ["libraryId", "query"],
288 "additionalProperties": false
289 })
290 }
291
292 async fn execute(
293 &self,
294 _tool_call_id: &str,
295 params: Value,
296 _signal: Option<oneshot::Receiver<()>>,
297 _ctx: &ToolContext,
298 ) -> Result<AgentToolResult, String> {
299 let library_id = params
300 .get("libraryId")
301 .and_then(|v| v.as_str())
302 .ok_or("Missing required parameter: libraryId")?;
303 let query = params
304 .get("query")
305 .and_then(|v| v.as_str())
306 .ok_or("Missing required parameter: query")?;
307 let research_mode = params
308 .get("researchMode")
309 .and_then(|v| v.as_bool())
310 .unwrap_or(false);
311
312 let mut request = client()
313 .get(format!("{}/v2/context", api_base_url()))
314 .query(&[("query", query), ("libraryId", library_id)]);
315
316 if let Some(ref key) = *api_key() {
317 request = request.bearer_auth(key);
318 }
319
320 if research_mode {
321 request = request.query(&[("researchMode", "true")]);
322 }
323
324 let response = request
325 .send()
326 .await
327 .map_err(|e| format!("Context7 API request failed: {}", e))?;
328
329 if !response.status().is_success() {
330 return Ok(map_error(response).await);
331 }
332
333 let text = response
334 .text()
335 .await
336 .map_err(|e| format!("Failed to read Context7 response: {}", e))?;
337
338 if text.is_empty() {
339 return Ok(AgentToolResult::success(format!(
340 "No documentation found for library \"{}\". \
341 This might be because the library ID is invalid. \
342 Use context7_resolve-library-id to get a valid ID.",
343 library_id
344 )));
345 }
346
347 Ok(AgentToolResult::success(text))
348 }
349}
350
351async fn map_error(response: reqwest::Response) -> AgentToolResult {
355 let status = response.status();
356 let body = response.text().await.unwrap_or_default();
357 let hint = key_location_hint();
358
359 let msg = match status.as_u16() {
360 429 => format!(
361 "Rate limited or quota exceeded. Add an API key for higher limits: {}",
362 hint
363 ),
364 401 => format!("Invalid API key. Check your key at: {}", hint),
365 404 => "Library not found. Use context7_resolve-library-id to get a valid ID.".to_string(),
366 _ => format!(
367 "Context7 API error ({}): {}",
368 status,
369 body.chars().take(200).collect::<String>()
370 ),
371 };
372
373 AgentToolResult::error(msg)
374}
375
376fn format_search_results(results: &[LibraryResult]) -> String {
378 let mut text = String::from("Available Libraries:\n\n");
379 for lib in results {
380 text.push_str(&format!("**{}**\n", lib.title));
381 text.push_str(&format!(" Library ID: {}\n", lib.id));
382 if let Some(snippets) = lib.total_snippets {
383 text.push_str(&format!(" Code Snippets: {}\n", snippets));
384 }
385 if let Some(score) = lib.benchmark_score {
386 text.push_str(&format!(" Benchmark Score: {}/100\n", score));
387 }
388 if let Some(trust) = lib.trust_score {
389 let label = if trust >= 0.8 {
390 "High"
391 } else if trust >= 0.5 {
392 "Medium"
393 } else {
394 "Low"
395 };
396 text.push_str(&format!(" Source Reputation: {}\n", label));
397 }
398 if let Some(ref versions) = lib.versions {
399 if !versions.is_empty() {
400 text.push_str(&format!(" Versions: {}\n", versions.join(", ")));
401 }
402 }
403 text.push_str(&format!(" {}\n\n", lib.description));
404 }
405 text.trim_end().to_string()
406}