Skip to main content

oxi_agent/tools/
context7.rs

1//! Context7 documentation tools.
2//!
3//! Built-in tools that call the Context7 API directly (no MCP).
4//! - `context7_resolve-library-id`: Search for libraries and get a Context7-compatible ID
5//! - `context7_query-docs`: Fetch up-to-date documentation for a library
6//!
7//! API reference: <https://context7.com/api>
8//!
9//! # API key resolution
10//!
11//! 1. `~/.config/oxi/keys/context7` — plain text file containing the key (one line)
12//! 2. `CONTEXT7_API_KEY` environment variable (fallback)
13//!
14//! Anonymous access works without a key but has lower rate limits.
15
16use 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
26// ── Constants ────────────────────────────────────────────────────────
27
28const API_BASE_URL: &str = "https://context7.com/api";
29const KEY_FILE_NAME: &str = "context7";
30
31/// Resolve the API base URL. Supports self-hosted Context7 via env var.
32fn api_base_url() -> &'static str {
33    // Compile-time default; env var checked at runtime in api_key()
34    // Self-hosted users can set CONTEXT7_API_URL.
35    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
41// ── Shared state (process-lifetime singletons) ───────────────────────
42
43/// Process-lifetime cached API key. Loaded once from file or env.
44static API_KEY: OnceLock<Option<String>> = OnceLock::new();
45
46/// Get the shared reqwest client.
47fn client() -> &'static reqwest::Client {
48    shared_http_client()
49}
50
51/// Get or initialise the API key.
52///
53/// Resolution order:
54/// 1. `~/.config/oxi/keys/context7` file (first non-empty line)
55/// 2. `CONTEXT7_API_KEY` environment variable
56fn api_key() -> &'static Option<String> {
57    API_KEY.get_or_init(|| {
58        // 1. File
59        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        // 2. Env var fallback
74        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
86/// Where the user should put their key (for error messages).
87fn 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// ── API types ────────────────────────────────────────────────────────
95
96#[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/// Arguments for the resolve-library-id sub-tool.
114#[derive(Deserialize, JsonSchema)]
115pub struct Context7ResolveLibraryIdArgs {
116    query: String,
117    #[serde(rename = "libraryName")]
118    library_name: String,
119}
120
121// ── Tool 1: resolve-library-id ───────────────────────────────────────
122
123/// Resolve a library name to a Context7-compatible library ID.
124pub struct Context7ResolveLibraryIdTool;
125
126impl Default for Context7ResolveLibraryIdTool {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132impl Context7ResolveLibraryIdTool {
133    /// Create a new instance.
134    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// ── Tool 2: query-docs ──────────────────────────────────────────────
267
268/// Arguments for the query-docs sub-tool.
269#[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
278/// Query up-to-date documentation from Context7.
279pub struct Context7QueryDocsTool;
280
281impl Default for Context7QueryDocsTool {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl Context7QueryDocsTool {
288    /// Create a new instance.
289    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
364/// Format library search results into human-readable text.
365fn 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}