rmcp_openapi/
openapi.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use crate::error::OpenApiError;
6use crate::normalize_tag;
7use crate::tool::ToolMetadata;
8use crate::tool_generator::ToolGenerator;
9use oas3::Spec;
10use reqwest::Method;
11use serde_json::Value;
12use url::Url;
13
14#[derive(Debug, Clone)]
15pub enum OpenApiSpecLocation {
16    File(PathBuf),
17    Url(Url),
18    Json(serde_json::Value),
19}
20
21impl FromStr for OpenApiSpecLocation {
22    type Err = OpenApiError;
23
24    fn from_str(s: &str) -> Result<Self, Self::Err> {
25        if s.starts_with("http://") || s.starts_with("https://") {
26            let url =
27                Url::parse(s).map_err(|e| OpenApiError::InvalidUrl(format!("Invalid URL: {e}")))?;
28            Ok(OpenApiSpecLocation::Url(url))
29        } else {
30            let path = PathBuf::from(s);
31            Ok(OpenApiSpecLocation::File(path))
32        }
33    }
34}
35
36impl OpenApiSpecLocation {
37    pub async fn load_spec(&self) -> Result<OpenApiSpec, OpenApiError> {
38        match self {
39            OpenApiSpecLocation::File(path) => {
40                OpenApiSpec::from_file(path.to_str().ok_or_else(|| {
41                    OpenApiError::InvalidPath("Invalid file path encoding".to_string())
42                })?)
43                .await
44            }
45            OpenApiSpecLocation::Url(url) => OpenApiSpec::from_url(url).await,
46            OpenApiSpecLocation::Json(value) => OpenApiSpec::from_value(value.clone()),
47        }
48    }
49}
50
51impl fmt::Display for OpenApiSpecLocation {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        match self {
54            OpenApiSpecLocation::File(path) => write!(f, "{}", path.display()),
55            OpenApiSpecLocation::Url(url) => write!(f, "{url}"),
56            OpenApiSpecLocation::Json(_) => write!(f, "<inline JSON>"),
57        }
58    }
59}
60
61/// OpenAPI specification wrapper that provides convenience methods
62/// for working with oas3::Spec
63#[derive(Debug, Clone)]
64pub struct OpenApiSpec {
65    pub spec: Spec,
66}
67
68impl OpenApiSpec {
69    /// Load and parse an OpenAPI specification from a URL
70    pub async fn from_url(url: &Url) -> Result<Self, OpenApiError> {
71        let client = reqwest::Client::new();
72        let response = client.get(url.clone()).send().await?;
73        let text = response.text().await?;
74        let spec: Spec = serde_json::from_str(&text)?;
75
76        Ok(OpenApiSpec { spec })
77    }
78
79    /// Load and parse an OpenAPI specification from a file
80    pub async fn from_file(path: &str) -> Result<Self, OpenApiError> {
81        let content = tokio::fs::read_to_string(path).await?;
82        let spec: Spec = serde_json::from_str(&content)?;
83
84        Ok(OpenApiSpec { spec })
85    }
86
87    /// Parse an OpenAPI specification from a JSON value
88    pub fn from_value(json_value: Value) -> Result<Self, OpenApiError> {
89        let spec: Spec = serde_json::from_value(json_value)?;
90        Ok(OpenApiSpec { spec })
91    }
92
93    /// Convert all operations to MCP tool metadata
94    pub fn to_tool_metadata(
95        &self,
96        tag_filter: Option<&[String]>,
97        method_filter: Option<&[reqwest::Method]>,
98    ) -> Result<Vec<ToolMetadata>, OpenApiError> {
99        let mut tools = Vec::new();
100
101        if let Some(paths) = &self.spec.paths {
102            for (path, path_item) in paths {
103                // Handle operations in the path item
104                let operations = [
105                    (Method::GET, &path_item.get),
106                    (Method::POST, &path_item.post),
107                    (Method::PUT, &path_item.put),
108                    (Method::DELETE, &path_item.delete),
109                    (Method::PATCH, &path_item.patch),
110                    (Method::HEAD, &path_item.head),
111                    (Method::OPTIONS, &path_item.options),
112                    (Method::TRACE, &path_item.trace),
113                ];
114
115                for (method, operation_ref) in operations {
116                    if let Some(operation) = operation_ref {
117                        // Filter by methods if specified
118                        if let Some(filter_methods) = method_filter
119                            && !filter_methods.contains(&method)
120                        {
121                            continue; // Skip this operation
122                        }
123
124                        // Filter by tags if specified (with kebab-case normalization)
125                        if let Some(filter_tags) = tag_filter {
126                            if !operation.tags.is_empty() {
127                                // Normalize both filter tags and operation tags before comparison
128                                let normalized_filter_tags: Vec<String> =
129                                    filter_tags.iter().map(|tag| normalize_tag(tag)).collect();
130
131                                let has_matching_tag = operation.tags.iter().any(|operation_tag| {
132                                    let normalized_operation_tag = normalize_tag(operation_tag);
133                                    normalized_filter_tags.contains(&normalized_operation_tag)
134                                });
135
136                                if !has_matching_tag {
137                                    continue; // Skip this operation
138                                }
139                            } else {
140                                continue; // Skip operations without tags when filtering
141                            }
142                        }
143
144                        let tool_metadata = ToolGenerator::generate_tool_metadata(
145                            operation,
146                            method.to_string(),
147                            path.clone(),
148                            &self.spec,
149                        )?;
150                        tools.push(tool_metadata);
151                    }
152                }
153            }
154        }
155
156        Ok(tools)
157    }
158
159    /// Convert all operations to OpenApiTool instances with HTTP configuration
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if any operations cannot be converted or OpenApiTool instances cannot be created
164    pub fn to_openapi_tools(
165        &self,
166        tag_filter: Option<&[String]>,
167        method_filter: Option<&[reqwest::Method]>,
168        base_url: Option<url::Url>,
169        default_headers: Option<reqwest::header::HeaderMap>,
170    ) -> Result<Vec<crate::tool::OpenApiTool>, OpenApiError> {
171        // First generate the tool metadata using existing method
172        let tools_metadata = self.to_tool_metadata(tag_filter, method_filter)?;
173
174        // Then convert to OpenApiTool instances
175        crate::tool_generator::ToolGenerator::generate_openapi_tools(
176            tools_metadata,
177            base_url,
178            default_headers,
179        )
180    }
181
182    /// Get operation by operation ID
183    pub fn get_operation(
184        &self,
185        operation_id: &str,
186    ) -> Option<(&oas3::spec::Operation, String, String)> {
187        if let Some(paths) = &self.spec.paths {
188            for (path, path_item) in paths {
189                let operations = [
190                    (Method::GET, &path_item.get),
191                    (Method::POST, &path_item.post),
192                    (Method::PUT, &path_item.put),
193                    (Method::DELETE, &path_item.delete),
194                    (Method::PATCH, &path_item.patch),
195                    (Method::HEAD, &path_item.head),
196                    (Method::OPTIONS, &path_item.options),
197                    (Method::TRACE, &path_item.trace),
198                ];
199
200                for (method, operation_ref) in operations {
201                    if let Some(operation) = operation_ref {
202                        let default_id = format!(
203                            "{}_{}",
204                            method,
205                            path.replace('/', "_").replace(['{', '}'], "")
206                        );
207                        let op_id = operation.operation_id.as_deref().unwrap_or(&default_id);
208
209                        if op_id == operation_id {
210                            return Some((operation, method.to_string(), path.clone()));
211                        }
212                    }
213                }
214            }
215        }
216        None
217    }
218
219    /// Get all operation IDs
220    pub fn get_operation_ids(&self) -> Vec<String> {
221        let mut operation_ids = Vec::new();
222
223        if let Some(paths) = &self.spec.paths {
224            for (path, path_item) in paths {
225                let operations = [
226                    (Method::GET, &path_item.get),
227                    (Method::POST, &path_item.post),
228                    (Method::PUT, &path_item.put),
229                    (Method::DELETE, &path_item.delete),
230                    (Method::PATCH, &path_item.patch),
231                    (Method::HEAD, &path_item.head),
232                    (Method::OPTIONS, &path_item.options),
233                    (Method::TRACE, &path_item.trace),
234                ];
235
236                for (method, operation_ref) in operations {
237                    if let Some(operation) = operation_ref {
238                        let default_id = format!(
239                            "{}_{}",
240                            method,
241                            path.replace('/', "_").replace(['{', '}'], "")
242                        );
243                        let op_id = operation.operation_id.as_deref().unwrap_or(&default_id);
244                        operation_ids.push(op_id.to_string());
245                    }
246                }
247            }
248        }
249
250        operation_ids
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use serde_json::json;
258
259    fn create_test_spec_with_tags() -> OpenApiSpec {
260        let spec_json = json!({
261            "openapi": "3.0.3",
262            "info": {
263                "title": "Test API",
264                "version": "1.0.0"
265            },
266            "paths": {
267                "/pets": {
268                    "get": {
269                        "operationId": "listPets",
270                        "tags": ["pet", "list"],
271                        "responses": {
272                            "200": {
273                                "description": "List of pets"
274                            }
275                        }
276                    },
277                    "post": {
278                        "operationId": "createPet",
279                        "tags": ["pet"],
280                        "responses": {
281                            "201": {
282                                "description": "Pet created"
283                            }
284                        }
285                    }
286                },
287                "/users": {
288                    "get": {
289                        "operationId": "listUsers",
290                        "tags": ["user"],
291                        "responses": {
292                            "200": {
293                                "description": "List of users"
294                            }
295                        }
296                    }
297                },
298                "/admin": {
299                    "get": {
300                        "operationId": "adminPanel",
301                        "tags": ["admin", "management"],
302                        "responses": {
303                            "200": {
304                                "description": "Admin panel"
305                            }
306                        }
307                    }
308                },
309                "/public": {
310                    "get": {
311                        "operationId": "publicEndpoint",
312                        "responses": {
313                            "200": {
314                                "description": "Public endpoint with no tags"
315                            }
316                        }
317                    }
318                }
319            }
320        });
321
322        OpenApiSpec::from_value(spec_json).expect("Failed to create test spec")
323    }
324
325    fn create_test_spec_with_mixed_case_tags() -> OpenApiSpec {
326        let spec_json = json!({
327            "openapi": "3.0.3",
328            "info": {
329                "title": "Test API with Mixed Case Tags",
330                "version": "1.0.0"
331            },
332            "paths": {
333                "/camel": {
334                    "get": {
335                        "operationId": "camelCaseOperation",
336                        "tags": ["userManagement"],
337                        "responses": {
338                            "200": {
339                                "description": "camelCase tag"
340                            }
341                        }
342                    }
343                },
344                "/pascal": {
345                    "get": {
346                        "operationId": "pascalCaseOperation",
347                        "tags": ["UserManagement"],
348                        "responses": {
349                            "200": {
350                                "description": "PascalCase tag"
351                            }
352                        }
353                    }
354                },
355                "/snake": {
356                    "get": {
357                        "operationId": "snakeCaseOperation",
358                        "tags": ["user_management"],
359                        "responses": {
360                            "200": {
361                                "description": "snake_case tag"
362                            }
363                        }
364                    }
365                },
366                "/screaming": {
367                    "get": {
368                        "operationId": "screamingCaseOperation",
369                        "tags": ["USER_MANAGEMENT"],
370                        "responses": {
371                            "200": {
372                                "description": "SCREAMING_SNAKE_CASE tag"
373                            }
374                        }
375                    }
376                },
377                "/kebab": {
378                    "get": {
379                        "operationId": "kebabCaseOperation",
380                        "tags": ["user-management"],
381                        "responses": {
382                            "200": {
383                                "description": "kebab-case tag"
384                            }
385                        }
386                    }
387                },
388                "/mixed": {
389                    "get": {
390                        "operationId": "mixedCaseOperation",
391                        "tags": ["XMLHttpRequest", "HTTPSConnection", "APIKey"],
392                        "responses": {
393                            "200": {
394                                "description": "Mixed case with acronyms"
395                            }
396                        }
397                    }
398                }
399            }
400        });
401
402        OpenApiSpec::from_value(spec_json).expect("Failed to create test spec")
403    }
404
405    fn create_test_spec_with_methods() -> OpenApiSpec {
406        let spec_json = json!({
407            "openapi": "3.0.3",
408            "info": {
409                "title": "Test API with Multiple Methods",
410                "version": "1.0.0"
411            },
412            "paths": {
413                "/users": {
414                    "get": {
415                        "operationId": "listUsers",
416                        "tags": ["user"],
417                        "responses": {
418                            "200": {
419                                "description": "List of users"
420                            }
421                        }
422                    },
423                    "post": {
424                        "operationId": "createUser",
425                        "tags": ["user"],
426                        "responses": {
427                            "201": {
428                                "description": "User created"
429                            }
430                        }
431                    },
432                    "put": {
433                        "operationId": "updateUser",
434                        "tags": ["user"],
435                        "responses": {
436                            "200": {
437                                "description": "User updated"
438                            }
439                        }
440                    },
441                    "delete": {
442                        "operationId": "deleteUser",
443                        "tags": ["user"],
444                        "responses": {
445                            "204": {
446                                "description": "User deleted"
447                            }
448                        }
449                    }
450                },
451                "/pets": {
452                    "get": {
453                        "operationId": "listPets",
454                        "tags": ["pet"],
455                        "responses": {
456                            "200": {
457                                "description": "List of pets"
458                            }
459                        }
460                    },
461                    "post": {
462                        "operationId": "createPet",
463                        "tags": ["pet"],
464                        "responses": {
465                            "201": {
466                                "description": "Pet created"
467                            }
468                        }
469                    },
470                    "patch": {
471                        "operationId": "patchPet",
472                        "tags": ["pet"],
473                        "responses": {
474                            "200": {
475                                "description": "Pet patched"
476                            }
477                        }
478                    }
479                },
480                "/health": {
481                    "head": {
482                        "operationId": "healthCheck",
483                        "tags": ["health"],
484                        "responses": {
485                            "200": {
486                                "description": "Health check"
487                            }
488                        }
489                    },
490                    "options": {
491                        "operationId": "healthOptions",
492                        "tags": ["health"],
493                        "responses": {
494                            "200": {
495                                "description": "Health options"
496                            }
497                        }
498                    }
499                }
500            }
501        });
502
503        OpenApiSpec::from_value(spec_json).expect("Failed to create test spec")
504    }
505
506    #[test]
507    fn test_tag_filtering_no_filter() {
508        let spec = create_test_spec_with_tags();
509        let tools = spec
510            .to_tool_metadata(None, None)
511            .expect("Failed to generate tools");
512
513        // All operations should be included
514        assert_eq!(tools.len(), 5);
515
516        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
517        assert!(tool_names.contains(&"listPets"));
518        assert!(tool_names.contains(&"createPet"));
519        assert!(tool_names.contains(&"listUsers"));
520        assert!(tool_names.contains(&"adminPanel"));
521        assert!(tool_names.contains(&"publicEndpoint"));
522    }
523
524    #[test]
525    fn test_tag_filtering_single_tag() {
526        let spec = create_test_spec_with_tags();
527        let filter_tags = vec!["pet".to_string()];
528        let tools = spec
529            .to_tool_metadata(Some(&filter_tags), None)
530            .expect("Failed to generate tools");
531
532        // Only pet operations should be included
533        assert_eq!(tools.len(), 2);
534
535        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
536        assert!(tool_names.contains(&"listPets"));
537        assert!(tool_names.contains(&"createPet"));
538        assert!(!tool_names.contains(&"listUsers"));
539        assert!(!tool_names.contains(&"adminPanel"));
540        assert!(!tool_names.contains(&"publicEndpoint"));
541    }
542
543    #[test]
544    fn test_tag_filtering_multiple_tags() {
545        let spec = create_test_spec_with_tags();
546        let filter_tags = vec!["pet".to_string(), "user".to_string()];
547        let tools = spec
548            .to_tool_metadata(Some(&filter_tags), None)
549            .expect("Failed to generate tools");
550
551        // Pet and user operations should be included
552        assert_eq!(tools.len(), 3);
553
554        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
555        assert!(tool_names.contains(&"listPets"));
556        assert!(tool_names.contains(&"createPet"));
557        assert!(tool_names.contains(&"listUsers"));
558        assert!(!tool_names.contains(&"adminPanel"));
559        assert!(!tool_names.contains(&"publicEndpoint"));
560    }
561
562    #[test]
563    fn test_tag_filtering_or_logic() {
564        let spec = create_test_spec_with_tags();
565        let filter_tags = vec!["list".to_string()]; // listPets has both "pet" and "list" tags
566        let tools = spec
567            .to_tool_metadata(Some(&filter_tags), None)
568            .expect("Failed to generate tools");
569
570        // Only operations with "list" tag should be included
571        assert_eq!(tools.len(), 1);
572
573        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
574        assert!(tool_names.contains(&"listPets")); // Has both "pet" and "list" tags
575        assert!(!tool_names.contains(&"createPet")); // Only has "pet" tag
576    }
577
578    #[test]
579    fn test_tag_filtering_no_matching_tags() {
580        let spec = create_test_spec_with_tags();
581        let filter_tags = vec!["nonexistent".to_string()];
582        let tools = spec
583            .to_tool_metadata(Some(&filter_tags), None)
584            .expect("Failed to generate tools");
585
586        // No operations should be included
587        assert_eq!(tools.len(), 0);
588    }
589
590    #[test]
591    fn test_tag_filtering_excludes_operations_without_tags() {
592        let spec = create_test_spec_with_tags();
593        let filter_tags = vec!["admin".to_string()];
594        let tools = spec
595            .to_tool_metadata(Some(&filter_tags), None)
596            .expect("Failed to generate tools");
597
598        // Only admin operations should be included, public endpoint (no tags) should be excluded
599        assert_eq!(tools.len(), 1);
600
601        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
602        assert!(tool_names.contains(&"adminPanel"));
603        assert!(!tool_names.contains(&"publicEndpoint")); // No tags, should be excluded
604    }
605
606    #[test]
607    fn test_tag_normalization_all_cases_match() {
608        let spec = create_test_spec_with_mixed_case_tags();
609        let filter_tags = vec!["user-management".to_string()]; // kebab-case filter
610        let tools = spec
611            .to_tool_metadata(Some(&filter_tags), None)
612            .expect("Failed to generate tools");
613
614        // All userManagement variants should match user-management filter
615        assert_eq!(tools.len(), 5);
616
617        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
618        assert!(tool_names.contains(&"camelCaseOperation")); // userManagement
619        assert!(tool_names.contains(&"pascalCaseOperation")); // UserManagement
620        assert!(tool_names.contains(&"snakeCaseOperation")); // user_management
621        assert!(tool_names.contains(&"screamingCaseOperation")); // USER_MANAGEMENT
622        assert!(tool_names.contains(&"kebabCaseOperation")); // user-management
623        assert!(!tool_names.contains(&"mixedCaseOperation")); // Different tags
624    }
625
626    #[test]
627    fn test_tag_normalization_camel_case_filter() {
628        let spec = create_test_spec_with_mixed_case_tags();
629        let filter_tags = vec!["userManagement".to_string()]; // camelCase filter
630        let tools = spec
631            .to_tool_metadata(Some(&filter_tags), None)
632            .expect("Failed to generate tools");
633
634        // All userManagement variants should match camelCase filter
635        assert_eq!(tools.len(), 5);
636
637        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
638        assert!(tool_names.contains(&"camelCaseOperation"));
639        assert!(tool_names.contains(&"pascalCaseOperation"));
640        assert!(tool_names.contains(&"snakeCaseOperation"));
641        assert!(tool_names.contains(&"screamingCaseOperation"));
642        assert!(tool_names.contains(&"kebabCaseOperation"));
643    }
644
645    #[test]
646    fn test_tag_normalization_snake_case_filter() {
647        let spec = create_test_spec_with_mixed_case_tags();
648        let filter_tags = vec!["user_management".to_string()]; // snake_case filter
649        let tools = spec
650            .to_tool_metadata(Some(&filter_tags), None)
651            .expect("Failed to generate tools");
652
653        // All userManagement variants should match snake_case filter
654        assert_eq!(tools.len(), 5);
655    }
656
657    #[test]
658    fn test_tag_normalization_acronyms() {
659        let spec = create_test_spec_with_mixed_case_tags();
660        let filter_tags = vec!["xml-http-request".to_string()]; // kebab-case filter for acronym
661        let tools = spec
662            .to_tool_metadata(Some(&filter_tags), None)
663            .expect("Failed to generate tools");
664
665        // Should match XMLHttpRequest tag
666        assert_eq!(tools.len(), 1);
667
668        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
669        assert!(tool_names.contains(&"mixedCaseOperation"));
670    }
671
672    #[test]
673    fn test_tag_normalization_multiple_mixed_filters() {
674        let spec = create_test_spec_with_mixed_case_tags();
675        let filter_tags = vec![
676            "user-management".to_string(), // kebab-case
677            "HTTPSConnection".to_string(), // PascalCase with acronym
678        ];
679        let tools = spec
680            .to_tool_metadata(Some(&filter_tags), None)
681            .expect("Failed to generate tools");
682
683        // Should match all userManagement variants + mixedCaseOperation (for HTTPSConnection)
684        assert_eq!(tools.len(), 6);
685
686        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
687        assert!(tool_names.contains(&"camelCaseOperation"));
688        assert!(tool_names.contains(&"pascalCaseOperation"));
689        assert!(tool_names.contains(&"snakeCaseOperation"));
690        assert!(tool_names.contains(&"screamingCaseOperation"));
691        assert!(tool_names.contains(&"kebabCaseOperation"));
692        assert!(tool_names.contains(&"mixedCaseOperation"));
693    }
694
695    #[test]
696    fn test_tag_filtering_empty_filter_list() {
697        let spec = create_test_spec_with_tags();
698        let filter_tags: Vec<String> = vec![];
699        let tools = spec
700            .to_tool_metadata(Some(&filter_tags), None)
701            .expect("Failed to generate tools");
702
703        // Empty filter should exclude all operations
704        assert_eq!(tools.len(), 0);
705    }
706
707    #[test]
708    fn test_tag_filtering_complex_scenario() {
709        let spec = create_test_spec_with_tags();
710        let filter_tags = vec!["management".to_string(), "list".to_string()];
711        let tools = spec
712            .to_tool_metadata(Some(&filter_tags), None)
713            .expect("Failed to generate tools");
714
715        // Should include adminPanel (has "management") and listPets (has "list")
716        assert_eq!(tools.len(), 2);
717
718        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
719        assert!(tool_names.contains(&"adminPanel"));
720        assert!(tool_names.contains(&"listPets"));
721        assert!(!tool_names.contains(&"createPet"));
722        assert!(!tool_names.contains(&"listUsers"));
723        assert!(!tool_names.contains(&"publicEndpoint"));
724    }
725
726    #[test]
727    fn test_method_filtering_no_filter() {
728        let spec = create_test_spec_with_methods();
729        let tools = spec
730            .to_tool_metadata(None, None)
731            .expect("Failed to generate tools");
732
733        // All operations should be included (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
734        assert_eq!(tools.len(), 9);
735
736        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
737        assert!(tool_names.contains(&"listUsers")); // GET /users
738        assert!(tool_names.contains(&"createUser")); // POST /users
739        assert!(tool_names.contains(&"updateUser")); // PUT /users
740        assert!(tool_names.contains(&"deleteUser")); // DELETE /users
741        assert!(tool_names.contains(&"listPets")); // GET /pets
742        assert!(tool_names.contains(&"createPet")); // POST /pets
743        assert!(tool_names.contains(&"patchPet")); // PATCH /pets
744        assert!(tool_names.contains(&"healthCheck")); // HEAD /health
745        assert!(tool_names.contains(&"healthOptions")); // OPTIONS /health
746    }
747
748    #[test]
749    fn test_method_filtering_single_method() {
750        use reqwest::Method;
751
752        let spec = create_test_spec_with_methods();
753        let filter_methods = vec![Method::GET];
754        let tools = spec
755            .to_tool_metadata(None, Some(&filter_methods))
756            .expect("Failed to generate tools");
757
758        // Only GET operations should be included
759        assert_eq!(tools.len(), 2);
760
761        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
762        assert!(tool_names.contains(&"listUsers")); // GET /users
763        assert!(tool_names.contains(&"listPets")); // GET /pets
764        assert!(!tool_names.contains(&"createUser")); // POST /users
765        assert!(!tool_names.contains(&"updateUser")); // PUT /users
766        assert!(!tool_names.contains(&"deleteUser")); // DELETE /users
767        assert!(!tool_names.contains(&"createPet")); // POST /pets
768        assert!(!tool_names.contains(&"patchPet")); // PATCH /pets
769        assert!(!tool_names.contains(&"healthCheck")); // HEAD /health
770        assert!(!tool_names.contains(&"healthOptions")); // OPTIONS /health
771    }
772
773    #[test]
774    fn test_method_filtering_multiple_methods() {
775        use reqwest::Method;
776
777        let spec = create_test_spec_with_methods();
778        let filter_methods = vec![Method::GET, Method::POST];
779        let tools = spec
780            .to_tool_metadata(None, Some(&filter_methods))
781            .expect("Failed to generate tools");
782
783        // Only GET and POST operations should be included
784        assert_eq!(tools.len(), 4);
785
786        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
787        assert!(tool_names.contains(&"listUsers")); // GET /users
788        assert!(tool_names.contains(&"createUser")); // POST /users
789        assert!(tool_names.contains(&"listPets")); // GET /pets
790        assert!(tool_names.contains(&"createPet")); // POST /pets
791        assert!(!tool_names.contains(&"updateUser")); // PUT /users
792        assert!(!tool_names.contains(&"deleteUser")); // DELETE /users
793        assert!(!tool_names.contains(&"patchPet")); // PATCH /pets
794        assert!(!tool_names.contains(&"healthCheck")); // HEAD /health
795        assert!(!tool_names.contains(&"healthOptions")); // OPTIONS /health
796    }
797
798    #[test]
799    fn test_method_filtering_uncommon_methods() {
800        use reqwest::Method;
801
802        let spec = create_test_spec_with_methods();
803        let filter_methods = vec![Method::HEAD, Method::OPTIONS, Method::PATCH];
804        let tools = spec
805            .to_tool_metadata(None, Some(&filter_methods))
806            .expect("Failed to generate tools");
807
808        // Only HEAD, OPTIONS, and PATCH operations should be included
809        assert_eq!(tools.len(), 3);
810
811        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
812        assert!(tool_names.contains(&"patchPet")); // PATCH /pets
813        assert!(tool_names.contains(&"healthCheck")); // HEAD /health
814        assert!(tool_names.contains(&"healthOptions")); // OPTIONS /health
815        assert!(!tool_names.contains(&"listUsers")); // GET /users
816        assert!(!tool_names.contains(&"createUser")); // POST /users
817        assert!(!tool_names.contains(&"updateUser")); // PUT /users
818        assert!(!tool_names.contains(&"deleteUser")); // DELETE /users
819        assert!(!tool_names.contains(&"listPets")); // GET /pets
820        assert!(!tool_names.contains(&"createPet")); // POST /pets
821    }
822
823    #[test]
824    fn test_method_and_tag_filtering_combined() {
825        use reqwest::Method;
826
827        let spec = create_test_spec_with_methods();
828        let filter_tags = vec!["user".to_string()];
829        let filter_methods = vec![Method::GET, Method::POST];
830        let tools = spec
831            .to_tool_metadata(Some(&filter_tags), Some(&filter_methods))
832            .expect("Failed to generate tools");
833
834        // Only user operations with GET and POST methods should be included
835        assert_eq!(tools.len(), 2);
836
837        let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
838        assert!(tool_names.contains(&"listUsers")); // GET /users (has user tag)
839        assert!(tool_names.contains(&"createUser")); // POST /users (has user tag)
840        assert!(!tool_names.contains(&"updateUser")); // PUT /users (user tag but not GET/POST)
841        assert!(!tool_names.contains(&"deleteUser")); // DELETE /users (user tag but not GET/POST)
842        assert!(!tool_names.contains(&"listPets")); // GET /pets (GET method but not user tag)
843        assert!(!tool_names.contains(&"createPet")); // POST /pets (POST method but not user tag)
844        assert!(!tool_names.contains(&"patchPet")); // PATCH /pets (neither user tag nor GET/POST)
845        assert!(!tool_names.contains(&"healthCheck")); // HEAD /health (neither user tag nor GET/POST)
846        assert!(!tool_names.contains(&"healthOptions")); // OPTIONS /health (neither user tag nor GET/POST)
847    }
848
849    #[test]
850    fn test_method_filtering_no_matching_methods() {
851        use reqwest::Method;
852
853        let spec = create_test_spec_with_methods();
854        let filter_methods = vec![Method::TRACE]; // No TRACE operations in the spec
855        let tools = spec
856            .to_tool_metadata(None, Some(&filter_methods))
857            .expect("Failed to generate tools");
858
859        // No operations should be included
860        assert_eq!(tools.len(), 0);
861    }
862
863    #[test]
864    fn test_method_filtering_empty_filter_list() {
865        let spec = create_test_spec_with_methods();
866        let filter_methods: Vec<reqwest::Method> = vec![];
867        let tools = spec
868            .to_tool_metadata(None, Some(&filter_methods))
869            .expect("Failed to generate tools");
870
871        // Empty filter should exclude all operations
872        assert_eq!(tools.len(), 0);
873    }
874}