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