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            execution: None,
182            title: metadata.title.clone(),
183            icons: None,
184            meta: None,
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use serde_json::json;
193
194    /// Helper function to create test metadata with a specific HTTP method
195    fn create_test_metadata(method: &str) -> ToolMetadata {
196        ToolMetadata {
197            name: "test_tool".to_string(),
198            title: None,
199            description: None,
200            parameters: json!({}),
201            output_schema: None,
202            method: method.to_string(),
203            path: "/test".to_string(),
204            security: None,
205            parameter_mappings: HashMap::new(),
206        }
207    }
208
209    #[test]
210    fn test_get_annotations() {
211        let metadata = create_test_metadata("GET");
212        let annotations = metadata
213            .generate_annotations()
214            .expect("GET should return annotations");
215
216        assert_eq!(annotations.title, None);
217        assert_eq!(annotations.read_only_hint, Some(true));
218        assert_eq!(annotations.destructive_hint, Some(false));
219        assert_eq!(annotations.idempotent_hint, Some(true));
220        assert_eq!(annotations.open_world_hint, Some(true));
221    }
222
223    #[test]
224    fn test_post_annotations() {
225        let metadata = create_test_metadata("POST");
226        let annotations = metadata
227            .generate_annotations()
228            .expect("POST should return annotations");
229
230        assert_eq!(annotations.title, None);
231        assert_eq!(annotations.read_only_hint, Some(false));
232        assert_eq!(annotations.destructive_hint, Some(false));
233        assert_eq!(annotations.idempotent_hint, Some(false));
234        assert_eq!(annotations.open_world_hint, Some(true));
235    }
236
237    #[test]
238    fn test_put_annotations() {
239        let metadata = create_test_metadata("PUT");
240        let annotations = metadata
241            .generate_annotations()
242            .expect("PUT should return annotations");
243
244        assert_eq!(annotations.title, None);
245        assert_eq!(annotations.read_only_hint, Some(false));
246        assert_eq!(annotations.destructive_hint, Some(true));
247        assert_eq!(annotations.idempotent_hint, Some(true));
248        assert_eq!(annotations.open_world_hint, Some(true));
249    }
250
251    #[test]
252    fn test_patch_annotations() {
253        let metadata = create_test_metadata("PATCH");
254        let annotations = metadata
255            .generate_annotations()
256            .expect("PATCH should return annotations");
257
258        assert_eq!(annotations.title, None);
259        assert_eq!(annotations.read_only_hint, Some(false));
260        assert_eq!(annotations.destructive_hint, Some(true));
261        assert_eq!(annotations.idempotent_hint, Some(false));
262        assert_eq!(annotations.open_world_hint, Some(true));
263    }
264
265    #[test]
266    fn test_delete_annotations() {
267        let metadata = create_test_metadata("DELETE");
268        let annotations = metadata
269            .generate_annotations()
270            .expect("DELETE should return annotations");
271
272        assert_eq!(annotations.title, None);
273        assert_eq!(annotations.read_only_hint, Some(false));
274        assert_eq!(annotations.destructive_hint, Some(true));
275        assert_eq!(annotations.idempotent_hint, Some(true));
276        assert_eq!(annotations.open_world_hint, Some(true));
277    }
278
279    #[test]
280    fn test_head_annotations() {
281        let metadata = create_test_metadata("HEAD");
282        let annotations = metadata
283            .generate_annotations()
284            .expect("HEAD should return annotations");
285
286        // HEAD should have the same annotations as GET
287        assert_eq!(annotations.title, None);
288        assert_eq!(annotations.read_only_hint, Some(true));
289        assert_eq!(annotations.destructive_hint, Some(false));
290        assert_eq!(annotations.idempotent_hint, Some(true));
291        assert_eq!(annotations.open_world_hint, Some(true));
292    }
293
294    #[test]
295    fn test_options_annotations() {
296        let metadata = create_test_metadata("OPTIONS");
297        let annotations = metadata
298            .generate_annotations()
299            .expect("OPTIONS should return annotations");
300
301        // OPTIONS should have the same annotations as GET
302        assert_eq!(annotations.title, None);
303        assert_eq!(annotations.read_only_hint, Some(true));
304        assert_eq!(annotations.destructive_hint, Some(false));
305        assert_eq!(annotations.idempotent_hint, Some(true));
306        assert_eq!(annotations.open_world_hint, Some(true));
307    }
308
309    #[test]
310    fn test_unknown_method_returns_none() {
311        // Test various unknown/unsupported HTTP methods
312        let unknown_methods = vec!["TRACE", "CONNECT", "CUSTOM", "INVALID", "UNKNOWN"];
313
314        for method in unknown_methods {
315            let metadata = create_test_metadata(method);
316            let annotations = metadata.generate_annotations();
317            assert_eq!(
318                annotations, None,
319                "Unknown method '{}' should return None",
320                method
321            );
322        }
323    }
324
325    #[test]
326    fn test_case_insensitive_method_matching() {
327        // Test that method matching is case-insensitive
328        let get_variations = vec!["GET", "get", "Get", "gEt", "GeT"];
329
330        for method in get_variations {
331            let metadata = create_test_metadata(method);
332            let annotations = metadata
333                .generate_annotations()
334                .unwrap_or_else(|| panic!("'{}' should return annotations", method));
335
336            // All variations should produce GET annotations
337            assert_eq!(annotations.read_only_hint, Some(true));
338            assert_eq!(annotations.destructive_hint, Some(false));
339            assert_eq!(annotations.idempotent_hint, Some(true));
340            assert_eq!(annotations.open_world_hint, Some(true));
341        }
342
343        // Test POST variations too
344        let post_variations = vec!["POST", "post", "Post"];
345
346        for method in post_variations {
347            let metadata = create_test_metadata(method);
348            let annotations = metadata
349                .generate_annotations()
350                .unwrap_or_else(|| panic!("'{}' should return annotations", method));
351
352            // All variations should produce POST annotations
353            assert_eq!(annotations.read_only_hint, Some(false));
354            assert_eq!(annotations.destructive_hint, Some(false));
355            assert_eq!(annotations.idempotent_hint, Some(false));
356            assert_eq!(annotations.open_world_hint, Some(true));
357        }
358    }
359
360    #[test]
361    fn test_annotations_title_always_none() {
362        // Verify that title field in annotations is always None for all methods
363        let all_methods = vec!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
364
365        for method in all_methods {
366            let metadata = create_test_metadata(method);
367            let annotations = metadata
368                .generate_annotations()
369                .unwrap_or_else(|| panic!("'{}' should return annotations", method));
370
371            assert_eq!(
372                annotations.title, None,
373                "Method '{}' should have title=None in annotations",
374                method
375            );
376        }
377    }
378}