rmcp_openapi/
http_client.rs

1use reqwest::header;
2use reqwest::{Client, Method, RequestBuilder, StatusCode};
3use serde_json::Value;
4use std::collections::HashMap;
5use std::sync::Arc;
6use std::time::Duration;
7use url::Url;
8
9use crate::error::OpenApiError;
10use crate::server::ToolMetadata;
11use crate::tool_generator::{ExtractedParameters, ToolGenerator};
12
13/// HTTP client for executing `OpenAPI` requests
14#[derive(Clone)]
15pub struct HttpClient {
16    client: Arc<Client>,
17    base_url: Option<Url>,
18}
19
20impl HttpClient {
21    /// Create a new HTTP client
22    ///
23    /// # Panics
24    ///
25    /// Panics if the HTTP client cannot be created
26    #[must_use]
27    pub fn new() -> Self {
28        let client = Client::builder()
29            .timeout(Duration::from_secs(30))
30            .build()
31            .expect("Failed to create HTTP client");
32
33        Self {
34            client: Arc::new(client),
35            base_url: None,
36        }
37    }
38
39    /// Create a new HTTP client with custom timeout
40    ///
41    /// # Panics
42    ///
43    /// Panics if the HTTP client cannot be created
44    #[must_use]
45    pub fn with_timeout(timeout_seconds: u64) -> Self {
46        let client = Client::builder()
47            .timeout(Duration::from_secs(timeout_seconds))
48            .build()
49            .expect("Failed to create HTTP client");
50
51        Self {
52            client: Arc::new(client),
53            base_url: None,
54        }
55    }
56
57    /// Set the base URL for all requests
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the base URL is invalid
62    pub fn with_base_url(mut self, base_url: Url) -> Result<Self, OpenApiError> {
63        self.base_url = Some(base_url);
64        Ok(self)
65    }
66
67    /// Execute an `OpenAPI` tool call
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the HTTP request fails or parameters are invalid
72    pub async fn execute_tool_call(
73        &self,
74        tool_metadata: &ToolMetadata,
75        arguments: &Value,
76    ) -> Result<HttpResponse, OpenApiError> {
77        // Extract parameters from arguments
78        let extracted_params = ToolGenerator::extract_parameters(tool_metadata, arguments)?;
79
80        // Build the URL with path parameters
81        let mut url = self.build_url(tool_metadata, &extracted_params)?;
82
83        // Add query parameters with proper URL encoding
84        if !extracted_params.query.is_empty() {
85            Self::add_query_parameters(&mut url, &extracted_params.query);
86        }
87
88        // Create the HTTP request
89        let mut request = self.create_request(&tool_metadata.method, &url)?;
90
91        // Add headers
92        if !extracted_params.headers.is_empty() {
93            request = Self::add_headers(request, &extracted_params.headers);
94        }
95
96        // Add cookies
97        if !extracted_params.cookies.is_empty() {
98            request = Self::add_cookies(request, &extracted_params.cookies);
99        }
100
101        // Add request body if present
102        if !extracted_params.body.is_empty() {
103            request =
104                Self::add_request_body(request, &extracted_params.body, &extracted_params.config)?;
105        }
106
107        // Apply custom timeout if specified
108        if extracted_params.config.timeout_seconds != 30 {
109            request = request.timeout(Duration::from_secs(u64::from(
110                extracted_params.config.timeout_seconds,
111            )));
112        }
113
114        // Capture request details for response formatting
115        let request_body_string = if extracted_params.body.is_empty() {
116            String::new()
117        } else if extracted_params.body.len() == 1
118            && extracted_params.body.contains_key("request_body")
119        {
120            serde_json::to_string(&extracted_params.body["request_body"]).unwrap_or_default()
121        } else {
122            let body_object = Value::Object(
123                extracted_params
124                    .body
125                    .iter()
126                    .map(|(k, v)| (k.clone(), v.clone()))
127                    .collect(),
128            );
129            serde_json::to_string(&body_object).unwrap_or_default()
130        };
131
132        // Get the final URL for logging
133        let final_url = url.to_string();
134
135        // Execute the request
136        let response = request.send().await.map_err(|e| {
137            // Provide more specific error information
138            if e.is_timeout() {
139                OpenApiError::Http(format!(
140                    "Request timeout after {} seconds while calling {} {}",
141                    extracted_params.config.timeout_seconds,
142                    tool_metadata.method.to_uppercase(),
143                    final_url
144                ))
145            } else if e.is_connect() {
146                OpenApiError::Http(format!(
147                    "Connection failed to {final_url} - check if the server is running and the URL is correct"
148                ))
149            } else if e.is_request() {
150                OpenApiError::Http(format!(
151                    "Request error: {} (URL: {}, Method: {})",
152                    e,
153                    final_url,
154                    tool_metadata.method.to_uppercase()
155                ))
156            } else {
157                OpenApiError::Http(format!(
158                    "HTTP request failed: {} (URL: {}, Method: {})",
159                    e,
160                    final_url,
161                    tool_metadata.method.to_uppercase()
162                ))
163            }
164        })?;
165
166        // Convert response to our format with request details
167        self.process_response_with_request(
168            response,
169            &tool_metadata.method,
170            &final_url,
171            &request_body_string,
172        )
173        .await
174    }
175
176    /// Build the complete URL with path parameters substituted
177    fn build_url(
178        &self,
179        tool_metadata: &ToolMetadata,
180        extracted_params: &ExtractedParameters,
181    ) -> Result<Url, OpenApiError> {
182        let mut path = tool_metadata.path.clone();
183
184        // Substitute path parameters
185        for (param_name, param_value) in &extracted_params.path {
186            let placeholder = format!("{{{param_name}}}");
187            let value_str = match param_value {
188                Value::String(s) => s.clone(),
189                Value::Number(n) => n.to_string(),
190                Value::Bool(b) => b.to_string(),
191                _ => param_value.to_string(),
192            };
193            path = path.replace(&placeholder, &value_str);
194        }
195
196        // Combine with base URL if available
197        if let Some(base_url) = &self.base_url {
198            base_url.join(&path).map_err(|e| {
199                OpenApiError::Http(format!(
200                    "Failed to join URL '{base_url}' with path '{path}': {e}"
201                ))
202            })
203        } else {
204            // Assume the path is already a complete URL
205            if path.starts_with("http") {
206                Url::parse(&path)
207                    .map_err(|e| OpenApiError::Http(format!("Invalid URL '{path}': {e}")))
208            } else {
209                Err(OpenApiError::Http(
210                    "No base URL configured and path is not a complete URL".to_string(),
211                ))
212            }
213        }
214    }
215
216    /// Create a new HTTP request with the specified method and URL
217    fn create_request(&self, method: &str, url: &Url) -> Result<RequestBuilder, OpenApiError> {
218        let http_method = method.to_uppercase();
219        let method = match http_method.as_str() {
220            "GET" => Method::GET,
221            "POST" => Method::POST,
222            "PUT" => Method::PUT,
223            "DELETE" => Method::DELETE,
224            "PATCH" => Method::PATCH,
225            "HEAD" => Method::HEAD,
226            "OPTIONS" => Method::OPTIONS,
227            _ => {
228                return Err(OpenApiError::Http(format!(
229                    "Unsupported HTTP method: {http_method}"
230                )));
231            }
232        };
233
234        Ok(self.client.request(method, url.clone()))
235    }
236
237    /// Add query parameters to the request using proper URL encoding
238    fn add_query_parameters(url: &mut Url, query_params: &HashMap<String, Value>) {
239        {
240            let mut query_pairs = url.query_pairs_mut();
241            for (key, value) in query_params {
242                if let Value::Array(arr) = value {
243                    // Handle array parameters - add each value as a separate query parameter
244                    for item in arr {
245                        let item_str = match item {
246                            Value::String(s) => s.clone(),
247                            Value::Number(n) => n.to_string(),
248                            Value::Bool(b) => b.to_string(),
249                            _ => item.to_string(),
250                        };
251                        query_pairs.append_pair(key, &item_str);
252                    }
253                } else {
254                    let value_str = match value {
255                        Value::String(s) => s.clone(),
256                        Value::Number(n) => n.to_string(),
257                        Value::Bool(b) => b.to_string(),
258                        _ => value.to_string(),
259                    };
260                    query_pairs.append_pair(key, &value_str);
261                }
262            }
263        }
264    }
265
266    /// Add headers to the request
267    fn add_headers(
268        mut request: RequestBuilder,
269        headers: &HashMap<String, Value>,
270    ) -> RequestBuilder {
271        for (key, value) in headers {
272            let value_str = match value {
273                Value::String(s) => s.clone(),
274                Value::Number(n) => n.to_string(),
275                Value::Bool(b) => b.to_string(),
276                _ => value.to_string(),
277            };
278            request = request.header(key, value_str);
279        }
280        request
281    }
282
283    /// Add cookies to the request
284    fn add_cookies(
285        mut request: RequestBuilder,
286        cookies: &HashMap<String, Value>,
287    ) -> RequestBuilder {
288        if !cookies.is_empty() {
289            let cookie_header = cookies
290                .iter()
291                .map(|(key, value)| {
292                    let value_str = match value {
293                        Value::String(s) => s.clone(),
294                        Value::Number(n) => n.to_string(),
295                        Value::Bool(b) => b.to_string(),
296                        _ => value.to_string(),
297                    };
298                    format!("{key}={value_str}")
299                })
300                .collect::<Vec<_>>()
301                .join("; ");
302
303            request = request.header(header::COOKIE, cookie_header);
304        }
305        request
306    }
307
308    /// Add request body to the request
309    fn add_request_body(
310        mut request: RequestBuilder,
311        body: &HashMap<String, Value>,
312        config: &crate::tool_generator::RequestConfig,
313    ) -> Result<RequestBuilder, OpenApiError> {
314        if body.is_empty() {
315            return Ok(request);
316        }
317
318        // Set content type header
319        request = request.header(header::CONTENT_TYPE, &config.content_type);
320
321        // Handle different content types
322        match config.content_type.as_str() {
323            s if s == mime::APPLICATION_JSON.as_ref() => {
324                // For JSON content type, serialize the body
325                if body.len() == 1 && body.contains_key("request_body") {
326                    // Use the request_body directly if it's the only parameter
327                    let body_value = &body["request_body"];
328                    let json_string = serde_json::to_string(body_value).map_err(|e| {
329                        OpenApiError::Http(format!("Failed to serialize request body: {e}"))
330                    })?;
331                    request = request.body(json_string);
332                } else {
333                    // Create JSON object from all body parameters
334                    let body_object =
335                        Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
336                    let json_string = serde_json::to_string(&body_object).map_err(|e| {
337                        OpenApiError::Http(format!("Failed to serialize request body: {e}"))
338                    })?;
339                    request = request.body(json_string);
340                }
341            }
342            s if s == mime::APPLICATION_WWW_FORM_URLENCODED.as_ref() => {
343                // Handle form data
344                let form_data: Vec<(String, String)> = body
345                    .iter()
346                    .map(|(key, value)| {
347                        let value_str = match value {
348                            Value::String(s) => s.clone(),
349                            Value::Number(n) => n.to_string(),
350                            Value::Bool(b) => b.to_string(),
351                            _ => value.to_string(),
352                        };
353                        (key.clone(), value_str)
354                    })
355                    .collect();
356                request = request.form(&form_data);
357            }
358            _ => {
359                // For other content types, try to serialize as JSON
360                let body_object =
361                    Value::Object(body.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
362                let json_string = serde_json::to_string(&body_object).map_err(|e| {
363                    OpenApiError::Http(format!("Failed to serialize request body: {e}"))
364                })?;
365                request = request.body(json_string);
366            }
367        }
368
369        Ok(request)
370    }
371
372    /// Process the HTTP response with request details for better formatting
373    async fn process_response_with_request(
374        &self,
375        response: reqwest::Response,
376        method: &str,
377        url: &str,
378        request_body: &str,
379    ) -> Result<HttpResponse, OpenApiError> {
380        let status = response.status();
381        let headers = response
382            .headers()
383            .iter()
384            .map(|(name, value)| {
385                (
386                    name.to_string(),
387                    value.to_str().unwrap_or("<invalid>").to_string(),
388                )
389            })
390            .collect();
391
392        let body = response
393            .text()
394            .await
395            .map_err(|e| OpenApiError::Http(format!("Failed to read response body: {e}")))?;
396
397        let is_success = status.is_success();
398        let status_code = status.as_u16();
399        let status_text = status.canonical_reason().unwrap_or("Unknown").to_string();
400
401        // Add additional context for common error status codes
402        let enhanced_status_text = match status {
403            StatusCode::BAD_REQUEST => {
404                format!("{status_text} - Bad Request: Check request parameters")
405            }
406            StatusCode::UNAUTHORIZED => {
407                format!("{status_text} - Unauthorized: Authentication required")
408            }
409            StatusCode::FORBIDDEN => format!("{status_text} - Forbidden: Access denied"),
410            StatusCode::NOT_FOUND => {
411                format!("{status_text} - Not Found: Endpoint or resource does not exist")
412            }
413            StatusCode::METHOD_NOT_ALLOWED => format!(
414                "{} - Method Not Allowed: {} method not supported",
415                status_text,
416                method.to_uppercase()
417            ),
418            StatusCode::UNPROCESSABLE_ENTITY => {
419                format!("{status_text} - Unprocessable Entity: Request validation failed")
420            }
421            StatusCode::TOO_MANY_REQUESTS => {
422                format!("{status_text} - Too Many Requests: Rate limit exceeded")
423            }
424            StatusCode::INTERNAL_SERVER_ERROR => {
425                format!("{status_text} - Internal Server Error: Server encountered an error")
426            }
427            StatusCode::BAD_GATEWAY => {
428                format!("{status_text} - Bad Gateway: Upstream server error")
429            }
430            StatusCode::SERVICE_UNAVAILABLE => {
431                format!("{status_text} - Service Unavailable: Server temporarily unavailable")
432            }
433            StatusCode::GATEWAY_TIMEOUT => {
434                format!("{status_text} - Gateway Timeout: Upstream server timeout")
435            }
436            _ => status_text,
437        };
438
439        Ok(HttpResponse {
440            status_code,
441            status_text: enhanced_status_text,
442            headers,
443            body,
444            is_success,
445            request_method: method.to_string(),
446            request_url: url.to_string(),
447            request_body: request_body.to_string(),
448        })
449    }
450}
451
452impl Default for HttpClient {
453    fn default() -> Self {
454        Self::new()
455    }
456}
457
458/// HTTP response from an API call
459#[derive(Debug, Clone)]
460pub struct HttpResponse {
461    pub status_code: u16,
462    pub status_text: String,
463    pub headers: HashMap<String, String>,
464    pub body: String,
465    pub is_success: bool,
466    pub request_method: String,
467    pub request_url: String,
468    pub request_body: String,
469}
470
471impl HttpResponse {
472    /// Try to parse the response body as JSON
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if the body is not valid JSON
477    pub fn json(&self) -> Result<Value, OpenApiError> {
478        serde_json::from_str(&self.body)
479            .map_err(|e| OpenApiError::Http(format!("Failed to parse response as JSON: {e}")))
480    }
481
482    /// Get a formatted response summary for MCP
483    #[must_use]
484    pub fn to_mcp_content(&self) -> String {
485        let method = if self.request_method.is_empty() {
486            None
487        } else {
488            Some(self.request_method.as_str())
489        };
490        let url = if self.request_url.is_empty() {
491            None
492        } else {
493            Some(self.request_url.as_str())
494        };
495        let body = if self.request_body.is_empty() {
496            None
497        } else {
498            Some(self.request_body.as_str())
499        };
500        self.to_mcp_content_with_request(method, url, body)
501    }
502
503    /// Get a formatted response summary for MCP with request details
504    pub fn to_mcp_content_with_request(
505        &self,
506        method: Option<&str>,
507        url: Option<&str>,
508        request_body: Option<&str>,
509    ) -> String {
510        let mut result = format!(
511            "HTTP {} {}\n\nStatus: {} {}\n",
512            if self.is_success { "✅" } else { "❌" },
513            if self.is_success { "Success" } else { "Error" },
514            self.status_code,
515            self.status_text
516        );
517
518        // Add request details if provided
519        if let (Some(method), Some(url)) = (method, url) {
520            result.push_str("\nRequest: ");
521            result.push_str(&method.to_uppercase());
522            result.push(' ');
523            result.push_str(url);
524            result.push('\n');
525
526            if let Some(body) = request_body {
527                if !body.is_empty() && body != "{}" {
528                    result.push_str("\nRequest Body:\n");
529                    if let Ok(parsed) = serde_json::from_str::<Value>(body) {
530                        if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
531                            result.push_str(&pretty);
532                        } else {
533                            result.push_str(body);
534                        }
535                    } else {
536                        result.push_str(body);
537                    }
538                    result.push('\n');
539                }
540            }
541        }
542
543        // Add important headers
544        if !self.headers.is_empty() {
545            result.push_str("\nHeaders:\n");
546            for (key, value) in &self.headers {
547                // Only show commonly useful headers
548                if [
549                    header::CONTENT_TYPE.as_str(),
550                    header::CONTENT_LENGTH.as_str(),
551                    header::LOCATION.as_str(),
552                    header::SET_COOKIE.as_str(),
553                ]
554                .iter()
555                .any(|&h| key.to_lowercase().contains(h))
556                {
557                    result.push_str("  ");
558                    result.push_str(key);
559                    result.push_str(": ");
560                    result.push_str(value);
561                    result.push('\n');
562                }
563            }
564        }
565
566        // Add body content
567        result.push_str("\nResponse Body:\n");
568        if self.body.is_empty() {
569            result.push_str("(empty)");
570        } else if let Ok(json_value) = self.json() {
571            // Pretty print JSON if possible
572            match serde_json::to_string_pretty(&json_value) {
573                Ok(pretty) => result.push_str(&pretty),
574                Err(_) => result.push_str(&self.body),
575            }
576        } else {
577            // Truncate very long responses
578            if self.body.len() > 2000 {
579                result.push_str(&self.body[..2000]);
580                result.push_str("\n... (");
581                result.push_str(&(self.body.len() - 2000).to_string());
582                result.push_str(" more characters)");
583            } else {
584                result.push_str(&self.body);
585            }
586        }
587
588        result
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::tool_generator::ExtractedParameters;
596    use serde_json::json;
597    use std::collections::HashMap;
598
599    #[test]
600    fn test_with_base_url_validation() {
601        // Test valid URLs
602        let url = Url::parse("https://api.example.com").unwrap();
603        let client = HttpClient::new().with_base_url(url);
604        assert!(client.is_ok());
605
606        let url = Url::parse("http://localhost:8080").unwrap();
607        let client = HttpClient::new().with_base_url(url);
608        assert!(client.is_ok());
609
610        // Test invalid URLs - these will fail at parse time now
611        assert!(Url::parse("not-a-url").is_err());
612        assert!(Url::parse("").is_err());
613
614        // Test schemes that parse successfully
615        let url = Url::parse("ftp://invalid-scheme.com").unwrap();
616        let client = HttpClient::new().with_base_url(url);
617        assert!(client.is_ok()); // url crate accepts ftp, our HttpClient should too
618    }
619
620    #[test]
621    fn test_build_url_with_base_url() {
622        let base_url = Url::parse("https://api.example.com").unwrap();
623        let client = HttpClient::new().with_base_url(base_url).unwrap();
624
625        let tool_metadata = crate::server::ToolMetadata {
626            name: "test".to_string(),
627            description: "test".to_string(),
628            parameters: json!({}),
629            method: "GET".to_string(),
630            path: "/pets/{id}".to_string(),
631        };
632
633        let mut path_params = HashMap::new();
634        path_params.insert("id".to_string(), json!(123));
635
636        let extracted_params = ExtractedParameters {
637            path: path_params,
638            query: HashMap::new(),
639            headers: HashMap::new(),
640            cookies: HashMap::new(),
641            body: HashMap::new(),
642            config: crate::tool_generator::RequestConfig::default(),
643        };
644
645        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
646        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
647    }
648
649    #[test]
650    fn test_build_url_without_base_url() {
651        let client = HttpClient::new();
652
653        let tool_metadata = crate::server::ToolMetadata {
654            name: "test".to_string(),
655            description: "test".to_string(),
656            parameters: json!({}),
657            method: "GET".to_string(),
658            path: "https://api.example.com/pets/123".to_string(),
659        };
660
661        let extracted_params = ExtractedParameters {
662            path: HashMap::new(),
663            query: HashMap::new(),
664            headers: HashMap::new(),
665            cookies: HashMap::new(),
666            body: HashMap::new(),
667            config: crate::tool_generator::RequestConfig::default(),
668        };
669
670        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
671        assert_eq!(url.to_string(), "https://api.example.com/pets/123");
672
673        // Test error case: relative path without base URL
674        let tool_metadata_relative = crate::server::ToolMetadata {
675            name: "test".to_string(),
676            description: "test".to_string(),
677            parameters: json!({}),
678            method: "GET".to_string(),
679            path: "/pets/123".to_string(),
680        };
681
682        let result = client.build_url(&tool_metadata_relative, &extracted_params);
683        assert!(result.is_err());
684        assert!(
685            result
686                .unwrap_err()
687                .to_string()
688                .contains("No base URL configured")
689        );
690    }
691
692    #[test]
693    fn test_query_parameter_encoding_integration() {
694        let base_url = Url::parse("https://api.example.com").unwrap();
695        let client = HttpClient::new().with_base_url(base_url).unwrap();
696
697        let tool_metadata = crate::server::ToolMetadata {
698            name: "test".to_string(),
699            description: "test".to_string(),
700            parameters: json!({}),
701            method: "GET".to_string(),
702            path: "/search".to_string(),
703        };
704
705        // Test various query parameter values that need encoding
706        let mut query_params = HashMap::new();
707        query_params.insert("q".to_string(), json!("hello world")); // space
708        query_params.insert("category".to_string(), json!("pets&dogs")); // ampersand
709        query_params.insert("special".to_string(), json!("foo=bar")); // equals
710        query_params.insert("unicode".to_string(), json!("café")); // unicode
711        query_params.insert("percent".to_string(), json!("100%")); // percent
712
713        let extracted_params = ExtractedParameters {
714            path: HashMap::new(),
715            query: query_params,
716            headers: HashMap::new(),
717            cookies: HashMap::new(),
718            body: HashMap::new(),
719            config: crate::tool_generator::RequestConfig::default(),
720        };
721
722        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
723        HttpClient::add_query_parameters(&mut url, &extracted_params.query);
724
725        let url_string = url.to_string();
726
727        // Verify the URL contains properly encoded parameters
728        // Note: url crate encodes spaces as + in query parameters (which is valid)
729        assert!(url_string.contains("q=hello+world")); // space encoded as +
730        assert!(url_string.contains("category=pets%26dogs")); // & encoded as %26
731        assert!(url_string.contains("special=foo%3Dbar")); // = encoded as %3D
732        assert!(url_string.contains("unicode=caf%C3%A9")); // é encoded as %C3%A9
733        assert!(url_string.contains("percent=100%25")); // % encoded as %25
734    }
735
736    #[test]
737    fn test_array_query_parameters() {
738        let base_url = Url::parse("https://api.example.com").unwrap();
739        let client = HttpClient::new().with_base_url(base_url).unwrap();
740
741        let tool_metadata = crate::server::ToolMetadata {
742            name: "test".to_string(),
743            description: "test".to_string(),
744            parameters: json!({}),
745            method: "GET".to_string(),
746            path: "/search".to_string(),
747        };
748
749        let mut query_params = HashMap::new();
750        query_params.insert("status".to_string(), json!(["available", "pending"]));
751        query_params.insert("tags".to_string(), json!(["red & blue", "fast=car"]));
752
753        let extracted_params = ExtractedParameters {
754            path: HashMap::new(),
755            query: query_params,
756            headers: HashMap::new(),
757            cookies: HashMap::new(),
758            body: HashMap::new(),
759            config: crate::tool_generator::RequestConfig::default(),
760        };
761
762        let mut url = client.build_url(&tool_metadata, &extracted_params).unwrap();
763        HttpClient::add_query_parameters(&mut url, &extracted_params.query);
764
765        let url_string = url.to_string();
766
767        // Verify array parameters are added multiple times with proper encoding
768        assert!(url_string.contains("status=available"));
769        assert!(url_string.contains("status=pending"));
770        assert!(url_string.contains("tags=red+%26+blue")); // "red & blue" encoded (spaces as +)
771        assert!(url_string.contains("tags=fast%3Dcar")); // "fast=car" encoded
772    }
773
774    #[test]
775    fn test_path_parameter_substitution() {
776        let base_url = Url::parse("https://api.example.com").unwrap();
777        let client = HttpClient::new().with_base_url(base_url).unwrap();
778
779        let tool_metadata = crate::server::ToolMetadata {
780            name: "test".to_string(),
781            description: "test".to_string(),
782            parameters: json!({}),
783            method: "GET".to_string(),
784            path: "/users/{userId}/pets/{petId}".to_string(),
785        };
786
787        let mut path_params = HashMap::new();
788        path_params.insert("userId".to_string(), json!(42));
789        path_params.insert("petId".to_string(), json!("special-pet-123"));
790
791        let extracted_params = ExtractedParameters {
792            path: path_params,
793            query: HashMap::new(),
794            headers: HashMap::new(),
795            cookies: HashMap::new(),
796            body: HashMap::new(),
797            config: crate::tool_generator::RequestConfig::default(),
798        };
799
800        let url = client.build_url(&tool_metadata, &extracted_params).unwrap();
801        assert_eq!(
802            url.to_string(),
803            "https://api.example.com/users/42/pets/special-pet-123"
804        );
805    }
806
807    #[test]
808    fn test_url_join_edge_cases() {
809        // Test trailing slash handling
810        let base_url1 = Url::parse("https://api.example.com/").unwrap();
811        let client1 = HttpClient::new().with_base_url(base_url1).unwrap();
812
813        let base_url2 = Url::parse("https://api.example.com").unwrap();
814        let client2 = HttpClient::new().with_base_url(base_url2).unwrap();
815
816        let tool_metadata = crate::server::ToolMetadata {
817            name: "test".to_string(),
818            description: "test".to_string(),
819            parameters: json!({}),
820            method: "GET".to_string(),
821            path: "/pets".to_string(),
822        };
823
824        let extracted_params = ExtractedParameters {
825            path: HashMap::new(),
826            query: HashMap::new(),
827            headers: HashMap::new(),
828            cookies: HashMap::new(),
829            body: HashMap::new(),
830            config: crate::tool_generator::RequestConfig::default(),
831        };
832
833        let url1 = client1
834            .build_url(&tool_metadata, &extracted_params)
835            .unwrap();
836        let url2 = client2
837            .build_url(&tool_metadata, &extracted_params)
838            .unwrap();
839
840        // Both should produce the same normalized URL
841        assert_eq!(url1.to_string(), "https://api.example.com/pets");
842        assert_eq!(url2.to_string(), "https://api.example.com/pets");
843    }
844}