Skip to main content

fetchkit/
tool.rs

1//! Tool builder and toolkit-library contract for FetchKit.
2//
3// DECISION: keep the legacy typed `execute`/`llmtxt` surface as wrappers around the
4// toolkit-library contract so existing fetchkit callers can migrate incrementally.
5
6#[cfg(feature = "bot-auth")]
7use crate::bot_auth::BotAuthConfig;
8use crate::client::{fetch_with_options, FetchOptions};
9use crate::dns::DnsPolicy;
10use crate::error::{FetchError, ToolError};
11use crate::fetchers::FetcherRegistry;
12use crate::file_saver::FileSaver;
13use crate::transport::HttpTransport;
14use crate::types::{FetchRequest, FetchResponse};
15use futures::future::BoxFuture;
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Map, Value};
18use std::sync::Arc;
19use std::task::{Context, Poll};
20use std::time::Instant;
21use tower::Service;
22
23const DEFAULT_LOCALE: &str = "en-US";
24const TOOL_NAME: &str = "web_fetch";
25
26/// Status update during tool execution
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ToolStatus {
29    /// Current phase (e.g., "validate", "connect", "fetch", "convert")
30    pub phase: String,
31    /// Optional message
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub message: Option<String>,
34    /// Estimated completion percentage (0-100)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub percent_complete: Option<f32>,
37    /// Estimated time remaining in milliseconds
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub eta_ms: Option<u64>,
40}
41
42impl ToolStatus {
43    /// Create a new status with phase
44    pub fn new(phase: impl Into<String>) -> Self {
45        Self {
46            phase: phase.into(),
47            message: None,
48            percent_complete: None,
49            eta_ms: None,
50        }
51    }
52
53    /// Set message
54    pub fn with_message(mut self, message: impl Into<String>) -> Self {
55        self.message = Some(message.into());
56        self
57    }
58
59    /// Set completion percentage
60    pub fn with_percent(mut self, percent: f32) -> Self {
61        self.percent_complete = Some(percent);
62        self
63    }
64
65    /// Set ETA
66    pub fn with_eta(mut self, eta_ms: u64) -> Self {
67        self.eta_ms = Some(eta_ms);
68        self
69    }
70}
71
72/// Output image returned by the toolkit-library contract.
73#[derive(Debug, Clone, Default, PartialEq, Eq)]
74pub struct ToolImage {
75    pub base64: String,
76    pub media_type: String,
77}
78
79/// Consumer-only metadata returned by the toolkit-library contract.
80#[derive(Debug, Clone)]
81pub struct ToolOutputMetadata {
82    pub duration: std::time::Duration,
83    pub extra: Value,
84}
85
86impl Default for ToolOutputMetadata {
87    fn default() -> Self {
88        Self {
89            duration: std::time::Duration::default(),
90            extra: json!({}),
91        }
92    }
93}
94
95/// Structured tool output for the toolkit-library contract.
96#[derive(Debug, Clone, Default)]
97pub struct ToolOutput {
98    pub result: Value,
99    pub images: Vec<ToolImage>,
100    pub metadata: ToolOutputMetadata,
101}
102
103/// Builder for configuring the FetchKit tool
104///
105/// # Examples
106///
107/// ```
108/// use fetchkit::ToolBuilder;
109///
110/// let tool = ToolBuilder::new()
111///     .enable_markdown(true)
112///     .enable_text(false)
113///     .user_agent("MyBot/1.0")
114///     .allow_prefix("https://docs.example.com")
115///     .block_prefix("https://internal.example.com")
116///     .build();
117///
118/// assert_eq!(tool.name(), "web_fetch");
119/// ```
120#[derive(Clone, Default)]
121pub struct ToolBuilder {
122    locale: String,
123    /// Enable as_markdown option
124    enable_markdown: bool,
125    /// Enable as_text option
126    enable_text: bool,
127    /// Custom User-Agent
128    user_agent: Option<String>,
129    /// Allow list of URL prefixes
130    allow_prefixes: Vec<String>,
131    /// Block list of URL prefixes
132    block_prefixes: Vec<String>,
133    /// DNS resolution policy for SSRF prevention
134    dns_policy: DnsPolicy,
135    /// Maximum response body size in bytes
136    max_body_size: Option<usize>,
137    /// Enable save_to_file parameter (opt-in)
138    enable_save_to_file: bool,
139    /// Whether to honor proxy environment variables
140    respect_proxy_env: bool,
141    /// Restrict outbound requests to these ports. Empty means any port.
142    allowed_ports: Vec<u16>,
143    /// Block exact hosts and suffix rules. Leading '.' means suffix match.
144    blocked_hosts: Vec<String>,
145    /// Restrict redirects to the original host only.
146    same_host_redirects_only: bool,
147    /// Web Bot Authentication config.
148    #[cfg(feature = "bot-auth")]
149    bot_auth: Option<BotAuthConfig>,
150    /// Pluggable HTTP transport. None => default ReqwestTransport.
151    transport: Option<Arc<dyn HttpTransport>>,
152}
153
154// Manual Debug: `transport` is a trait object that does not implement Debug
155// (same pattern as FetchOptions).
156impl std::fmt::Debug for ToolBuilder {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        let mut d = f.debug_struct("ToolBuilder");
159        d.field("locale", &self.locale)
160            .field("enable_markdown", &self.enable_markdown)
161            .field("enable_text", &self.enable_text)
162            .field("user_agent", &self.user_agent)
163            .field("allow_prefixes", &self.allow_prefixes)
164            .field("block_prefixes", &self.block_prefixes)
165            .field("dns_policy", &self.dns_policy)
166            .field("max_body_size", &self.max_body_size)
167            .field("enable_save_to_file", &self.enable_save_to_file)
168            .field("respect_proxy_env", &self.respect_proxy_env)
169            .field("allowed_ports", &self.allowed_ports)
170            .field("blocked_hosts", &self.blocked_hosts)
171            .field("same_host_redirects_only", &self.same_host_redirects_only);
172        #[cfg(feature = "bot-auth")]
173        d.field("bot_auth", &self.bot_auth);
174        d.field(
175            "transport",
176            &self
177                .transport
178                .as_ref()
179                .map(|_| "<custom>")
180                .unwrap_or("None"),
181        );
182        d.finish()
183    }
184}
185
186impl ToolBuilder {
187    /// Create a new tool builder with all options enabled
188    pub fn new() -> Self {
189        Self {
190            locale: DEFAULT_LOCALE.to_string(),
191            enable_markdown: true,
192            enable_text: true,
193            ..Default::default()
194        }
195    }
196
197    /// Set the locale for user-facing metadata and tool errors.
198    pub fn locale(mut self, locale: &str) -> Self {
199        self.locale = normalize_locale(locale);
200        self
201    }
202
203    /// Enable as_markdown option
204    pub fn enable_markdown(mut self, enable: bool) -> Self {
205        self.enable_markdown = enable;
206        self
207    }
208
209    /// Enable as_text option
210    pub fn enable_text(mut self, enable: bool) -> Self {
211        self.enable_text = enable;
212        self
213    }
214
215    /// Set custom User-Agent
216    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
217        self.user_agent = Some(ua.into());
218        self
219    }
220
221    /// Add URL prefix to allow list
222    pub fn allow_prefix(mut self, prefix: impl Into<String>) -> Self {
223        self.allow_prefixes.push(prefix.into());
224        self
225    }
226
227    /// Add URL prefix to block list
228    pub fn block_prefix(mut self, prefix: impl Into<String>) -> Self {
229        self.block_prefixes.push(prefix.into());
230        self
231    }
232
233    /// Set maximum response body size in bytes
234    ///
235    /// Limits the amount of data read from responses. Protects against
236    /// memory exhaustion from large responses and compressed content bombs.
237    /// Default: 10 MB if not set.
238    pub fn max_body_size(mut self, size: usize) -> Self {
239        self.max_body_size = Some(size);
240        self
241    }
242
243    /// Enable file download (save_to_file parameter).
244    /// Disabled by default — opt-in only.
245    pub fn enable_save_to_file(mut self, enable: bool) -> Self {
246        self.enable_save_to_file = enable;
247        self
248    }
249
250    /// Allow outbound requests to a specific port.
251    ///
252    /// If no ports are configured, any URL port is allowed.
253    pub fn allow_port(mut self, port: u16) -> Self {
254        if !self.allowed_ports.contains(&port) {
255            self.allowed_ports.push(port);
256        }
257        self
258    }
259
260    /// Block an exact hostname before DNS resolution.
261    pub fn block_host(mut self, host: impl Into<String>) -> Self {
262        self.blocked_hosts.push(host.into());
263        self
264    }
265
266    /// Block a hostname suffix before DNS resolution.
267    ///
268    /// Suffixes should usually start with `.` such as `.cluster.local`.
269    pub fn block_host_suffix(mut self, suffix: impl Into<String>) -> Self {
270        let mut suffix = suffix.into();
271        if !suffix.starts_with('.') {
272            suffix.insert(0, '.');
273        }
274        self.blocked_hosts.push(suffix);
275        self
276    }
277
278    /// Restrict redirects to the original host only.
279    pub fn same_host_redirects_only(mut self, enable: bool) -> Self {
280        self.same_host_redirects_only = enable;
281        self
282    }
283
284    /// Restrict redirects to the original host only when the caller set a value.
285    pub fn same_host_redirects_only_if_set(mut self, enable: Option<bool>) -> Self {
286        if let Some(enable) = enable {
287            self.same_host_redirects_only = enable;
288        }
289        self
290    }
291
292    /// Control private/reserved IP range blocking (SSRF prevention)
293    ///
294    /// Enabled by default. When enabled, FetchKit resolves hostnames to IP
295    /// addresses before connecting and validates that the resolved IP is not
296    /// in a private or reserved range. DNS pinning prevents rebinding attacks.
297    ///
298    /// Pass `false` only for local development or testing against loopback
299    /// servers. In production, always leave this enabled.
300    pub fn block_private_ips(mut self, block: bool) -> Self {
301        self.dns_policy = if block {
302            DnsPolicy::block_private_ips()
303        } else {
304            DnsPolicy::allow_all()
305        };
306        self
307    }
308
309    /// Control whether reqwest inherits proxy configuration from the process environment.
310    ///
311    /// Disabled by default so shared runtimes do not accidentally route requests
312    /// through ambient HTTP(S)_PROXY configuration.
313    pub fn respect_proxy_env(mut self, respect: bool) -> Self {
314        self.respect_proxy_env = respect;
315        self
316    }
317
318    /// Alias for [`respect_proxy_env`](Self::respect_proxy_env).
319    pub fn use_env_proxy(mut self, enable: bool) -> Self {
320        self.respect_proxy_env = enable;
321        self
322    }
323
324    /// Set Web Bot Authentication config (draft-meunier-web-bot-auth-architecture).
325    ///
326    /// When configured, all outgoing requests are signed with Ed25519 per RFC 9421.
327    /// Origins can verify bot identity via the `Signature` and `Signature-Input` headers.
328    #[cfg(feature = "bot-auth")]
329    pub fn bot_auth(mut self, config: BotAuthConfig) -> Self {
330        self.bot_auth = Some(config);
331        self
332    }
333
334    /// Set a custom HTTP transport for all tool execution paths.
335    ///
336    /// A host application can supply its own [`HttpTransport`] to route fetchkit's
337    /// outbound HTTP through a dedicated egress boundary while keeping the Tool
338    /// surface (description, schemas, llmtxt, `execute`, `execute_with_saver`).
339    /// fetchkit still performs URL validation, DNS policy resolution, redirect
340    /// following, bot-auth signing, and body/timeout caps; only the socket-level
341    /// send is delegated. Default: the built-in `ReqwestTransport`.
342    pub fn transport(mut self, transport: Arc<dyn HttpTransport>) -> Self {
343        self.transport = Some(transport);
344        self
345    }
346
347    /// Apply a production-oriented hardening profile.
348    ///
349    /// This preset keeps private IP blocking enabled, ignores ambient proxy
350    /// environment variables, restricts outbound traffic to ports 80 and 443,
351    /// blocks common internal DNS suffixes, and only follows same-host redirects.
352    pub fn hardened(mut self) -> Self {
353        self = self
354            .block_private_ips(true)
355            .use_env_proxy(false)
356            .allow_port(80)
357            .allow_port(443)
358            .block_host("localhost")
359            .block_host_suffix(".local")
360            .block_host_suffix(".internal")
361            .block_host_suffix(".svc")
362            .block_host_suffix(".cluster.local")
363            .same_host_redirects_only(true);
364        self
365    }
366
367    /// Build the full tool metadata + execution factory.
368    pub fn build(&self) -> Tool {
369        Tool {
370            locale: self.locale.clone(),
371            display_name: display_name(&self.locale).to_string(),
372            description: description(&self.locale, self.enable_save_to_file),
373            version: env!("CARGO_PKG_VERSION").to_string(),
374            enable_markdown: self.enable_markdown,
375            enable_text: self.enable_text,
376            user_agent: self.user_agent.clone(),
377            allow_prefixes: self.allow_prefixes.clone(),
378            block_prefixes: self.block_prefixes.clone(),
379            dns_policy: self.dns_policy.clone(),
380            max_body_size: self.max_body_size,
381            enable_save_to_file: self.enable_save_to_file,
382            respect_proxy_env: self.respect_proxy_env,
383            allowed_ports: self.allowed_ports.clone(),
384            blocked_hosts: self.blocked_hosts.clone(),
385            same_host_redirects_only: self.same_host_redirects_only,
386            #[cfg(feature = "bot-auth")]
387            bot_auth: self.bot_auth.clone(),
388            transport: self.transport.clone(),
389        }
390    }
391
392    /// Build a `tower::Service` that accepts JSON args and returns JSON result.
393    pub fn build_service(&self) -> ToolService {
394        ToolService { tool: self.build() }
395    }
396
397    /// Alias for `build_service()` for generic executor-oriented consumers.
398    pub fn build_executor(&self) -> ToolService {
399        self.build_service()
400    }
401
402    /// Build an OpenAI-compatible function tool definition.
403    pub fn build_tool_definition(&self) -> Value {
404        let tool = self.build();
405        json!({
406            "type": "function",
407            "function": {
408                "name": tool.name(),
409                "description": tool.description(),
410                "parameters": tool.input_schema()
411            }
412        })
413    }
414
415    /// Build the JSON Schema for the tool's input parameters.
416    pub fn build_input_schema(&self) -> Value {
417        build_input_schema(
418            self.enable_markdown,
419            self.enable_text,
420            self.enable_save_to_file,
421        )
422    }
423
424    /// Build the JSON Schema for the tool's output.
425    pub fn build_output_schema(&self) -> Value {
426        build_output_schema()
427    }
428}
429
430/// Configured FetchKit tool
431///
432/// Created via [`ToolBuilder`]. Provides methods for executing fetch requests,
433/// retrieving schemas, and accessing tool metadata.
434#[derive(Clone)]
435pub struct Tool {
436    locale: String,
437    display_name: String,
438    description: String,
439    version: String,
440    enable_markdown: bool,
441    enable_text: bool,
442    user_agent: Option<String>,
443    allow_prefixes: Vec<String>,
444    block_prefixes: Vec<String>,
445    dns_policy: DnsPolicy,
446    max_body_size: Option<usize>,
447    enable_save_to_file: bool,
448    respect_proxy_env: bool,
449    allowed_ports: Vec<u16>,
450    blocked_hosts: Vec<String>,
451    same_host_redirects_only: bool,
452    #[cfg(feature = "bot-auth")]
453    bot_auth: Option<BotAuthConfig>,
454    transport: Option<Arc<dyn HttpTransport>>,
455}
456
457// Manual Debug: `transport` is a trait object that does not implement Debug
458// (same pattern as FetchOptions).
459impl std::fmt::Debug for Tool {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        let mut d = f.debug_struct("Tool");
462        d.field("locale", &self.locale)
463            .field("display_name", &self.display_name)
464            .field("description", &self.description)
465            .field("version", &self.version)
466            .field("enable_markdown", &self.enable_markdown)
467            .field("enable_text", &self.enable_text)
468            .field("user_agent", &self.user_agent)
469            .field("allow_prefixes", &self.allow_prefixes)
470            .field("block_prefixes", &self.block_prefixes)
471            .field("dns_policy", &self.dns_policy)
472            .field("max_body_size", &self.max_body_size)
473            .field("enable_save_to_file", &self.enable_save_to_file)
474            .field("respect_proxy_env", &self.respect_proxy_env)
475            .field("allowed_ports", &self.allowed_ports)
476            .field("blocked_hosts", &self.blocked_hosts)
477            .field("same_host_redirects_only", &self.same_host_redirects_only);
478        #[cfg(feature = "bot-auth")]
479        d.field("bot_auth", &self.bot_auth);
480        d.field(
481            "transport",
482            &self
483                .transport
484                .as_ref()
485                .map(|_| "<custom>")
486                .unwrap_or("None"),
487        );
488        d.finish()
489    }
490}
491
492impl Default for Tool {
493    fn default() -> Self {
494        ToolBuilder::new().build()
495    }
496}
497
498impl Tool {
499    /// Create a new tool builder
500    pub fn builder() -> ToolBuilder {
501        ToolBuilder::new()
502    }
503
504    /// Tool name for LLM invocation.
505    pub fn name(&self) -> &str {
506        TOOL_NAME
507    }
508
509    /// Human-readable display name.
510    pub fn display_name(&self) -> &str {
511        &self.display_name
512    }
513
514    /// Toolkit version.
515    pub fn version(&self) -> &str {
516        &self.version
517    }
518
519    /// Tool description, reflecting enabled features.
520    pub fn description(&self) -> &str {
521        &self.description
522    }
523
524    /// Tool locale.
525    pub fn locale(&self) -> &str {
526        &self.locale
527    }
528
529    /// Get system prompt contribution.
530    pub fn system_prompt(&self) -> String {
531        system_prompt(
532            &self.locale,
533            self.enable_save_to_file,
534            self.dns_policy.block_private,
535        )
536    }
537
538    /// Get comprehensive Markdown help.
539    pub fn help(&self) -> String {
540        build_help(self)
541    }
542
543    /// Backward-compatible alias for `help()`.
544    pub fn llmtxt(&self) -> String {
545        self.help()
546    }
547
548    /// Get input schema as JSON.
549    pub fn input_schema(&self) -> Value {
550        build_input_schema(
551            self.enable_markdown,
552            self.enable_text,
553            self.enable_save_to_file,
554        )
555    }
556
557    /// Get output schema as JSON.
558    pub fn output_schema(&self) -> Value {
559        build_output_schema()
560    }
561
562    /// Create a single-use tool execution from JSON arguments.
563    pub fn execution(&self, args: Value) -> Result<ToolExecution, ToolError> {
564        validate_args(self, &args)?;
565        let mut request: FetchRequest = serde_json::from_value(args)
566            .map_err(|err| invalid_arguments_error(self.locale(), &err.to_string()))?;
567        normalize_request(self, &mut request)?;
568        validate_request(self, &request)?;
569
570        Ok(ToolExecution {
571            tool: self.clone(),
572            request,
573        })
574    }
575
576    /// Execute the tool with the given typed request.
577    pub async fn execute(&self, req: FetchRequest) -> Result<FetchResponse, FetchError> {
578        fetch_with_options(req, self.build_options()).await
579    }
580
581    /// Execute the tool with status updates.
582    pub async fn execute_with_status<F>(
583        &self,
584        mut req: FetchRequest,
585        mut status_callback: F,
586    ) -> Result<FetchResponse, FetchError>
587    where
588        F: FnMut(ToolStatus),
589    {
590        status_callback(ToolStatus::new("validate").with_percent(0.0));
591
592        if req.url.is_empty() {
593            return Err(FetchError::MissingUrl);
594        }
595
596        req.normalize_url_for_fetch()?;
597
598        status_callback(ToolStatus::new("connect").with_percent(10.0));
599        status_callback(ToolStatus::new("fetch").with_percent(20.0));
600
601        let result = fetch_with_options(req, self.build_options()).await;
602
603        status_callback(ToolStatus::new("complete").with_percent(100.0));
604
605        result
606    }
607
608    /// Execute fetch with optional file saving.
609    ///
610    /// When `req.save_to_file` is set, validates the path via the saver,
611    /// fetches content (including binary), and saves through the saver.
612    /// Returns metadata without inline content.
613    ///
614    /// When `req.save_to_file` is `None`, behaves identically to [`execute`](Self::execute).
615    pub async fn execute_with_saver(
616        &self,
617        req: FetchRequest,
618        saver: Option<&dyn FileSaver>,
619    ) -> Result<FetchResponse, FetchError> {
620        let mut req = req;
621        if req.url.is_empty() {
622            return Err(FetchError::MissingUrl);
623        }
624        req.normalize_url_for_fetch()?;
625
626        if req.save_to_file.is_some() {
627            if !self.enable_save_to_file {
628                return Err(FetchError::SaverNotAvailable);
629            }
630
631            let saver = saver.ok_or(FetchError::SaverNotAvailable)?;
632
633            let options = self.build_options();
634            let registry = FetcherRegistry::with_defaults();
635            registry.fetch_to_file(req, options, saver).await
636        } else {
637            self.execute(req).await
638        }
639    }
640
641    fn build_options(&self) -> FetchOptions {
642        FetchOptions {
643            user_agent: self.user_agent.clone(),
644            allow_prefixes: self.allow_prefixes.clone(),
645            block_prefixes: self.block_prefixes.clone(),
646            enable_markdown: self.enable_markdown,
647            enable_text: self.enable_text,
648            dns_policy: self.dns_policy.clone(),
649            max_body_size: self.max_body_size,
650            enable_save_to_file: self.enable_save_to_file,
651            respect_proxy_env: self.respect_proxy_env,
652            allowed_ports: self.allowed_ports.clone(),
653            blocked_hosts: self.blocked_hosts.clone(),
654            same_host_redirects_only: self.same_host_redirects_only,
655            #[cfg(feature = "bot-auth")]
656            bot_auth: self.bot_auth.clone(),
657            // None => default ReqwestTransport; hosts inject a custom transport via
658            // ToolBuilder::transport and every Tool execution path honors it.
659            transport: self.transport.clone(),
660        }
661    }
662}
663
664/// Single-use runtime execution for one tool call.
665#[derive(Debug, Clone)]
666pub struct ToolExecution {
667    tool: Tool,
668    request: FetchRequest,
669}
670
671impl ToolExecution {
672    /// Run to completion without adapters.
673    pub async fn execute(self) -> Result<ToolOutput, ToolError> {
674        self.execute_inner(None).await
675    }
676
677    /// Run to completion with an injected file saver adapter.
678    pub async fn execute_with<A>(self, saver: &A) -> Result<ToolOutput, ToolError>
679    where
680        A: FileSaver,
681    {
682        self.execute_inner(Some(saver)).await
683    }
684
685    async fn execute_inner(self, saver: Option<&dyn FileSaver>) -> Result<ToolOutput, ToolError> {
686        let ToolExecution { tool, request } = self;
687        let started_at = Instant::now();
688        let response = tool
689            .execute_with_saver(request, saver)
690            .await
691            .map_err(|err| map_fetch_error(&tool.locale, err))?;
692
693        build_tool_output(response, started_at)
694    }
695}
696
697/// Generic JSON args → JSON result service.
698#[derive(Debug, Clone)]
699pub struct ToolService {
700    tool: Tool,
701}
702
703impl Service<Value> for ToolService {
704    type Response = Value;
705    type Error = ToolError;
706    type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
707
708    fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
709        Poll::Ready(Ok(()))
710    }
711
712    fn call(&mut self, req: Value) -> Self::Future {
713        let tool = self.tool.clone();
714        Box::pin(async move {
715            let output = tool.execution(req)?.execute().await?;
716            Ok(output.result)
717        })
718    }
719}
720
721fn build_tool_output(
722    response: FetchResponse,
723    started_at: Instant,
724) -> Result<ToolOutput, ToolError> {
725    let result = serde_json::to_value(&response)
726        .map_err(|err| ToolError::Internal(format!("failed to serialize tool output: {err}")))?;
727
728    Ok(ToolOutput {
729        result,
730        images: Vec::new(),
731        metadata: ToolOutputMetadata {
732            duration: started_at.elapsed(),
733            extra: json!({
734                "http_status": response.status_code,
735                "content_type": response.content_type,
736                "content_length": response.size,
737                "format": response.format,
738                "truncated": response.truncated.unwrap_or(false),
739                "saved_path": response.saved_path,
740                "bytes_written": response.bytes_written,
741            }),
742        },
743    })
744}
745
746fn validate_args(tool: &Tool, args: &Value) -> Result<(), ToolError> {
747    let object = args
748        .as_object()
749        .ok_or_else(|| invalid_arguments_error(tool.locale(), "arguments must be a JSON object"))?;
750
751    for key in object.keys() {
752        let allowed = match key.as_str() {
753            "url" | "method" => true,
754            "as_markdown" => tool.enable_markdown,
755            "as_text" => tool.enable_text,
756            "save_to_file" => tool.enable_save_to_file,
757            "if_none_match" | "if_modified_since" => true,
758            _ => false,
759        };
760
761        if !allowed {
762            return Err(unknown_parameter_error(tool.locale(), key));
763        }
764    }
765
766    Ok(())
767}
768
769fn validate_request(tool: &Tool, request: &FetchRequest) -> Result<(), ToolError> {
770    if request.url.is_empty() {
771        return Err(map_fetch_error(tool.locale(), FetchError::MissingUrl));
772    }
773
774    if !request.url.starts_with("http://") && !request.url.starts_with("https://") {
775        return Err(map_fetch_error(tool.locale(), FetchError::InvalidUrlScheme));
776    }
777
778    Ok(())
779}
780
781fn normalize_request(tool: &Tool, request: &mut FetchRequest) -> Result<(), ToolError> {
782    if request.url.is_empty() {
783        return Err(map_fetch_error(tool.locale(), FetchError::MissingUrl));
784    }
785
786    request
787        .normalize_url_for_fetch()
788        .map_err(|err| map_fetch_error(tool.locale(), err))
789}
790
791fn build_input_schema(
792    enable_markdown: bool,
793    enable_text: bool,
794    enable_save_to_file: bool,
795) -> Value {
796    let mut properties = Map::new();
797    properties.insert(
798        "url".to_string(),
799        json!({
800            "type": "string",
801            "description": "HTTP/HTTPS URL, or a bare domain URL normalized to https://"
802        }),
803    );
804    properties.insert(
805        "method".to_string(),
806        json!({"type": "string", "enum": ["GET", "HEAD"], "default": "GET"}),
807    );
808
809    if enable_markdown {
810        properties.insert(
811            "as_markdown".to_string(),
812            json!({"type": "boolean", "default": false}),
813        );
814    }
815
816    if enable_text {
817        properties.insert(
818            "as_text".to_string(),
819            json!({"type": "boolean", "default": false}),
820        );
821    }
822
823    if enable_save_to_file {
824        properties.insert(
825            "save_to_file".to_string(),
826            json!({
827                "type": "string",
828                "description": "Adapter-defined destination path"
829            }),
830        );
831    }
832
833    properties.insert(
834        "if_none_match".to_string(),
835        json!({
836            "type": "string",
837            "description": "ETag value for conditional requests (If-None-Match header)"
838        }),
839    );
840    properties.insert(
841        "if_modified_since".to_string(),
842        json!({
843            "type": "string",
844            "description": "Last-Modified value for conditional requests (If-Modified-Since header)"
845        }),
846    );
847
848    json!({
849        "type": "object",
850        "properties": properties,
851        "required": ["url"],
852        "additionalProperties": false,
853    })
854}
855
856fn build_output_schema() -> Value {
857    json!({
858        "type": "object",
859        "properties": {
860            "url": {"type": "string"},
861            "status_code": {"type": "integer", "minimum": 100, "maximum": 599},
862            "content_type": {"type": "string"},
863            "size": {"type": "integer", "minimum": 0},
864            "last_modified": {"type": "string"},
865            "etag": {"type": "string"},
866            "filename": {"type": "string"},
867            "format": {"type": "string", "enum": ["markdown", "text", "raw", "github_repo"]},
868            "content": {"type": "string"},
869            "truncated": {"type": "boolean"},
870            "method": {"type": "string", "enum": ["HEAD"]},
871            "error": {"type": "string"},
872            "saved_path": {"type": "string"},
873            "bytes_written": {"type": "integer", "minimum": 0},
874            "word_count": {"type": "integer", "minimum": 0},
875            "redirect_chain": {"type": "array", "items": {"type": "string"}},
876            "is_paywall": {"type": "boolean"}
877        },
878        "required": ["url", "status_code"],
879        "additionalProperties": false
880    })
881}
882
883fn normalize_locale(locale: &str) -> String {
884    let locale = locale.trim();
885    if locale.is_empty() {
886        DEFAULT_LOCALE.to_string()
887    } else {
888        locale.to_string()
889    }
890}
891
892fn is_ukrainian(locale: &str) -> bool {
893    locale.to_ascii_lowercase().starts_with("uk")
894}
895
896fn display_name(locale: &str) -> &'static str {
897    if is_ukrainian(locale) {
898        "Веб-завантаження"
899    } else {
900        "Web Fetch"
901    }
902}
903
904fn description(locale: &str, enable_save_to_file: bool) -> String {
905    if is_ukrainian(locale) {
906        if enable_save_to_file {
907            "Завантажити URL як текст або markdown; повернути метадані для бінарного вмісту або зберегти байти через save_to_file.".to_string()
908        } else {
909            "Завантажити URL як текст або markdown; повернути метадані для бінарного вмісту."
910                .to_string()
911        }
912    } else if enable_save_to_file {
913        "Fetch URL content as text or markdown; return metadata for binary responses or save bytes with save_to_file.".to_string()
914    } else {
915        "Fetch URL content as text or markdown; return metadata for binary responses.".to_string()
916    }
917}
918
919fn system_prompt(locale: &str, enable_save_to_file: bool, block_private_ips: bool) -> String {
920    if is_ukrainian(locale) {
921        let binary_rule = if enable_save_to_file {
922            "Бінарні відповіді повертають метадані; використовуйте save_to_file, щоб зберегти байти."
923        } else {
924            "Бінарні відповіді повертають лише метадані."
925        };
926        let network_rule = if block_private_ips {
927            "Приватні IP-адреси заблоковані."
928        } else {
929            "Блокування приватних IP-адрес вимкнене."
930        };
931        format!(
932            "{}: повертає truncated=true для часткових відповідей після таймауту. {} {}",
933            TOOL_NAME, binary_rule, network_rule
934        )
935    } else {
936        let binary_rule = if enable_save_to_file {
937            "Binary responses return metadata; use save_to_file to persist bytes."
938        } else {
939            "Binary responses return metadata only."
940        };
941        let network_rule = if block_private_ips {
942            "Private IPs are blocked."
943        } else {
944            "Private IP blocking is disabled."
945        };
946        format!(
947            "{}: returns truncated=true for partial responses after timeout. {} {}",
948            TOOL_NAME, binary_rule, network_rule
949        )
950    }
951}
952
953fn build_help(tool: &Tool) -> String {
954    let (parameters_heading, examples_heading, adapters_heading, errors_heading, locale_label) =
955        if is_ukrainian(tool.locale()) {
956            ("Параметри", "Приклади", "Адаптери", "Помилки", "Локаль")
957        } else {
958            ("Parameters", "Examples", "Adapters", "Errors", "Locale")
959        };
960
961    let mut rows = vec![
962        table_row(
963            "url",
964            "string",
965            "yes",
966            "—",
967            parameter_description(tool.locale(), "url"),
968        ),
969        table_row(
970            "method",
971            "string",
972            "no",
973            "\"GET\"",
974            parameter_description(tool.locale(), "method"),
975        ),
976    ];
977
978    if tool.enable_markdown {
979        rows.push(table_row(
980            "as_markdown",
981            "boolean",
982            "no",
983            "false",
984            parameter_description(tool.locale(), "as_markdown"),
985        ));
986    }
987
988    if tool.enable_text {
989        rows.push(table_row(
990            "as_text",
991            "boolean",
992            "no",
993            "false",
994            parameter_description(tool.locale(), "as_text"),
995        ));
996    }
997
998    if tool.enable_save_to_file {
999        rows.push(table_row(
1000            "save_to_file",
1001            "string",
1002            "no",
1003            "—",
1004            parameter_description(tool.locale(), "save_to_file"),
1005        ));
1006    }
1007
1008    let adapters = if tool.enable_save_to_file {
1009        if is_ukrainian(tool.locale()) {
1010            "- `FileSaver` (необов’язковий): потрібен, коли задано `save_to_file`.\n"
1011        } else {
1012            "- `FileSaver` (optional): required when `save_to_file` is set.\n"
1013        }
1014    } else if is_ukrainian(tool.locale()) {
1015        "- Збереження файлів вимкнене в цій конфігурації.\n"
1016    } else {
1017        "- File saving is disabled in this tool build.\n"
1018    };
1019
1020    let errors = if is_ukrainian(tool.locale()) {
1021        if tool.enable_save_to_file {
1022            "- `MissingUrl` — параметр `url` обов’язковий\n\
1023             - `InvalidUrlScheme` — URL має бути `http://`, `https://` або доменним URL\n\
1024             - `BlockedUrl` — URL заблокований політикою SSRF або allow/block правилами\n\
1025             - `FirstByteTimeout` — сервер не відповів протягом 1 секунди\n\
1026             - `SaverNotAvailable` — `save_to_file` потребує адаптер `FileSaver`\n"
1027        } else {
1028            "- `MissingUrl` — параметр `url` обов’язковий\n\
1029             - `InvalidUrlScheme` — URL має бути `http://`, `https://` або доменним URL\n\
1030             - `BlockedUrl` — URL заблокований політикою SSRF або allow/block правилами\n\
1031             - `FirstByteTimeout` — сервер не відповів протягом 1 секунди\n"
1032        }
1033    } else if tool.enable_save_to_file {
1034        "- `MissingUrl` — `url` is required\n\
1035         - `InvalidUrlScheme` — URL must be `http://`, `https://`, or a bare domain URL\n\
1036         - `BlockedUrl` — URL blocked by SSRF policy or allow/block rules\n\
1037         - `FirstByteTimeout` — server did not respond within 1 second\n\
1038         - `SaverNotAvailable` — `save_to_file` requires a `FileSaver` adapter\n"
1039    } else {
1040        "- `MissingUrl` — `url` is required\n\
1041         - `InvalidUrlScheme` — URL must be `http://`, `https://`, or a bare domain URL\n\
1042         - `BlockedUrl` — URL blocked by SSRF policy or allow/block rules\n\
1043         - `FirstByteTimeout` — server did not respond within 1 second\n"
1044    };
1045
1046    let mut help = String::new();
1047    help.push_str(&format!("# {}\n\n", tool.display_name()));
1048    help.push_str(tool.description());
1049    help.push_str("\n\n");
1050    help.push_str(&format!("**Version:** {}\n", tool.version()));
1051    help.push_str(&format!("**Name:** `{}`\n", tool.name()));
1052    help.push_str(&format!("**{}:** `{}`\n\n", locale_label, tool.locale()));
1053    help.push_str(&format!("## {}\n\n", parameters_heading));
1054    help.push_str("| Name | Type | Required | Default | Description |\n");
1055    help.push_str("|------|------|----------|---------|-------------|\n");
1056    for row in rows {
1057        help.push_str(&row);
1058    }
1059    help.push('\n');
1060    help.push_str(&format!("## {}\n\n", examples_heading));
1061    help.push_str("```json\n");
1062    help.push_str("{\"url\": \"https://example.com\", \"as_markdown\": true}\n");
1063    help.push_str("```\n\n");
1064    help.push_str("```json\n");
1065    help.push_str("{\"url\": \"https://example.com/file.pdf\", \"method\": \"HEAD\"}\n");
1066    help.push_str("```\n");
1067    if tool.enable_save_to_file {
1068        help.push_str("\n```json\n");
1069        help.push_str(
1070            "{\"url\": \"https://example.com/image.png\", \"save_to_file\": \"/tmp/image.png\"}\n",
1071        );
1072        help.push_str("```\n");
1073    }
1074    help.push('\n');
1075    help.push_str(&format!("## {}\n\n", adapters_heading));
1076    help.push_str(adapters);
1077    help.push('\n');
1078    help.push_str(&format!("## {}\n\n", errors_heading));
1079    help.push_str(errors);
1080    help.push('\n');
1081    help.push_str("## System Prompt\n\n");
1082    help.push_str(&tool.system_prompt());
1083    help.push('\n');
1084    help
1085}
1086
1087fn parameter_description(locale: &str, field: &str) -> &'static str {
1088    match (is_ukrainian(locale), field) {
1089        (true, "url") => "HTTP/HTTPS URL або доменний URL, який нормалізується до https://",
1090        (true, "method") => "`GET` або `HEAD`",
1091        (true, "as_markdown") => "Перетворити HTML у markdown",
1092        (true, "as_text") => "Перетворити HTML у plain text",
1093        (true, "save_to_file") => "Шлях призначення, визначений адаптером",
1094        (false, "url") => "HTTP/HTTPS URL, or a bare domain URL normalized to `https://`",
1095        (false, "method") => "`GET` or `HEAD`",
1096        (false, "as_markdown") => "Convert HTML to markdown",
1097        (false, "as_text") => "Convert HTML to plain text",
1098        (false, "save_to_file") => "Adapter-defined destination path",
1099        _ => "",
1100    }
1101}
1102
1103fn table_row(name: &str, ty: &str, required: &str, default: &str, description: &str) -> String {
1104    format!("| `{name}` | {ty} | {required} | {default} | {description} |\n")
1105}
1106
1107fn map_fetch_error(locale: &str, err: FetchError) -> ToolError {
1108    match err {
1109        FetchError::ClientBuildError(_) => internal_error("failed to create HTTP client"),
1110        FetchError::MissingUrl => user_error(locale, user_text(locale, "missing_url")),
1111        FetchError::InvalidUrlScheme => user_error(locale, user_text(locale, "invalid_scheme")),
1112        FetchError::InvalidMethod => user_error(locale, user_text(locale, "invalid_method")),
1113        FetchError::BlockedUrl => user_error(locale, user_text(locale, "blocked_url")),
1114        FetchError::FirstByteTimeout => user_error(locale, user_text(locale, "timeout")),
1115        FetchError::ConnectError(_) => user_error(locale, user_text(locale, "connect_error")),
1116        FetchError::RequestError(_) => user_error(locale, user_text(locale, "request_error")),
1117        FetchError::FetcherError(_) => user_error(locale, user_text(locale, "fetcher_error")),
1118        FetchError::SaveError(_) => user_error(locale, user_text(locale, "save_error")),
1119        FetchError::SaverNotAvailable => user_error(locale, user_text(locale, "saver_missing")),
1120    }
1121}
1122
1123fn invalid_arguments_error(locale: &str, detail: &str) -> ToolError {
1124    if is_ukrainian(locale) {
1125        user_error(locale, format!("Неприпустимі аргументи: {detail}"))
1126    } else {
1127        user_error(locale, format!("Invalid arguments: {detail}"))
1128    }
1129}
1130
1131fn unknown_parameter_error(locale: &str, key: &str) -> ToolError {
1132    if is_ukrainian(locale) {
1133        user_error(locale, format!("Невідомий параметр: {key}"))
1134    } else {
1135        user_error(locale, format!("Unknown parameter: {key}"))
1136    }
1137}
1138
1139fn user_text(locale: &str, key: &str) -> &'static str {
1140    match (is_ukrainian(locale), key) {
1141        (true, "missing_url") => "Параметр url обов’язковий.",
1142        (true, "invalid_scheme") => "URL має бути http://, https:// або доменним URL.",
1143        (true, "invalid_method") => "Метод має бути GET або HEAD.",
1144        (true, "blocked_url") => "URL заблокований політикою безпеки.",
1145        (true, "timeout") => {
1146            "Сервер не відповів протягом 1 секунди. Спробуйте ще раз або інший URL."
1147        }
1148        (true, "connect_error") => {
1149            "Не вдалося з’єднатися із сервером. Спробуйте ще раз або інший URL."
1150        }
1151        (true, "request_error") => "Запит не вдався. Спробуйте ще раз.",
1152        (true, "fetcher_error") => "Не вдалося обробити відповідь цього URL.",
1153        (true, "save_error") => "Не вдалося зберегти файл. Перевірте шлях призначення.",
1154        (true, "saver_missing") => "save_to_file потребує адаптер FileSaver.",
1155        (false, "missing_url") => "url is required.",
1156        (false, "invalid_scheme") => "URL must be http://, https://, or a bare domain URL.",
1157        (false, "invalid_method") => "Method must be GET or HEAD.",
1158        (false, "blocked_url") => "URL is blocked by security policy.",
1159        (false, "timeout") => {
1160            "Server did not respond within 1 second. Retry or try a different URL."
1161        }
1162        (false, "connect_error") => "Could not connect to server. Retry or try a different URL.",
1163        (false, "request_error") => "Request failed. Retry the tool call.",
1164        (false, "fetcher_error") => "Could not process the response for this URL.",
1165        (false, "save_error") => "Could not save the file. Check the destination path.",
1166        (false, "saver_missing") => "save_to_file requires the FileSaver adapter.",
1167        _ => "Tool execution failed.",
1168    }
1169}
1170
1171fn user_error(locale: &str, message: impl Into<String>) -> ToolError {
1172    let _ = locale;
1173    ToolError::UserFacing(message.into())
1174}
1175
1176fn internal_error(message: impl Into<String>) -> ToolError {
1177    ToolError::Internal(message.into())
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use super::*;
1183    use serde_json::json;
1184    use tower::Service;
1185
1186    #[test]
1187    fn test_tool_builder() {
1188        let tool = Tool::builder()
1189            .locale("uk-UA")
1190            .enable_markdown(false)
1191            .enable_text(true)
1192            .user_agent("TestAgent/1.0")
1193            .allow_prefix("https://allowed.com")
1194            .block_prefix("https://blocked.com")
1195            .max_body_size(1024)
1196            .respect_proxy_env(true)
1197            .build();
1198
1199        assert_eq!(tool.locale(), "uk-UA");
1200        assert_eq!(tool.name(), "web_fetch");
1201        assert_eq!(tool.display_name(), "Веб-завантаження");
1202        assert!(!tool.enable_markdown);
1203        assert!(tool.enable_text);
1204        assert_eq!(tool.user_agent, Some("TestAgent/1.0".to_string()));
1205        assert_eq!(tool.allow_prefixes, vec!["https://allowed.com"]);
1206        assert_eq!(tool.block_prefixes, vec!["https://blocked.com"]);
1207        assert!(tool.dns_policy.block_private);
1208        assert_eq!(tool.max_body_size, Some(1024));
1209        assert!(!tool.enable_save_to_file);
1210        assert!(tool.respect_proxy_env);
1211        assert!(tool.allowed_ports.is_empty());
1212        assert!(tool.blocked_hosts.is_empty());
1213        assert!(!tool.same_host_redirects_only);
1214    }
1215
1216    #[test]
1217    fn test_tool_builder_opt_out_private_ip_blocking() {
1218        let tool = Tool::builder().block_private_ips(false).build();
1219        assert!(!tool.dns_policy.block_private);
1220    }
1221
1222    #[test]
1223    fn test_tool_builder_security_defaults() {
1224        let tool = Tool::builder().build();
1225        assert!(tool.max_body_size.is_none());
1226        assert!(!tool.enable_save_to_file);
1227        assert!(!tool.respect_proxy_env);
1228        assert!(tool.allowed_ports.is_empty());
1229        assert!(tool.blocked_hosts.is_empty());
1230        assert!(!tool.same_host_redirects_only);
1231    }
1232
1233    #[test]
1234    fn test_tool_builder_hardened_profile() {
1235        let tool = Tool::builder().hardened().build();
1236
1237        assert!(tool.dns_policy.block_private);
1238        assert!(!tool.respect_proxy_env);
1239        assert_eq!(tool.allowed_ports, vec![80, 443]);
1240        assert!(tool.blocked_hosts.contains(&"localhost".to_string()));
1241        assert!(tool.blocked_hosts.contains(&".cluster.local".to_string()));
1242        assert!(tool.same_host_redirects_only);
1243    }
1244
1245    #[test]
1246    fn test_tool_builder_preserves_hardened_redirect_policy_when_override_is_unset() {
1247        let tool = Tool::builder()
1248            .hardened()
1249            .same_host_redirects_only_if_set(None)
1250            .build();
1251
1252        assert!(tool.same_host_redirects_only);
1253    }
1254
1255    #[test]
1256    fn test_tool_builder_allows_explicit_redirect_policy_override() {
1257        let tool = Tool::builder()
1258            .hardened()
1259            .same_host_redirects_only_if_set(Some(false))
1260            .build();
1261
1262        assert!(!tool.same_host_redirects_only);
1263    }
1264
1265    #[test]
1266    fn test_tool_metadata() {
1267        let tool = Tool::default();
1268        assert_eq!(tool.name(), "web_fetch");
1269        assert_eq!(tool.display_name(), "Web Fetch");
1270        assert!(!tool.description().is_empty());
1271        assert!(tool.system_prompt().starts_with("web_fetch:"));
1272        assert!(tool.help().contains("## Parameters"));
1273        assert_eq!(tool.locale(), "en-US");
1274    }
1275
1276    #[test]
1277    fn test_tool_llmtxt_matches_help() {
1278        let tool = Tool::builder().enable_save_to_file(true).build();
1279        assert_eq!(tool.llmtxt(), tool.help());
1280        assert!(tool.llmtxt().contains("save_to_file"));
1281    }
1282
1283    #[test]
1284    fn test_tool_schemas() {
1285        let tool = Tool::default();
1286        let input_schema = tool.input_schema();
1287        let output_schema = tool.output_schema();
1288
1289        assert_eq!(input_schema["type"], "object");
1290        assert!(input_schema["properties"]["url"]["description"]
1291            .as_str()
1292            .unwrap()
1293            .contains("bare domain URL"));
1294        assert_eq!(input_schema["properties"]["method"]["default"], "GET");
1295        assert!(input_schema["properties"]["if_none_match"].is_object());
1296        assert!(input_schema["properties"]["if_modified_since"].is_object());
1297        assert!(output_schema["properties"]["url"].is_object());
1298        assert!(output_schema["properties"]["status_code"].is_object());
1299        assert!(output_schema["properties"]["word_count"].is_object());
1300        assert!(output_schema["properties"]["redirect_chain"].is_object());
1301        assert!(output_schema["properties"]["is_paywall"].is_object());
1302        assert!(output_schema["properties"]["etag"].is_object());
1303    }
1304
1305    #[test]
1306    fn test_tool_schema_feature_gating() {
1307        let tool = Tool::builder()
1308            .enable_markdown(false)
1309            .enable_text(false)
1310            .build();
1311
1312        let schema = tool.input_schema();
1313        let props = schema
1314            .get("properties")
1315            .and_then(|p| p.as_object())
1316            .unwrap();
1317
1318        assert!(!props.contains_key("as_markdown"));
1319        assert!(!props.contains_key("as_text"));
1320    }
1321
1322    #[test]
1323    fn test_tool_definition_uses_contract_metadata() {
1324        let definition = Tool::builder()
1325            .enable_save_to_file(true)
1326            .build_tool_definition();
1327        assert_eq!(definition["type"], "function");
1328        assert_eq!(definition["function"]["name"], "web_fetch");
1329        assert_eq!(definition["function"]["parameters"]["type"], "object");
1330    }
1331
1332    #[test]
1333    fn test_execution_rejects_unknown_parameter() {
1334        let err = Tool::default().execution(json!({
1335            "url": "https://example.com",
1336            "bogus": true
1337        }));
1338
1339        assert!(matches!(&err, Err(ToolError::UserFacing(_))));
1340        assert!(err.unwrap_err().to_string().contains("Unknown parameter"));
1341    }
1342
1343    #[test]
1344    fn test_execution_accepts_conditional_fetch_arguments() {
1345        let ok = Tool::default().execution(json!({
1346            "url": "https://example.com",
1347            "if_none_match": "\"abc\"",
1348            "if_modified_since": "Wed, 21 Oct 2015 07:28:00 GMT"
1349        }));
1350        assert!(ok.is_ok());
1351    }
1352
1353    #[test]
1354    fn test_execution_rejects_invalid_url_before_running() {
1355        let err = Tool::default().execution(json!({"url": "ftp://example.com"}));
1356        assert!(matches!(&err, Err(ToolError::UserFacing(_))));
1357        assert!(err
1358            .unwrap_err()
1359            .to_string()
1360            .contains("URL must be http://, https://, or a bare domain URL"));
1361    }
1362
1363    #[test]
1364    fn test_tool_status() {
1365        let status = ToolStatus::new("fetch")
1366            .with_message("Fetching URL")
1367            .with_percent(50.0)
1368            .with_eta(5000);
1369
1370        assert_eq!(status.phase, "fetch");
1371        assert_eq!(status.message, Some("Fetching URL".to_string()));
1372        assert_eq!(status.percent_complete, Some(50.0));
1373        assert_eq!(status.eta_ms, Some(5000));
1374    }
1375
1376    #[tokio::test]
1377    async fn test_build_service_propagates_validation_errors() {
1378        let mut service = Tool::builder().build_service();
1379        let err = service.call(json!(["not-an-object"])).await.unwrap_err();
1380        assert!(err.is_user_facing());
1381    }
1382}