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