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