rmcp_openapi/
http_client.rs

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