rmcp_openapi/
spec.rs

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