rmcp_openapi/
tool_generator.rs

1use serde_json::{Value, json};
2use std::collections::HashMap;
3
4use crate::error::OpenApiError;
5use crate::openapi_spec::{OpenApiOperation, OpenApiParameter};
6use crate::server::ToolMetadata;
7
8/// Tool generator for creating MCP tools from OpenAPI operations
9pub struct ToolGenerator;
10
11impl ToolGenerator {
12    /// Generate tool metadata from an OpenAPI operation
13    pub fn generate_tool_metadata(
14        operation: &OpenApiOperation,
15    ) -> Result<ToolMetadata, OpenApiError> {
16        let name = operation.operation_id.clone();
17
18        // Build description from summary and description
19        let description = Self::build_description(operation);
20
21        // Generate parameter schema
22        let parameters = Self::generate_parameter_schema(&operation.parameters, &operation.method)?;
23
24        Ok(ToolMetadata {
25            name,
26            description,
27            parameters,
28            method: operation.method.clone(),
29            path: operation.path.clone(),
30        })
31    }
32
33    /// Build a comprehensive description for the tool
34    fn build_description(operation: &OpenApiOperation) -> String {
35        match (&operation.summary, &operation.description) {
36            (Some(summary), Some(desc)) => {
37                format!(
38                    "{}\n\n{}\n\nEndpoint: {} {}",
39                    summary,
40                    desc,
41                    operation.method.to_uppercase(),
42                    operation.path
43                )
44            }
45            (Some(summary), None) => {
46                format!(
47                    "{}\n\nEndpoint: {} {}",
48                    summary,
49                    operation.method.to_uppercase(),
50                    operation.path
51                )
52            }
53            (None, Some(desc)) => {
54                format!(
55                    "{}\n\nEndpoint: {} {}",
56                    desc,
57                    operation.method.to_uppercase(),
58                    operation.path
59                )
60            }
61            (None, None) => {
62                format!(
63                    "API endpoint: {} {}",
64                    operation.method.to_uppercase(),
65                    operation.path
66                )
67            }
68        }
69    }
70
71    /// Generate JSON Schema for tool parameters
72    fn generate_parameter_schema(
73        parameters: &[OpenApiParameter],
74        method: &str,
75    ) -> Result<Value, OpenApiError> {
76        let mut properties = serde_json::Map::new();
77        let mut required = Vec::new();
78
79        // Group parameters by location
80        let mut path_params = Vec::new();
81        let mut query_params = Vec::new();
82        let mut header_params = Vec::new();
83        let mut cookie_params = Vec::new();
84        let mut body_params = Vec::new();
85
86        for param in parameters {
87            match param.location {
88                crate::openapi_spec::ParameterLocation::Path => path_params.push(param),
89                crate::openapi_spec::ParameterLocation::Query => query_params.push(param),
90                crate::openapi_spec::ParameterLocation::Header => header_params.push(param),
91                crate::openapi_spec::ParameterLocation::Cookie => cookie_params.push(param),
92                crate::openapi_spec::ParameterLocation::FormData => body_params.push(param),
93            }
94        }
95
96        // Process path parameters (always required)
97        for param in path_params {
98            let param_schema = Self::convert_parameter_schema(param)?;
99            properties.insert(param.name.clone(), param_schema);
100            required.push(param.name.clone());
101        }
102
103        // Process query parameters
104        for param in &query_params {
105            let param_schema = Self::convert_parameter_schema(param)?;
106            properties.insert(param.name.clone(), param_schema);
107            if param.required {
108                required.push(param.name.clone());
109            }
110        }
111
112        // Process header parameters (optional by default unless explicitly required)
113        for param in &header_params {
114            let mut param_schema = Self::convert_parameter_schema(param)?;
115
116            // Add location metadata for headers
117            if let Value::Object(ref mut obj) = param_schema {
118                obj.insert("x-location".to_string(), json!("header"));
119            }
120
121            properties.insert(format!("header_{}", param.name), param_schema);
122            if param.required {
123                required.push(format!("header_{}", param.name));
124            }
125        }
126
127        // Process cookie parameters (rare, but supported)
128        for param in &cookie_params {
129            let mut param_schema = Self::convert_parameter_schema(param)?;
130
131            // Add location metadata for cookies
132            if let Value::Object(ref mut obj) = param_schema {
133                obj.insert("x-location".to_string(), json!("cookie"));
134            }
135
136            properties.insert(format!("cookie_{}", param.name), param_schema);
137            if param.required {
138                required.push(format!("cookie_{}", param.name));
139            }
140        }
141
142        // Process request body parameters (for POST/PUT operations)
143        for param in &body_params {
144            let mut param_schema = Self::convert_parameter_schema(param)?;
145
146            // Add location metadata for body
147            if let Value::Object(ref mut obj) = param_schema {
148                obj.insert("x-location".to_string(), json!("body"));
149                obj.insert("x-content-type".to_string(), json!("application/json"));
150            }
151
152            properties.insert(format!("body_{}", param.name), param_schema);
153            if param.required {
154                required.push(format!("body_{}", param.name));
155            }
156        }
157
158        // Add request body parameter for operations that typically need it
159        if body_params.is_empty()
160            && ["post", "put", "patch"].contains(&method.to_lowercase().as_str())
161        {
162            properties.insert(
163                "request_body".to_string(),
164                json!({
165                    "type": "object",
166                    "description": "Request body data (JSON)",
167                    "additionalProperties": true,
168                    "x-location": "body",
169                    "x-content-type": "application/json"
170                }),
171            );
172        }
173
174        // Add special parameters for request configuration
175        if !query_params.is_empty() || !header_params.is_empty() || !cookie_params.is_empty() {
176            // Add optional timeout parameter
177            properties.insert(
178                "timeout_seconds".to_string(),
179                json!({
180                    "type": "integer",
181                    "description": "Request timeout in seconds",
182                    "minimum": 1,
183                    "maximum": 300,
184                    "default": 30
185                }),
186            );
187        }
188
189        Ok(json!({
190            "type": "object",
191            "properties": properties,
192            "required": required,
193            "additionalProperties": false
194        }))
195    }
196
197    /// Convert OpenAPI parameter schema to JSON Schema for MCP tools
198    fn convert_parameter_schema(param: &OpenApiParameter) -> Result<Value, OpenApiError> {
199        let mut schema = param.schema.clone();
200
201        // Ensure we have a valid schema object
202        if !schema.is_object() {
203            schema = json!({
204                "type": param.param_type
205            });
206        }
207
208        let mut result = serde_json::Map::new();
209
210        // Copy type information
211        if let Some(param_type) = schema.get("type") {
212            result.insert("type".to_string(), param_type.clone());
213        } else {
214            result.insert("type".to_string(), json!(param.param_type));
215        }
216
217        // Add description
218        if let Some(desc) = &param.description {
219            result.insert("description".to_string(), json!(desc));
220        } else {
221            result.insert(
222                "description".to_string(),
223                json!(format!("{} parameter", param.name)),
224            );
225        }
226
227        // Handle array types
228        if param.param_type == "array"
229            || schema.get("type").and_then(|v| v.as_str()) == Some("array")
230        {
231            if let Some(items) = schema.get("items") {
232                result.insert("items".to_string(), items.clone());
233            } else {
234                result.insert("items".to_string(), json!({"type": "string"}));
235            }
236        }
237
238        // Copy additional constraints
239        for key in [
240            "minimum",
241            "maximum",
242            "minLength",
243            "maxLength",
244            "pattern",
245            "enum",
246            "format",
247        ] {
248            if let Some(constraint) = schema.get(key) {
249                result.insert(key.to_string(), constraint.clone());
250            }
251        }
252
253        // Add parameter location metadata
254        result.insert(
255            "x-parameter-location".to_string(),
256            json!(param.location.to_string()),
257        );
258        result.insert("x-parameter-required".to_string(), json!(param.required));
259
260        Ok(Value::Object(result))
261    }
262
263    /// Extract parameter values from MCP tool call arguments
264    pub fn extract_parameters(
265        tool_metadata: &ToolMetadata,
266        arguments: &Value,
267    ) -> Result<ExtractedParameters, OpenApiError> {
268        let args = arguments
269            .as_object()
270            .ok_or_else(|| OpenApiError::Validation("Arguments must be an object".to_string()))?;
271
272        let mut path_params = HashMap::new();
273        let mut query_params = HashMap::new();
274        let mut header_params = HashMap::new();
275        let mut cookie_params = HashMap::new();
276        let mut body_params = HashMap::new();
277        let mut config = RequestConfig::default();
278
279        // Extract timeout if provided
280        if let Some(timeout) = args.get("timeout_seconds").and_then(|v| v.as_u64()) {
281            config.timeout_seconds = timeout as u32;
282        }
283
284        // Process each argument
285        for (key, value) in args {
286            if key == "timeout_seconds" {
287                continue; // Already processed
288            }
289
290            // Handle special request_body parameter
291            if key == "request_body" {
292                body_params.insert("request_body".to_string(), value.clone());
293                continue;
294            }
295
296            // Determine parameter location from the tool metadata
297            let location = Self::get_parameter_location(tool_metadata, key)?;
298
299            match location.as_str() {
300                "path" => {
301                    path_params.insert(key.clone(), value.clone());
302                }
303                "query" => {
304                    query_params.insert(key.clone(), value.clone());
305                }
306                "header" => {
307                    // Remove "header_" prefix if present
308                    let header_name = if key.starts_with("header_") {
309                        key.strip_prefix("header_").unwrap_or(key).to_string()
310                    } else {
311                        key.clone()
312                    };
313                    header_params.insert(header_name, value.clone());
314                }
315                "cookie" => {
316                    // Remove "cookie_" prefix if present
317                    let cookie_name = if key.starts_with("cookie_") {
318                        key.strip_prefix("cookie_").unwrap_or(key).to_string()
319                    } else {
320                        key.clone()
321                    };
322                    cookie_params.insert(cookie_name, value.clone());
323                }
324                "body" => {
325                    // Remove "body_" prefix if present
326                    let body_name = if key.starts_with("body_") {
327                        key.strip_prefix("body_").unwrap_or(key).to_string()
328                    } else {
329                        key.clone()
330                    };
331                    body_params.insert(body_name, value.clone());
332                }
333                _ => {
334                    return Err(OpenApiError::ToolGeneration(format!(
335                        "Unknown parameter location for parameter: {key}"
336                    )));
337                }
338            }
339        }
340
341        let extracted = ExtractedParameters {
342            path: path_params,
343            query: query_params,
344            headers: header_params,
345            cookies: cookie_params,
346            body: body_params,
347            config,
348        };
349
350        // Validate parameters against tool metadata
351        Self::validate_parameters(tool_metadata, &extracted)?;
352
353        Ok(extracted)
354    }
355
356    /// Get parameter location from tool metadata
357    fn get_parameter_location(
358        tool_metadata: &ToolMetadata,
359        param_name: &str,
360    ) -> Result<String, OpenApiError> {
361        let properties = tool_metadata
362            .parameters
363            .get("properties")
364            .and_then(|p| p.as_object())
365            .ok_or_else(|| {
366                OpenApiError::ToolGeneration("Invalid tool parameters schema".to_string())
367            })?;
368
369        if let Some(param_schema) = properties.get(param_name) {
370            if let Some(location) = param_schema
371                .get("x-parameter-location")
372                .and_then(|v| v.as_str())
373            {
374                return Ok(location.to_string());
375            }
376        }
377
378        // Fallback: infer from parameter name prefix
379        if param_name.starts_with("header_") {
380            Ok("header".to_string())
381        } else if param_name.starts_with("cookie_") {
382            Ok("cookie".to_string())
383        } else if param_name.starts_with("body_") {
384            Ok("body".to_string())
385        } else {
386            // Default to query for unknown parameters
387            Ok("query".to_string())
388        }
389    }
390
391    /// Validate extracted parameters against tool metadata
392    fn validate_parameters(
393        tool_metadata: &ToolMetadata,
394        extracted: &ExtractedParameters,
395    ) -> Result<(), OpenApiError> {
396        let schema = &tool_metadata.parameters;
397
398        // Get required parameters from schema
399        let required_params = schema
400            .get("required")
401            .and_then(|r| r.as_array())
402            .map(|arr| {
403                arr.iter()
404                    .filter_map(|v| v.as_str())
405                    .collect::<std::collections::HashSet<_>>()
406            })
407            .unwrap_or_default();
408
409        let properties = schema
410            .get("properties")
411            .and_then(|p| p.as_object())
412            .ok_or_else(|| {
413                OpenApiError::Validation("Tool schema missing properties".to_string())
414            })?;
415
416        // Check all required parameters are provided
417        for required_param in &required_params {
418            let param_found = extracted.path.contains_key(*required_param)
419                || extracted.query.contains_key(*required_param)
420                || extracted
421                    .headers
422                    .contains_key(&required_param.replace("header_", ""))
423                || extracted
424                    .cookies
425                    .contains_key(&required_param.replace("cookie_", ""))
426                || extracted
427                    .body
428                    .contains_key(&required_param.replace("body_", ""))
429                || (*required_param == "request_body"
430                    && extracted.body.contains_key("request_body"));
431
432            if !param_found {
433                return Err(OpenApiError::InvalidParameter {
434                    parameter: required_param.to_string(),
435                    reason: "Required parameter is missing".to_string(),
436                });
437            }
438        }
439
440        // Validate parameter types and constraints
441        for (param_name, param_value) in extracted
442            .path
443            .iter()
444            .chain(extracted.query.iter())
445            .chain(extracted.headers.iter())
446            .chain(extracted.cookies.iter())
447            .chain(extracted.body.iter())
448        {
449            if let Some(param_schema) = properties
450                .get(param_name)
451                .or_else(|| properties.get(&format!("header_{param_name}")))
452                .or_else(|| properties.get(&format!("cookie_{param_name}")))
453                .or_else(|| properties.get(&format!("body_{param_name}")))
454            {
455                Self::validate_parameter_value(param_name, param_value, param_schema)?;
456            }
457        }
458
459        Ok(())
460    }
461
462    /// Validate a single parameter value against its schema
463    fn validate_parameter_value(
464        param_name: &str,
465        param_value: &Value,
466        param_schema: &Value,
467    ) -> Result<(), OpenApiError> {
468        let expected_type = param_schema.get("type").and_then(|t| t.as_str());
469
470        match expected_type {
471            Some("string") => {
472                if !param_value.is_string() {
473                    return Err(OpenApiError::InvalidParameter {
474                        parameter: param_name.to_string(),
475                        reason: "Expected string value".to_string(),
476                    });
477                }
478
479                // Check string constraints
480                if let Some(value_str) = param_value.as_str() {
481                    if let Some(min_length) = param_schema.get("minLength").and_then(|v| v.as_u64())
482                    {
483                        if value_str.len() < min_length as usize {
484                            return Err(OpenApiError::InvalidParameter {
485                                parameter: param_name.to_string(),
486                                reason: format!("String too short, minimum length is {min_length}"),
487                            });
488                        }
489                    }
490
491                    if let Some(max_length) = param_schema.get("maxLength").and_then(|v| v.as_u64())
492                    {
493                        if value_str.len() > max_length as usize {
494                            return Err(OpenApiError::InvalidParameter {
495                                parameter: param_name.to_string(),
496                                reason: format!("String too long, maximum length is {max_length}"),
497                            });
498                        }
499                    }
500
501                    if let Some(pattern) = param_schema.get("pattern").and_then(|v| v.as_str()) {
502                        if let Ok(regex) = regex::Regex::new(pattern) {
503                            if !regex.is_match(value_str) {
504                                return Err(OpenApiError::InvalidParameter {
505                                    parameter: param_name.to_string(),
506                                    reason: format!("String does not match pattern: {pattern}"),
507                                });
508                            }
509                        }
510                    }
511
512                    if let Some(enum_values) = param_schema.get("enum").and_then(|v| v.as_array()) {
513                        let valid_values: Vec<&str> =
514                            enum_values.iter().filter_map(|v| v.as_str()).collect();
515                        if !valid_values.contains(&value_str) {
516                            return Err(OpenApiError::InvalidParameter {
517                                parameter: param_name.to_string(),
518                                reason: format!(
519                                    "Invalid enum value. Valid values: {valid_values:?}"
520                                ),
521                            });
522                        }
523                    }
524                }
525            }
526            Some("integer") | Some("number") => {
527                if !param_value.is_number() {
528                    return Err(OpenApiError::InvalidParameter {
529                        parameter: param_name.to_string(),
530                        reason: "Expected numeric value".to_string(),
531                    });
532                }
533
534                if let Some(value_num) = param_value.as_f64() {
535                    if let Some(minimum) = param_schema.get("minimum").and_then(|v| v.as_f64()) {
536                        if value_num < minimum {
537                            return Err(OpenApiError::InvalidParameter {
538                                parameter: param_name.to_string(),
539                                reason: format!("Value {value_num} is below minimum {minimum}"),
540                            });
541                        }
542                    }
543
544                    if let Some(maximum) = param_schema.get("maximum").and_then(|v| v.as_f64()) {
545                        if value_num > maximum {
546                            return Err(OpenApiError::InvalidParameter {
547                                parameter: param_name.to_string(),
548                                reason: format!("Value {value_num} is above maximum {maximum}"),
549                            });
550                        }
551                    }
552                }
553            }
554            Some("boolean") => {
555                if !param_value.is_boolean() {
556                    return Err(OpenApiError::InvalidParameter {
557                        parameter: param_name.to_string(),
558                        reason: "Expected boolean value".to_string(),
559                    });
560                }
561            }
562            Some("array") => {
563                if !param_value.is_array() {
564                    return Err(OpenApiError::InvalidParameter {
565                        parameter: param_name.to_string(),
566                        reason: "Expected array value".to_string(),
567                    });
568                }
569            }
570            Some("object") => {
571                if !param_value.is_object() {
572                    return Err(OpenApiError::InvalidParameter {
573                        parameter: param_name.to_string(),
574                        reason: "Expected object value".to_string(),
575                    });
576                }
577            }
578            _ => {
579                // No specific type validation, allow any value
580            }
581        }
582
583        Ok(())
584    }
585}
586
587/// Extracted parameters from MCP tool call
588#[derive(Debug, Clone)]
589pub struct ExtractedParameters {
590    pub path: HashMap<String, Value>,
591    pub query: HashMap<String, Value>,
592    pub headers: HashMap<String, Value>,
593    pub cookies: HashMap<String, Value>,
594    pub body: HashMap<String, Value>,
595    pub config: RequestConfig,
596}
597
598/// Request configuration options
599#[derive(Debug, Clone)]
600pub struct RequestConfig {
601    pub timeout_seconds: u32,
602    pub content_type: String,
603}
604
605impl Default for RequestConfig {
606    fn default() -> Self {
607        Self {
608            timeout_seconds: 30,
609            content_type: "application/json".to_string(),
610        }
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::openapi_spec::{OpenApiOperation, OpenApiParameter, ParameterLocation};
618
619    #[test]
620    fn test_petstore_get_pet_by_id() {
621        let operation = OpenApiOperation {
622            operation_id: "getPetById".to_string(),
623            summary: Some("Find pet by ID".to_string()),
624            description: Some("Returns a single pet".to_string()),
625            method: "get".to_string(),
626            path: "/pet/{petId}".to_string(),
627            parameters: vec![OpenApiParameter {
628                name: "petId".to_string(),
629                location: ParameterLocation::Path,
630                description: Some("ID of pet to return".to_string()),
631                required: true,
632                param_type: "integer".to_string(),
633                schema: json!({
634                    "type": "integer",
635                    "format": "int64",
636                    "minimum": 1
637                }),
638            }],
639        };
640
641        let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
642        insta::assert_json_snapshot!(metadata);
643    }
644
645    #[test]
646    fn test_petstore_find_pets_by_status() {
647        let operation = OpenApiOperation {
648            operation_id: "findPetsByStatus".to_string(),
649            summary: Some("Finds Pets by status".to_string()),
650            description: Some(
651                "Multiple status values can be provided with comma separated strings".to_string(),
652            ),
653            method: "get".to_string(),
654            path: "/pet/findByStatus".to_string(),
655            parameters: vec![OpenApiParameter {
656                name: "status".to_string(),
657                location: ParameterLocation::Query,
658                description: Some(
659                    "Status values that need to be considered for filter".to_string(),
660                ),
661                required: true,
662                param_type: "array".to_string(),
663                schema: json!({
664                    "type": "array",
665                    "items": {
666                        "type": "string",
667                        "enum": ["available", "pending", "sold"]
668                    }
669                }),
670            }],
671        };
672
673        let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
674        insta::assert_json_snapshot!(metadata);
675    }
676
677    #[test]
678    fn test_petstore_add_pet() {
679        let operation = OpenApiOperation {
680            operation_id: "addPet".to_string(),
681            summary: Some("Add a new pet to the store".to_string()),
682            description: Some("Add a new pet to the store".to_string()),
683            method: "post".to_string(),
684            path: "/pet".to_string(),
685            parameters: vec![],
686        };
687
688        let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
689        insta::assert_json_snapshot!(metadata);
690    }
691
692    #[test]
693    fn test_petstore_update_pet_with_form() {
694        let operation = OpenApiOperation {
695            operation_id: "updatePetWithForm".to_string(),
696            summary: Some("Updates a pet in the store with form data".to_string()),
697            description: None,
698            method: "post".to_string(),
699            path: "/pet/{petId}".to_string(),
700            parameters: vec![
701                OpenApiParameter {
702                    name: "petId".to_string(),
703                    location: ParameterLocation::Path,
704                    description: Some("ID of pet that needs to be updated".to_string()),
705                    required: true,
706                    param_type: "integer".to_string(),
707                    schema: json!({
708                        "type": "integer",
709                        "format": "int64"
710                    }),
711                },
712                OpenApiParameter {
713                    name: "name".to_string(),
714                    location: ParameterLocation::Query,
715                    description: Some("Updated name of the pet".to_string()),
716                    required: false,
717                    param_type: "string".to_string(),
718                    schema: json!({
719                        "type": "string"
720                    }),
721                },
722                OpenApiParameter {
723                    name: "status".to_string(),
724                    location: ParameterLocation::Query,
725                    description: Some("Updated status of the pet".to_string()),
726                    required: false,
727                    param_type: "string".to_string(),
728                    schema: json!({
729                        "type": "string",
730                        "enum": ["available", "pending", "sold"]
731                    }),
732                },
733            ],
734        };
735
736        let metadata = ToolGenerator::generate_tool_metadata(&operation).unwrap();
737        insta::assert_json_snapshot!(metadata);
738    }
739}