Skip to main content

scv_tools/builtin/
web.rs

1//! Web tools: `web_fetch` and the configured-backend `web_search`.
2//!
3//! `web_fetch` refuses loopback, private, link-local, and other non-public
4//! addresses unless the user allows them. Host names are resolved once by a
5//! checking resolver whose addresses are the only ones the client connects
6//! to, so a name cannot be rebound to a local address between the check and
7//! the connection. IP-literal URLs, including redirect targets, are checked
8//! before any request.
9
10use std::{
11    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
12    sync::Arc,
13    time::Duration,
14};
15
16use async_trait::async_trait;
17use futures_util::StreamExt;
18use reqwest::{
19    Url,
20    dns::{Addrs, Name, Resolve, Resolving},
21    redirect,
22};
23use scv_core::{
24    Tool, ToolContext, ToolError, ToolFailure, ToolOutput, ToolRegistry, ToolRisk, ToolSpec,
25};
26use serde::Deserialize;
27use serde_json::{Value, json};
28
29use crate::args::{bounded, parse_args};
30
31const USER_AGENT: &str = concat!(
32    "scv/",
33    env!("CARGO_PKG_VERSION"),
34    " (+https://github.com/PeiyuanQi/scv)"
35);
36const MAX_URL_BYTES: usize = 4096;
37const MAX_QUERY_BYTES: usize = 512;
38const MAX_SEARCH_RESPONSE_BYTES: usize = 1024 * 1024;
39const HTML_WIDTH: usize = 120;
40
41/// Web tool settings resolved from the user's configuration.
42#[derive(Debug, Clone)]
43pub struct WebToolsConfig {
44    pub fetch_max_bytes: usize,
45    pub fetch_timeout: Duration,
46    pub max_redirects: usize,
47    /// HTTPS hosts fetched without approval. `*.example.com` matches
48    /// subdomains of `example.com` but not the domain itself.
49    pub auto_approve_domains: Vec<String>,
50    pub allow_private_addresses: bool,
51    pub search: Option<SearchBackend>,
52    pub max_search_results: usize,
53    pub output_limit: usize,
54}
55
56/// A search service that SCV queries itself. Provider-hosted search is
57/// configured on the provider instead and needs no SCV tool.
58#[derive(Clone)]
59pub enum SearchBackend {
60    Searxng { url: String },
61    Brave { url: String, api_key: String },
62}
63
64impl std::fmt::Debug for SearchBackend {
65    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::Searxng { url } => formatter.debug_struct("Searxng").field("url", url).finish(),
68            Self::Brave { url, .. } => formatter
69                .debug_struct("Brave")
70                .field("url", url)
71                .field("api_key", &"[REDACTED]")
72                .finish(),
73        }
74    }
75}
76
77/// Registers `web_fetch`, and `web_search` when a search backend is configured.
78pub fn register(registry: &mut ToolRegistry, config: WebToolsConfig) -> Result<(), ToolError> {
79    let config = Arc::new(config);
80    let allow_private = config.allow_private_addresses;
81    registry.register(Arc::new(WebFetchTool {
82        config: Arc::clone(&config),
83        address_allowed: Arc::new(move |address: SocketAddr| {
84            allow_private || is_public(address.ip())
85        }),
86    }))?;
87    if let Some(backend) = config.search.clone() {
88        registry.register(Arc::new(WebSearchTool {
89            backend,
90            config: Arc::clone(&config),
91        }))?;
92    }
93    Ok(())
94}
95
96/// Decides whether the client may connect to an address. Production uses
97/// [`is_public`]; tests substitute a port-aware check for loopback servers.
98type AddressCheck = Arc<dyn Fn(SocketAddr) -> bool + Send + Sync>;
99
100struct WebFetchTool {
101    config: Arc<WebToolsConfig>,
102    address_allowed: AddressCheck,
103}
104
105#[derive(Deserialize)]
106#[serde(deny_unknown_fields)]
107struct FetchArgs {
108    url: String,
109    #[serde(default)]
110    offset: Option<usize>,
111}
112
113impl WebFetchTool {
114    fn parse_url(&self, value: &str) -> Result<Url, ToolError> {
115        if value.len() > MAX_URL_BYTES {
116            return Err(ToolError::invalid_arguments(format!(
117                "url exceeds {MAX_URL_BYTES} bytes"
118            )));
119        }
120        let url = Url::parse(value.trim())
121            .map_err(|error| ToolError::invalid_arguments(format!("invalid url: {error}")))?;
122        check_url_shape(&url)?;
123        Ok(url)
124    }
125
126    fn auto_approved(&self, url: &Url) -> bool {
127        url.scheme() == "https"
128            && url
129                .host_str()
130                .is_some_and(|host| domain_listed(&self.config.auto_approve_domains, host))
131    }
132}
133
134/// Only plain HTTP(S) URLs with a host and no embedded credentials.
135fn check_url_shape(url: &Url) -> Result<(), ToolError> {
136    if !matches!(url.scheme(), "http" | "https") {
137        return Err(ToolError::invalid_arguments(format!(
138            "web_fetch supports only http and https URLs, not {}",
139            url.scheme()
140        )));
141    }
142    if url.host_str().is_none_or(str::is_empty) {
143        return Err(ToolError::invalid_arguments("url has no host"));
144    }
145    if !url.username().is_empty() || url.password().is_some() {
146        return Err(ToolError::invalid_arguments(
147            "urls with embedded credentials are not allowed",
148        ));
149    }
150    Ok(())
151}
152
153/// The literal IP of a URL host, if it is one.
154fn host_ip(url: &Url) -> Option<IpAddr> {
155    let host = url.host_str()?;
156    host.trim_start_matches('[')
157        .trim_end_matches(']')
158        .parse()
159        .ok()
160}
161
162fn check_literal(url: &Url, allowed: &AddressCheck) -> Result<(), String> {
163    if let Some(ip) = host_ip(url) {
164        let port = url.port_or_known_default().unwrap_or(0);
165        if !allowed(SocketAddr::new(ip, port)) {
166            return Err(format!(
167                "{ip} is a loopback, private, or otherwise non-public address"
168            ));
169        }
170    }
171    Ok(())
172}
173
174/// Case-insensitive host match against the allowlist.
175pub(crate) fn domain_listed(domains: &[String], host: &str) -> bool {
176    let host = host.trim_end_matches('.').to_ascii_lowercase();
177    domains.iter().any(|entry| {
178        let entry = entry.trim_end_matches('.').to_ascii_lowercase();
179        match entry.strip_prefix("*.") {
180            Some(parent) => host
181                .strip_suffix(parent)
182                .is_some_and(|prefix| prefix.len() > 1 && prefix.ends_with('.')),
183            None => host == entry,
184        }
185    })
186}
187
188/// Whether an address is a routable public one: not loopback, private,
189/// link-local, shared (CGNAT), multicast, documentation, reserved, or an IPv6
190/// form that embeds such an IPv4 address.
191pub(crate) fn is_public(ip: IpAddr) -> bool {
192    match ip {
193        IpAddr::V4(ip) => is_public_v4(ip),
194        IpAddr::V6(ip) => is_public_v6(ip),
195    }
196}
197
198fn is_public_v4(ip: Ipv4Addr) -> bool {
199    let [a, b, c, _] = ip.octets();
200    !(ip.is_unspecified()
201        || ip.is_loopback()
202        || ip.is_private()
203        || ip.is_link_local()
204        || ip.is_broadcast()
205        || ip.is_multicast()
206        || ip.is_documentation()
207        || a == 0
208        || (a == 100 && (64..128).contains(&b))
209        || (a == 192 && b == 0 && c == 0)
210        || (a == 198 && (b == 18 || b == 19))
211        || a >= 240)
212}
213
214fn is_public_v6(ip: Ipv6Addr) -> bool {
215    let segments = ip.segments();
216    if let Some(v4) = ip.to_ipv4_mapped() {
217        return is_public_v4(v4);
218    }
219    // IPv4-compatible (deprecated) and NAT64 addresses embed an IPv4 address
220    // in their low 32 bits.
221    let embedded = Ipv4Addr::from(ip.to_bits() as u32);
222    if segments[..6] == [0; 6] || segments[..6] == [0x64, 0xff9b, 0, 0, 0, 0] {
223        return !ip.is_unspecified() && !ip.is_loopback() && is_public_v4(embedded);
224    }
225    // 6to4 embeds its IPv4 address in bits 16..48.
226    if segments[0] == 0x2002 {
227        let v4 = Ipv4Addr::new(
228            (segments[1] >> 8) as u8,
229            segments[1] as u8,
230            (segments[2] >> 8) as u8,
231            segments[2] as u8,
232        );
233        return is_public_v4(v4);
234    }
235    !(ip.is_unspecified()
236        || ip.is_loopback()
237        || ip.is_multicast()
238        || (segments[0] & 0xfe00) == 0xfc00 // unique local
239        || (segments[0] & 0xffc0) == 0xfe80 // link-local
240        || (segments[0] & 0xffc0) == 0xfec0 // site-local
241        || (segments[0] == 0x2001 && segments[1] == 0x0db8) // documentation
242        || (segments[0] == 0x2001 && segments[1] == 0)) // Teredo
243}
244
245/// Resolves a name and fails if any of its addresses is refused, so the
246/// client only ever connects to addresses that passed the check.
247struct CheckedResolver {
248    allowed: AddressCheck,
249}
250
251impl Resolve for CheckedResolver {
252    fn resolve(&self, name: Name) -> Resolving {
253        let allowed = Arc::clone(&self.allowed);
254        let host = name.as_str().to_owned();
255        Box::pin(async move {
256            let addresses = resolve_checked(&host, &allowed).await?;
257            Ok(Box::new(addresses.into_iter()) as Addrs)
258        })
259    }
260}
261
262async fn resolve_checked(
263    host: &str,
264    allowed: &AddressCheck,
265) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error + Send + Sync>> {
266    let addresses: Vec<SocketAddr> = tokio::net::lookup_host((host, 0)).await?.collect();
267    check_resolved(host, &addresses, allowed)?;
268    Ok(addresses)
269}
270
271/// Every resolved address must pass; one refused address refuses the name.
272fn check_resolved(
273    host: &str,
274    addresses: &[SocketAddr],
275    allowed: &AddressCheck,
276) -> Result<(), String> {
277    if addresses.is_empty() {
278        return Err(format!("{host} did not resolve to any address"));
279    }
280    if let Some(refused) = addresses.iter().find(|address| !allowed(**address)) {
281        return Err(format!(
282            "{host} resolves to {}, a loopback, private, or otherwise non-public address",
283            refused.ip()
284        ));
285    }
286    Ok(())
287}
288
289enum Body {
290    Html,
291    Text,
292}
293
294/// Classifies a response by its media type, sniffing only when none is given.
295fn classify(content_type: Option<&str>, bytes: &[u8]) -> Result<Body, String> {
296    let Some(media) = content_type.map(|value| {
297        value
298            .split(';')
299            .next()
300            .unwrap_or("")
301            .trim()
302            .to_ascii_lowercase()
303    }) else {
304        let head = String::from_utf8_lossy(&bytes[..bytes.len().min(1024)]).to_ascii_lowercase();
305        return if head.contains("<html") || head.contains("<!doctype html") {
306            Ok(Body::Html)
307        } else if std::str::from_utf8(bytes).is_ok()
308            || std::str::from_utf8(&bytes[..bytes.len().saturating_sub(4)]).is_ok()
309        {
310            Ok(Body::Text)
311        } else {
312            Err("the response has no content type and is not text".into())
313        };
314    };
315    if media == "text/html" || media == "application/xhtml+xml" {
316        return Ok(Body::Html);
317    }
318    let textual = media.starts_with("text/")
319        || media.ends_with("+json")
320        || media.ends_with("+xml")
321        || matches!(
322            media.as_str(),
323            "application/json"
324                | "application/xml"
325                | "application/javascript"
326                | "application/ecmascript"
327                | "application/x-javascript"
328                | "application/toml"
329                | "application/yaml"
330                | "application/x-yaml"
331                | "application/x-ndjson"
332                | "application/sql"
333                | "application/graphql"
334        );
335    if textual {
336        Ok(Body::Text)
337    } else {
338        Err(format!(
339            "web_fetch returns text only; {media} is not a text content type"
340        ))
341    }
342}
343
344/// One page of `text` starting at character `offset`, within `budget` bytes.
345/// Returns the page and the offset of the next page, if any.
346fn page(text: &str, offset: usize, budget: usize) -> (String, Option<usize>) {
347    let mut output = String::new();
348    for (taken, character) in text.chars().skip(offset).enumerate() {
349        if output.len() + character.len_utf8() > budget {
350            return (output, Some(offset + taken));
351        }
352        output.push(character);
353    }
354    (output, None)
355}
356
357fn error_chain(error: &reqwest::Error) -> String {
358    let mut message = error.to_string();
359    let mut source = std::error::Error::source(error);
360    while let Some(cause) = source {
361        let text = cause.to_string();
362        if !message.contains(&text) {
363            message.push_str(": ");
364            message.push_str(&text);
365        }
366        source = cause.source();
367    }
368    message
369}
370
371#[async_trait]
372impl Tool for WebFetchTool {
373    fn spec(&self) -> ToolSpec {
374        ToolSpec {
375            name: "web_fetch".into(),
376            description: format!(
377                "Fetch a public web page or HTTP API with a GET request and return it as readable text \
378                 (HTML is converted to text; JSON and plain text pass through). Use it to read \
379                 documentation, release notes, issues, or a URL the user gave, and to open results \
380                 from web search. Long pages are returned in parts: call again with the reported \
381                 offset. Only public addresses are reachable. HTTPS pages on {} are fetched without \
382                 approval; other hosts need approval because the URL is sent to that site.",
383                if self.config.auto_approve_domains.is_empty() {
384                    "no hosts".to_owned()
385                } else {
386                    self.config.auto_approve_domains.join(", ")
387                }
388            ),
389            parameters: json!({
390                "type":"object",
391                "properties":{
392                    "url":{"type":"string","description":"Absolute http or https URL"},
393                    "offset":{"type":"integer","minimum":0,"description":"Character offset of the part to return, from a previous call"}
394                },
395                "required":["url"],
396                "additionalProperties":false
397            }),
398        }
399    }
400
401    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
402        let args: FetchArgs = parse_args(arguments)?;
403        let url = self.parse_url(&args.url)?;
404        Ok(if self.auto_approved(&url) {
405            ToolRisk::ReadOnly
406        } else {
407            ToolRisk::Network
408        })
409    }
410
411    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
412        let args: FetchArgs = parse_args(arguments)?;
413        let url = self.parse_url(&args.url)?;
414        Ok(format!(
415            "Fetch {} with an HTTP GET (no cookies or credentials). The full URL is sent to {}.",
416            bounded(url.as_str(), 2000),
417            url.host_str().unwrap_or("the host")
418        ))
419    }
420
421    async fn execute(
422        &self,
423        arguments: Value,
424        context: ToolContext,
425    ) -> Result<ToolOutput, ToolError> {
426        let args: FetchArgs = parse_args(&arguments)?;
427        let url = self.parse_url(&args.url)?;
428        check_literal(&url, &self.address_allowed).map_err(ToolError::invalid_arguments)?;
429        let auto_approved = self.auto_approved(&url);
430        let max_redirects = self.config.max_redirects;
431        let domains = self.config.auto_approve_domains.clone();
432        let allowed = Arc::clone(&self.address_allowed);
433        let policy = redirect::Policy::custom(move |attempt| {
434            if attempt.previous().len() > max_redirects {
435                return attempt.error(format!("stopped after {max_redirects} redirects"));
436            }
437            let next = attempt.url().clone();
438            if let Err(error) = check_url_shape(&next) {
439                return attempt.error(error.message);
440            }
441            if let Err(error) = check_literal(&next, &allowed) {
442                return attempt.error(error);
443            }
444            if auto_approved
445                && !(next.scheme() == "https"
446                    && next
447                        .host_str()
448                        .is_some_and(|host| domain_listed(&domains, host)))
449            {
450                return attempt.error(format!(
451                    "redirected to {next}, outside the auto-approved hosts; call web_fetch with that URL to request approval"
452                ));
453            }
454            attempt.follow()
455        });
456        let client = reqwest::Client::builder()
457            .user_agent(USER_AGENT)
458            .timeout(self.config.fetch_timeout)
459            .connect_timeout(self.config.fetch_timeout.min(Duration::from_secs(10)))
460            .redirect(policy)
461            .referer(false)
462            .no_proxy()
463            .dns_resolver(Arc::new(CheckedResolver {
464                allowed: Arc::clone(&self.address_allowed),
465            }))
466            .build()
467            .map_err(|error| ToolError::failed(format!("create HTTP client: {error}")))?;
468        let request = client.get(url.clone()).header(
469            reqwest::header::ACCEPT,
470            "text/html,application/xhtml+xml,text/plain;q=0.9,application/json;q=0.9,*/*;q=0.5",
471        );
472        let response = tokio::select! {
473            result = request.send() => result.map_err(|error| ToolError::failed(format!("fetch {url}: {}", error_chain(&error))))?,
474            () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web fetch cancelled")),
475        };
476        let status = response.status();
477        let final_url = response.url().clone();
478        let content_type = response
479            .headers()
480            .get(reqwest::header::CONTENT_TYPE)
481            .and_then(|value| value.to_str().ok())
482            .map(str::to_owned);
483        // Refuse a declared binary type before downloading it.
484        if content_type.is_some()
485            && let Err(message) = classify(content_type.as_deref(), &[])
486        {
487            return Ok(ToolOutput::failed(
488                ToolFailure::Failed,
489                format!("URL: {final_url}\nStatus: {}\n{message}", status.as_u16()),
490            ));
491        }
492        let limit = self.config.fetch_max_bytes;
493        let mut stream = response.bytes_stream();
494        let mut bytes = Vec::with_capacity(limit.min(64 * 1024));
495        let mut download_truncated = false;
496        loop {
497            let chunk = tokio::select! {
498                chunk = stream.next() => chunk,
499                () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web fetch cancelled")),
500            };
501            let Some(chunk) = chunk else { break };
502            let chunk = chunk.map_err(|error| {
503                ToolError::failed(format!("read {final_url}: {}", error_chain(&error)))
504            })?;
505            let remaining = limit - bytes.len();
506            if chunk.len() > remaining {
507                bytes.extend_from_slice(&chunk[..remaining]);
508                download_truncated = true;
509                break;
510            }
511            bytes.extend_from_slice(&chunk);
512        }
513        let kind = match classify(content_type.as_deref(), &bytes) {
514            Ok(kind) => kind,
515            Err(message) => {
516                return Ok(ToolOutput::failed(
517                    ToolFailure::Failed,
518                    format!("URL: {final_url}\nStatus: {}\n{message}", status.as_u16()),
519                ));
520            }
521        };
522        let text = match kind {
523            Body::Text => String::from_utf8_lossy(&bytes).into_owned(),
524            Body::Html => tokio::task::spawn_blocking(move || {
525                html2text::from_read(bytes.as_slice(), HTML_WIDTH)
526                    .map_err(|error| ToolError::failed(format!("convert HTML: {error}")))
527            })
528            .await
529            .map_err(|error| {
530                ToolError::failed(format!("HTML conversion task failed: {error}"))
531            })??,
532        };
533        let offset = args.offset.unwrap_or(0);
534        let total = text.chars().count();
535        let header = format!(
536            "URL: {final_url}\nStatus: {}\nContent-Type: {}\nCharacters: {offset}-{{end}} of {total}{}\n\n",
537            status.as_u16(),
538            content_type.as_deref().unwrap_or("unknown"),
539            if download_truncated {
540                format!(" (download stopped at {limit} bytes)")
541            } else {
542                String::new()
543            }
544        );
545        let footer_reserve = 160;
546        let budget = self
547            .config
548            .output_limit
549            .saturating_sub(header.len() + footer_reserve)
550            .max(1);
551        let (body, next) = page(&text, offset, budget);
552        let end = offset + body.chars().count();
553        let mut content = header.replace("{end}", &end.to_string());
554        content.push_str(&body);
555        if let Some(next) = next {
556            content.push_str(&format!(
557                "\n\n[{} more characters; call web_fetch with offset={next} for the next part]",
558                total - next
559            ));
560        }
561        Ok(ToolOutput {
562            content,
563            failure: (status.is_client_error() || status.is_server_error())
564                .then_some(ToolFailure::Failed),
565            truncated: next.is_some() || download_truncated,
566        })
567    }
568}
569
570struct WebSearchTool {
571    backend: SearchBackend,
572    config: Arc<WebToolsConfig>,
573}
574
575#[derive(Deserialize)]
576#[serde(deny_unknown_fields)]
577struct SearchArgs {
578    query: String,
579    #[serde(default)]
580    count: Option<usize>,
581}
582
583impl WebSearchTool {
584    fn validate(&self, args: &SearchArgs) -> Result<(), ToolError> {
585        let query = args.query.trim();
586        if query.is_empty() {
587            return Err(ToolError::invalid_arguments("query must not be empty"));
588        }
589        if query.len() > MAX_QUERY_BYTES {
590            return Err(ToolError::invalid_arguments(format!(
591                "query exceeds {MAX_QUERY_BYTES} bytes"
592            )));
593        }
594        Ok(())
595    }
596
597    fn backend_name(&self) -> &'static str {
598        match self.backend {
599            SearchBackend::Searxng { .. } => "SearXNG",
600            SearchBackend::Brave { .. } => "Brave Search",
601        }
602    }
603}
604
605#[derive(Debug, PartialEq)]
606struct SearchResult {
607    title: String,
608    url: String,
609    snippet: String,
610}
611
612fn parse_results(backend: &SearchBackend, body: &Value) -> Result<Vec<SearchResult>, String> {
613    let (items, snippet_field) = match backend {
614        SearchBackend::Searxng { .. } => (body.get("results"), "content"),
615        SearchBackend::Brave { .. } => (
616            body.get("web").and_then(|web| web.get("results")),
617            "description",
618        ),
619    };
620    let Some(items) = items else {
621        return Ok(Vec::new());
622    };
623    let items = items
624        .as_array()
625        .ok_or_else(|| "search results are not a list".to_owned())?;
626    Ok(items
627        .iter()
628        .filter_map(|item| {
629            let url = item.get("url")?.as_str()?.to_owned();
630            let text = |field: &str| {
631                item.get(field)
632                    .and_then(Value::as_str)
633                    .map(strip_tags)
634                    .unwrap_or_default()
635            };
636            Some(SearchResult {
637                title: text("title"),
638                url,
639                snippet: text(snippet_field),
640            })
641        })
642        .collect())
643}
644
645/// Removes markup such as Brave's `<strong>` highlights from a snippet.
646fn strip_tags(value: &str) -> String {
647    let mut output = String::with_capacity(value.len());
648    let mut in_tag = false;
649    for character in value.chars() {
650        match character {
651            '<' => in_tag = true,
652            '>' if in_tag => in_tag = false,
653            _ if !in_tag => output.push(character),
654            _ => {}
655        }
656    }
657    output
658        .replace("&amp;", "&")
659        .replace("&lt;", "<")
660        .replace("&gt;", ">")
661        .replace("&quot;", "\"")
662        .replace("&#39;", "'")
663        .split_whitespace()
664        .collect::<Vec<_>>()
665        .join(" ")
666}
667
668fn format_results(query: &str, results: &[SearchResult], limit: usize) -> String {
669    if results.is_empty() {
670        return format!("No results for {query:?}.");
671    }
672    let mut output = format!("Results for {query:?}:\n");
673    for (index, result) in results.iter().enumerate() {
674        let entry = format!(
675            "\n{}. {}\n   {}\n   {}\n",
676            index + 1,
677            if result.title.is_empty() {
678                "(untitled)"
679            } else {
680                &result.title
681            },
682            result.url,
683            bounded(&result.snippet, 400)
684        );
685        if output.len() + entry.len() > limit {
686            break;
687        }
688        output.push_str(&entry);
689    }
690    output
691}
692
693#[async_trait]
694impl Tool for WebSearchTool {
695    fn spec(&self) -> ToolSpec {
696        ToolSpec {
697            name: "web_search".into(),
698            description: format!(
699                "Search the web with {} and return result titles, URLs, and snippets. Use it for \
700                 current facts, versions, documentation locations, and error messages, then open \
701                 the most relevant results with web_fetch.",
702                self.backend_name()
703            ),
704            parameters: json!({
705                "type":"object",
706                "properties":{
707                    "query":{"type":"string"},
708                    "count":{"type":"integer","minimum":1,"maximum":self.config.max_search_results,"description":"Number of results (default and maximum shown)"}
709                },
710                "required":["query"],
711                "additionalProperties":false
712            }),
713        }
714    }
715
716    // The query goes only to the search service the user configured, so it
717    // cannot carry data to a host the model chooses.
718    fn risk(&self, arguments: &Value) -> Result<ToolRisk, ToolError> {
719        let args: SearchArgs = parse_args(arguments)?;
720        self.validate(&args)?;
721        Ok(ToolRisk::ReadOnly)
722    }
723
724    fn approval_summary(&self, arguments: &Value) -> Result<String, ToolError> {
725        let args: SearchArgs = parse_args(arguments)?;
726        self.validate(&args)?;
727        Ok(format!(
728            "Search {} for {:?}",
729            self.backend_name(),
730            bounded(args.query.trim(), 500)
731        ))
732    }
733
734    async fn execute(
735        &self,
736        arguments: Value,
737        context: ToolContext,
738    ) -> Result<ToolOutput, ToolError> {
739        let args: SearchArgs = parse_args(&arguments)?;
740        self.validate(&args)?;
741        let query = args.query.trim().to_owned();
742        let count = args
743            .count
744            .unwrap_or(self.config.max_search_results)
745            .clamp(1, self.config.max_search_results);
746        let client = reqwest::Client::builder()
747            .user_agent(USER_AGENT)
748            .timeout(self.config.fetch_timeout)
749            .redirect(redirect::Policy::limited(3))
750            .build()
751            .map_err(|error| ToolError::failed(format!("create HTTP client: {error}")))?;
752        let request = match &self.backend {
753            SearchBackend::Searxng { url } => {
754                let endpoint = format!("{}/search", url.trim_end_matches('/'));
755                client
756                    .get(endpoint)
757                    .query(&[("q", query.as_str()), ("format", "json")])
758            }
759            SearchBackend::Brave { url, api_key } => client
760                .get(url)
761                .query(&[("q", query.as_str()), ("count", &count.to_string())])
762                .header(reqwest::header::ACCEPT, "application/json")
763                .header("X-Subscription-Token", api_key),
764        };
765        let response = tokio::select! {
766            result = request.send() => result.map_err(|error| ToolError::failed(format!("{} request failed: {}", self.backend_name(), error_chain(&error))))?,
767            () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web search cancelled")),
768        };
769        let status = response.status();
770        let mut stream = response.bytes_stream();
771        let mut bytes = Vec::new();
772        loop {
773            let chunk = tokio::select! {
774                chunk = stream.next() => chunk,
775                () = context.cancellation.cancelled() => return Err(ToolError::cancelled("web search cancelled")),
776            };
777            let Some(chunk) = chunk else { break };
778            let chunk = chunk.map_err(|error| {
779                ToolError::failed(format!(
780                    "{} response failed: {}",
781                    self.backend_name(),
782                    error_chain(&error)
783                ))
784            })?;
785            if bytes.len() + chunk.len() > MAX_SEARCH_RESPONSE_BYTES {
786                return Err(ToolError::limit(format!(
787                    "{} response exceeded {MAX_SEARCH_RESPONSE_BYTES} bytes",
788                    self.backend_name()
789                )));
790            }
791            bytes.extend_from_slice(&chunk);
792        }
793        if !status.is_success() {
794            let body = String::from_utf8_lossy(&bytes[..bytes.len().min(300)]).into_owned();
795            let hint = match (&self.backend, status.as_u16()) {
796                (SearchBackend::Searxng { .. }, 403) => {
797                    " (enable the json format under search.formats in SearXNG's settings.yml)"
798                }
799                (SearchBackend::Brave { .. }, 401 | 403 | 422) => {
800                    " (check the Brave Search API key)"
801                }
802                _ => "",
803            };
804            return Ok(ToolOutput::failed(
805                ToolFailure::Failed,
806                format!(
807                    "{} returned HTTP {}{hint}: {}",
808                    self.backend_name(),
809                    status.as_u16(),
810                    bounded(&body, 300)
811                ),
812            ));
813        }
814        let body: Value = serde_json::from_slice(&bytes).map_err(|error| {
815            ToolError::failed(format!(
816                "{} returned invalid JSON: {error}",
817                self.backend_name()
818            ))
819        })?;
820        let mut results = parse_results(&self.backend, &body).map_err(ToolError::failed)?;
821        results.truncate(count);
822        Ok(ToolOutput::success(format_results(
823            &query,
824            &results,
825            self.config.output_limit,
826        )))
827    }
828}
829
830#[cfg(test)]
831mod tests;