rmcp_openapi/
spec.rs

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