Skip to main content

rmcp_openapi/tool/
metadata.rs

1use rmcp::model::{Tool, ToolAnnotations};
2use serde_json::Value;
3use std::{collections::HashMap, sync::Arc};
4
5/// Parameter mapping information for converting between MCP and OpenAPI parameters
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
7pub struct ParameterMapping {
8    /// The sanitized parameter name used in MCP
9    pub sanitized_name: String,
10    /// The original parameter name from OpenAPI
11    pub original_name: String,
12    /// The location of the parameter (query, header, path, cookie, body)
13    pub location: String,
14    /// Whether the parameter should be exploded (for arrays/objects)
15    pub explode: bool,
16}
17
18/// Internal metadata for tools generated from OpenAPI operations.
19///
20/// This struct contains all the information needed to execute HTTP requests
21/// and is used internally by the OpenAPI server. It includes fields that are
22/// not part of the MCP specification but are necessary for HTTP execution.
23///
24/// For MCP compliance, this struct is converted to `rmcp::model::Tool` using
25/// the `From` trait implementation, which only includes MCP-compliant fields.
26///
27/// ## MCP Tool Annotations
28///
29/// When converted to MCP tools, this metadata automatically generates appropriate
30/// annotation hints based on HTTP method semantics (see [`ToolMetadata::generate_annotations`]).
31/// These annotations help MCP clients understand the nature of each tool operation.
32#[derive(Debug, Clone, serde::Serialize)]
33pub struct ToolMetadata {
34    /// Tool name - exposed to MCP clients
35    pub name: String,
36    /// Tool title - human-readable display name exposed to MCP clients
37    pub title: Option<String>,
38    /// Tool description - exposed to MCP clients
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub description: Option<String>,
41    /// Input parameters schema - exposed to MCP clients as `inputSchema`
42    pub parameters: Value,
43    /// Output schema - exposed to MCP clients as `outputSchema`
44    pub output_schema: Option<Value>,
45    /// HTTP method (GET, POST, etc.) - internal only, not exposed to MCP
46    pub method: String,
47    /// URL path for the API endpoint - internal only, not exposed to MCP
48    pub path: String,
49    /// Security requirements from OpenAPI spec - internal only, not exposed to MCP
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub security: Option<Vec<String>>,
52    /// Parameter mappings for converting between MCP and OpenAPI parameters - internal only, not exposed to MCP
53    #[serde(skip_serializing_if = "HashMap::is_empty")]
54    pub parameter_mappings: HashMap<String, ParameterMapping>,
55}
56
57impl ToolMetadata {
58    /// Check if this tool requires authentication based on OpenAPI security definitions
59    pub fn requires_auth(&self) -> bool {
60        self.security.as_ref().is_some_and(|s| !s.is_empty())
61    }
62
63    /// Generate MCP annotations based on HTTP method semantics.
64    ///
65    /// This method maps HTTP verbs to appropriate MCP tool annotation hints following
66    /// the semantics defined in RFC 9110 (HTTP Semantics) and the Model Context Protocol
67    /// specification.
68    ///
69    /// # HTTP Method to Annotation Mapping
70    ///
71    /// - **GET, HEAD, OPTIONS**: Safe, idempotent read operations
72    ///   - `readOnlyHint: true` - No state modification
73    ///   - `destructiveHint: false` - Doesn't alter existing resources
74    ///   - `idempotentHint: true` - Multiple requests have same effect
75    ///   - `openWorldHint: true` - Interacts with external HTTP API
76    ///
77    /// - **POST**: Creates resources; not idempotent, not destructive
78    ///   - `readOnlyHint: false` - Modifies state
79    ///   - `destructiveHint: false` - Creates new resources (doesn't destroy existing)
80    ///   - `idempotentHint: false` - Multiple requests may create multiple resources
81    ///   - `openWorldHint: true` - Interacts with external HTTP API
82    ///
83    /// - **PUT**: Replaces/updates resources; idempotent but destructive
84    ///   - `readOnlyHint: false` - Modifies state
85    ///   - `destructiveHint: true` - Replaces existing resource state
86    ///   - `idempotentHint: true` - Multiple identical requests have same effect
87    ///   - `openWorldHint: true` - Interacts with external HTTP API
88    ///
89    /// - **PATCH**: Modifies resources; destructive and typically not idempotent
90    ///   - `readOnlyHint: false` - Modifies state
91    ///   - `destructiveHint: true` - Alters existing resource state
92    ///   - `idempotentHint: false` - Effect may vary based on current state
93    ///   - `openWorldHint: true` - Interacts with external HTTP API
94    ///
95    /// - **DELETE**: Removes resources; idempotent but destructive
96    ///   - `readOnlyHint: false` - Modifies state
97    ///   - `destructiveHint: true` - Removes resources
98    ///   - `idempotentHint: true` - Multiple deletions are no-ops after first
99    ///   - `openWorldHint: true` - Interacts with external HTTP API
100    ///
101    /// # Returns
102    ///
103    /// - `Some(ToolAnnotations)` for recognized HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS)
104    /// - `None` for unknown or unsupported HTTP methods
105    ///
106    /// # Notes
107    ///
108    /// - HTTP method comparison is case-insensitive
109    /// - The `title` field in annotations is always `None` (title is handled via `Tool.title`)
110    /// - `openWorldHint` is always `true` since all OpenAPI tools interact with external HTTP APIs
111    pub fn generate_annotations(&self) -> Option<ToolAnnotations> {
112        match self.method.to_uppercase().as_str() {
113            "GET" | "HEAD" | "OPTIONS" => Some(ToolAnnotations {
114                title: None,
115                read_only_hint: Some(true),
116                destructive_hint: Some(false),
117                idempotent_hint: Some(true),
118                open_world_hint: Some(true),
119            }),
120            "POST" => Some(ToolAnnotations {
121                title: None,
122                read_only_hint: Some(false),
123                destructive_hint: Some(false),
124                idempotent_hint: Some(false),
125                open_world_hint: Some(true),
126            }),
127            "PUT" => Some(ToolAnnotations {
128                title: None,
129                read_only_hint: Some(false),
130                destructive_hint: Some(true),
131                idempotent_hint: Some(true),
132                open_world_hint: Some(true),
133            }),
134            "PATCH" => Some(ToolAnnotations {
135                title: None,
136                read_only_hint: Some(false),
137                destructive_hint: Some(true),
138                idempotent_hint: Some(false),
139                open_world_hint: Some(true),
140            }),
141            "DELETE" => Some(ToolAnnotations {
142                title: None,
143                read_only_hint: Some(false),
144                destructive_hint: Some(true),
145                idempotent_hint: Some(true),
146                open_world_hint: Some(true),
147            }),
148            _ => None,
149        }
150    }
151}
152
153/// Converts internal `ToolMetadata` to MCP-compliant `Tool`.
154///
155/// This implementation ensures that only MCP-compliant fields are exposed to clients.
156/// Internal fields like `method` and `path` are not included in the conversion.
157impl From<&ToolMetadata> for Tool {
158    fn from(metadata: &ToolMetadata) -> Self {
159        // Convert parameters to the expected Arc<Map> format
160        let input_schema = if let Value::Object(obj) = &metadata.parameters {
161            Arc::new(obj.clone())
162        } else {
163            Arc::new(serde_json::Map::new())
164        };
165
166        // Convert output_schema to the expected Arc<Map> format if present
167        let output_schema = metadata.output_schema.as_ref().and_then(|schema| {
168            if let Value::Object(obj) = schema {
169                Some(Arc::new(obj.clone()))
170            } else {
171                None
172            }
173        });
174
175        Tool {
176            name: metadata.name.clone().into(),
177            description: metadata.description.clone().map(|d| d.into()),
178            input_schema,
179            output_schema,
180            annotations: metadata.generate_annotations(),
181            title: metadata.title.clone(),
182            icons: None,
183            meta: None,
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use serde_json::json;
192
193    /// Helper function to create test metadata with a specific HTTP method
194    fn create_test_metadata(method: &str) -> ToolMetadata {
195        ToolMetadata {
196            name: "test_tool".to_string(),
197            title: None,
198            description: None,
199            parameters: json!({}),
200            output_schema: None,
201            method: method.to_string(),
202            path: "/test".to_string(),
203            security: None,
204            parameter_mappings: HashMap::new(),
205        }
206    }
207
208    #[test]
209    fn test_get_annotations() {
210        let metadata = create_test_metadata("GET");
211        let annotations = metadata
212            .generate_annotations()
213            .expect("GET should return annotations");
214
215        assert_eq!(annotations.title, None);
216        assert_eq!(annotations.read_only_hint, Some(true));
217        assert_eq!(annotations.destructive_hint, Some(false));
218        assert_eq!(annotations.idempotent_hint, Some(true));
219        assert_eq!(annotations.open_world_hint, Some(true));
220    }
221
222    #[test]
223    fn test_post_annotations() {
224        let metadata = create_test_metadata("POST");
225        let annotations = metadata
226            .generate_annotations()
227            .expect("POST should return annotations");
228
229        assert_eq!(annotations.title, None);
230        assert_eq!(annotations.read_only_hint, Some(false));
231        assert_eq!(annotations.destructive_hint, Some(false));
232        assert_eq!(annotations.idempotent_hint, Some(false));
233        assert_eq!(annotations.open_world_hint, Some(true));
234    }
235
236    #[test]
237    fn test_put_annotations() {
238        let metadata = create_test_metadata("PUT");
239        let annotations = metadata
240            .generate_annotations()
241            .expect("PUT should return annotations");
242
243        assert_eq!(annotations.title, None);
244        assert_eq!(annotations.read_only_hint, Some(false));
245        assert_eq!(annotations.destructive_hint, Some(true));
246        assert_eq!(annotations.idempotent_hint, Some(true));
247        assert_eq!(annotations.open_world_hint, Some(true));
248    }
249
250    #[test]
251    fn test_patch_annotations() {
252        let metadata = create_test_metadata("PATCH");
253        let annotations = metadata
254            .generate_annotations()
255            .expect("PATCH should return annotations");
256
257        assert_eq!(annotations.title, None);
258        assert_eq!(annotations.read_only_hint, Some(false));
259        assert_eq!(annotations.destructive_hint, Some(true));
260        assert_eq!(annotations.idempotent_hint, Some(false));
261        assert_eq!(annotations.open_world_hint, Some(true));
262    }
263
264    #[test]
265    fn test_delete_annotations() {
266        let metadata = create_test_metadata("DELETE");
267        let annotations = metadata
268            .generate_annotations()
269            .expect("DELETE should return annotations");
270
271        assert_eq!(annotations.title, None);
272        assert_eq!(annotations.read_only_hint, Some(false));
273        assert_eq!(annotations.destructive_hint, Some(true));
274        assert_eq!(annotations.idempotent_hint, Some(true));
275        assert_eq!(annotations.open_world_hint, Some(true));
276    }
277
278    #[test]
279    fn test_head_annotations() {
280        let metadata = create_test_metadata("HEAD");
281        let annotations = metadata
282            .generate_annotations()
283            .expect("HEAD should return annotations");
284
285        // HEAD should have the same annotations as GET
286        assert_eq!(annotations.title, None);
287        assert_eq!(annotations.read_only_hint, Some(true));
288        assert_eq!(annotations.destructive_hint, Some(false));
289        assert_eq!(annotations.idempotent_hint, Some(true));
290        assert_eq!(annotations.open_world_hint, Some(true));
291    }
292
293    #[test]
294    fn test_options_annotations() {
295        let metadata = create_test_metadata("OPTIONS");
296        let annotations = metadata
297            .generate_annotations()
298            .expect("OPTIONS should return annotations");
299
300        // OPTIONS should have the same annotations as GET
301        assert_eq!(annotations.title, None);
302        assert_eq!(annotations.read_only_hint, Some(true));
303        assert_eq!(annotations.destructive_hint, Some(false));
304        assert_eq!(annotations.idempotent_hint, Some(true));
305        assert_eq!(annotations.open_world_hint, Some(true));
306    }
307
308    #[test]
309    fn test_unknown_method_returns_none() {
310        // Test various unknown/unsupported HTTP methods
311        let unknown_methods = vec!["TRACE", "CONNECT", "CUSTOM", "INVALID", "UNKNOWN"];
312
313        for method in unknown_methods {
314            let metadata = create_test_metadata(method);
315            let annotations = metadata.generate_annotations();
316            assert_eq!(
317                annotations, None,
318                "Unknown method '{}' should return None",
319                method
320            );
321        }
322    }
323
324    #[test]
325    fn test_case_insensitive_method_matching() {
326        // Test that method matching is case-insensitive
327        let get_variations = vec!["GET", "get", "Get", "gEt", "GeT"];
328
329        for method in get_variations {
330            let metadata = create_test_metadata(method);
331            let annotations = metadata
332                .generate_annotations()
333                .unwrap_or_else(|| panic!("'{}' should return annotations", method));
334
335            // All variations should produce GET annotations
336            assert_eq!(annotations.read_only_hint, Some(true));
337            assert_eq!(annotations.destructive_hint, Some(false));
338            assert_eq!(annotations.idempotent_hint, Some(true));
339            assert_eq!(annotations.open_world_hint, Some(true));
340        }
341
342        // Test POST variations too
343        let post_variations = vec!["POST", "post", "Post"];
344
345        for method in post_variations {
346            let metadata = create_test_metadata(method);
347            let annotations = metadata
348                .generate_annotations()
349                .unwrap_or_else(|| panic!("'{}' should return annotations", method));
350
351            // All variations should produce POST annotations
352            assert_eq!(annotations.read_only_hint, Some(false));
353            assert_eq!(annotations.destructive_hint, Some(false));
354            assert_eq!(annotations.idempotent_hint, Some(false));
355            assert_eq!(annotations.open_world_hint, Some(true));
356        }
357    }
358
359    #[test]
360    fn test_annotations_title_always_none() {
361        // Verify that title field in annotations is always None for all methods
362        let all_methods = vec!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
363
364        for method in all_methods {
365            let metadata = create_test_metadata(method);
366            let annotations = metadata
367                .generate_annotations()
368                .unwrap_or_else(|| panic!("'{}' should return annotations", method));
369
370            assert_eq!(
371                annotations.title, None,
372                "Method '{}' should have title=None in annotations",
373                method
374            );
375        }
376    }
377}