Skip to main content

turbomcp_openapi/
provider.rs

1//! OpenAPI provider for generating MCP components from OpenAPI specs.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::Arc;
6
7use openapiv3::{OpenAPI, Operation, Parameter, ParameterSchemaOrContent, ReferenceOr, Schema};
8use serde_json::{Value, json};
9use url::Url;
10
11use crate::error::{OpenApiError, Result};
12use crate::handler::OpenApiHandler;
13use crate::mapping::{McpType, RouteMapping};
14use crate::parser::{fetch_from_url, load_from_file, parse_spec};
15
16/// An operation extracted from an OpenAPI spec.
17#[derive(Debug, Clone)]
18pub struct ExtractedOperation {
19    /// HTTP method (GET, POST, etc.)
20    pub method: String,
21    /// Path template (e.g., "/users/{id}")
22    pub path: String,
23    /// Operation ID (if specified)
24    pub operation_id: Option<String>,
25    /// Summary/description
26    pub summary: Option<String>,
27    /// Operation description
28    pub description: Option<String>,
29    /// Parameters
30    pub parameters: Vec<ExtractedParameter>,
31    /// Request body schema (if any)
32    pub request_body_schema: Option<Value>,
33    /// What MCP type this maps to
34    pub mcp_type: McpType,
35}
36
37/// A parameter extracted from an OpenAPI operation.
38#[derive(Debug, Clone)]
39pub struct ExtractedParameter {
40    /// Parameter name
41    pub name: String,
42    /// Where the parameter goes (path, query, header, cookie)
43    pub location: String,
44    /// Whether the parameter is required
45    pub required: bool,
46    /// Description
47    pub description: Option<String>,
48    /// JSON Schema for the parameter
49    pub schema: Option<Value>,
50}
51
52/// Default request timeout in seconds.
53const DEFAULT_TIMEOUT_SECS: u64 = 30;
54
55/// OpenAPI to MCP provider.
56///
57/// This provider parses OpenAPI specifications and converts them to MCP
58/// tools and resources that can be used with a TurboMCP server.
59///
60/// # Security
61///
62/// The provider includes built-in SSRF protection that blocks requests to:
63/// - Localhost and loopback addresses (127.0.0.0/8, ::1)
64/// - Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
65/// - Link-local addresses (169.254.0.0/16) including cloud metadata endpoints
66/// - Other reserved ranges
67///
68/// Requests have a default timeout of 30 seconds to prevent slowloris attacks.
69#[derive(Debug)]
70pub struct OpenApiProvider {
71    /// The parsed OpenAPI specification
72    spec: OpenAPI,
73    /// Base URL for API calls
74    base_url: Option<Url>,
75    /// Route mapping configuration
76    mapping: RouteMapping,
77    /// HTTP client for making API calls
78    client: reqwest::Client,
79    /// Extracted operations
80    operations: Vec<ExtractedOperation>,
81    /// Request timeout
82    timeout: std::time::Duration,
83}
84
85impl OpenApiProvider {
86    /// Create a provider from a parsed OpenAPI specification.
87    pub fn from_spec(spec: OpenAPI) -> Self {
88        let mapping = RouteMapping::default_rules();
89        let timeout = std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS);
90        let client = reqwest::Client::builder()
91            .timeout(timeout)
92            .build()
93            .unwrap_or_else(|_| reqwest::Client::new());
94
95        let mut provider = Self {
96            spec,
97            base_url: None,
98            mapping,
99            client,
100            operations: Vec::new(),
101            timeout,
102        };
103        provider.extract_operations();
104        provider
105    }
106
107    /// Create a provider from an OpenAPI specification string.
108    pub fn from_string(content: &str) -> Result<Self> {
109        let spec = parse_spec(content)?;
110        Ok(Self::from_spec(spec))
111    }
112
113    /// Create a provider by loading from a file.
114    pub fn from_file(path: &Path) -> Result<Self> {
115        let spec = load_from_file(path)?;
116        Ok(Self::from_spec(spec))
117    }
118
119    /// Create a provider by fetching from a URL.
120    pub async fn from_url(url: &str) -> Result<Self> {
121        let spec = fetch_from_url(url).await?;
122        Ok(Self::from_spec(spec))
123    }
124
125    /// Set the base URL for API calls.
126    pub fn with_base_url(mut self, base_url: &str) -> Result<Self> {
127        self.base_url = Some(Url::parse(base_url)?);
128        Ok(self)
129    }
130
131    /// Set a custom route mapping configuration.
132    #[must_use]
133    pub fn with_route_mapping(mut self, mapping: RouteMapping) -> Self {
134        self.mapping = mapping;
135        self.extract_operations(); // Re-extract with new mapping
136        self
137    }
138
139    /// Set a custom HTTP client.
140    ///
141    /// # Warning
142    ///
143    /// When using a custom client, ensure it has appropriate timeout settings.
144    /// The default client uses a 30-second timeout.
145    #[must_use]
146    pub fn with_client(mut self, client: reqwest::Client) -> Self {
147        self.client = client;
148        self
149    }
150
151    /// Set a custom request timeout.
152    ///
153    /// This rebuilds the HTTP client with the new timeout. The default timeout
154    /// is 30 seconds.
155    #[must_use]
156    pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
157        self.timeout = timeout;
158        self.client = reqwest::Client::builder()
159            .timeout(timeout)
160            .build()
161            .unwrap_or_else(|_| reqwest::Client::new());
162        self
163    }
164
165    /// Get the current request timeout.
166    pub fn timeout(&self) -> std::time::Duration {
167        self.timeout
168    }
169
170    /// Get the API title from the spec.
171    pub fn title(&self) -> &str {
172        &self.spec.info.title
173    }
174
175    /// Get the API version from the spec.
176    pub fn version(&self) -> &str {
177        &self.spec.info.version
178    }
179
180    /// Get all extracted operations.
181    pub fn operations(&self) -> &[ExtractedOperation] {
182        &self.operations
183    }
184
185    /// Get operations that map to MCP tools.
186    pub fn tools(&self) -> impl Iterator<Item = &ExtractedOperation> {
187        self.operations
188            .iter()
189            .filter(|op| op.mcp_type == McpType::Tool)
190    }
191
192    /// Get operations that map to MCP resources.
193    pub fn resources(&self) -> impl Iterator<Item = &ExtractedOperation> {
194        self.operations
195            .iter()
196            .filter(|op| op.mcp_type == McpType::Resource)
197    }
198
199    /// Convert this provider into an McpHandler.
200    pub fn into_handler(self) -> OpenApiHandler {
201        OpenApiHandler::new(Arc::new(self))
202    }
203
204    /// Extract operations from the OpenAPI spec.
205    fn extract_operations(&mut self) {
206        self.operations.clear();
207
208        for (path, path_item) in &self.spec.paths.paths {
209            let path_item = match path_item {
210                ReferenceOr::Item(item) => item,
211                ReferenceOr::Reference { .. } => continue, // Skip references for now
212            };
213
214            // Extract operations for each HTTP method
215            let methods = [
216                ("GET", &path_item.get),
217                ("POST", &path_item.post),
218                ("PUT", &path_item.put),
219                ("DELETE", &path_item.delete),
220                ("PATCH", &path_item.patch),
221            ];
222
223            for (method, operation) in methods {
224                if let Some(op) = operation {
225                    let mcp_type = self.mapping.get_mcp_type(method, path);
226                    if mcp_type == McpType::Skip {
227                        continue;
228                    }
229
230                    self.operations
231                        .push(self.extract_operation(method, path, op, mcp_type));
232                }
233            }
234        }
235    }
236
237    /// Extract a single operation.
238    fn extract_operation(
239        &self,
240        method: &str,
241        path: &str,
242        operation: &Operation,
243        mcp_type: McpType,
244    ) -> ExtractedOperation {
245        let parameters = operation
246            .parameters
247            .iter()
248            .filter_map(|p| match p {
249                ReferenceOr::Item(param) => Some(self.extract_parameter(param)),
250                ReferenceOr::Reference { .. } => None,
251            })
252            .collect();
253
254        let request_body_schema = operation.request_body.as_ref().and_then(|rb| match rb {
255            ReferenceOr::Item(body) => body
256                .content
257                .get("application/json")
258                .and_then(|mt| mt.schema.as_ref())
259                .and_then(|s| self.schema_to_json(s)),
260            ReferenceOr::Reference { .. } => None,
261        });
262
263        ExtractedOperation {
264            method: method.to_string(),
265            path: path.to_string(),
266            operation_id: operation.operation_id.clone(),
267            summary: operation.summary.clone(),
268            description: operation.description.clone(),
269            parameters,
270            request_body_schema,
271            mcp_type,
272        }
273    }
274
275    /// Extract a parameter definition.
276    fn extract_parameter(&self, param: &Parameter) -> ExtractedParameter {
277        let (name, location, required, description, schema) = match param {
278            Parameter::Query { parameter_data, .. } => (
279                parameter_data.name.clone(),
280                "query".to_string(),
281                parameter_data.required,
282                parameter_data.description.clone(),
283                self.extract_param_schema(&parameter_data.format),
284            ),
285            Parameter::Header { parameter_data, .. } => (
286                parameter_data.name.clone(),
287                "header".to_string(),
288                parameter_data.required,
289                parameter_data.description.clone(),
290                self.extract_param_schema(&parameter_data.format),
291            ),
292            Parameter::Path { parameter_data, .. } => (
293                parameter_data.name.clone(),
294                "path".to_string(),
295                true, // Path params are always required
296                parameter_data.description.clone(),
297                self.extract_param_schema(&parameter_data.format),
298            ),
299            Parameter::Cookie { parameter_data, .. } => (
300                parameter_data.name.clone(),
301                "cookie".to_string(),
302                parameter_data.required,
303                parameter_data.description.clone(),
304                self.extract_param_schema(&parameter_data.format),
305            ),
306        };
307
308        ExtractedParameter {
309            name,
310            location,
311            required,
312            description,
313            schema,
314        }
315    }
316
317    /// Extract schema from parameter format.
318    fn extract_param_schema(&self, format: &ParameterSchemaOrContent) -> Option<Value> {
319        match format {
320            ParameterSchemaOrContent::Schema(schema) => self.schema_to_json(schema),
321            ParameterSchemaOrContent::Content(_) => None,
322        }
323    }
324
325    /// Convert an OpenAPI schema to a JSON Schema value, inlining `$ref`s
326    /// against `components.schemas`.
327    ///
328    /// OpenAPI lets schemas reference each other through
329    /// `{"$ref": "#/components/schemas/Foo"}`. MCP tool-input schemas have
330    /// no cross-operation component dictionary to share, so we resolve those
331    /// refs inline. Cycles are broken by leaving the first re-visited
332    /// reference as a `$ref` literal rather than expanding it forever.
333    fn schema_to_json(&self, schema: &ReferenceOr<Schema>) -> Option<Value> {
334        let initial = match schema {
335            ReferenceOr::Item(s) => serde_json::to_value(s).ok()?,
336            ReferenceOr::Reference { reference } => {
337                json!({ "$ref": reference })
338            }
339        };
340        let mut visited = std::collections::HashSet::new();
341        Some(self.resolve_refs(initial, &mut visited))
342    }
343
344    /// Recursively inline `$ref` pointers that target `components.schemas`.
345    ///
346    /// `visited` tracks the ref path currently being expanded; re-encountering
347    /// the same pointer during expansion leaves the `$ref` in place so the
348    /// output stays finite on self-referential schemas (the default interpretation
349    /// consumers do — most JSON Schema validators understand internal `$ref`).
350    fn resolve_refs(&self, value: Value, visited: &mut std::collections::HashSet<String>) -> Value {
351        match value {
352            Value::Object(mut map) => {
353                if let Some(Value::String(reference)) = map.get("$ref").cloned()
354                    && map.len() == 1
355                {
356                    if !visited.insert(reference.clone()) {
357                        map.insert("$ref".to_string(), Value::String(reference));
358                        return Value::Object(map);
359                    }
360                    let expanded = self.lookup_ref(&reference).map(|target| {
361                        let target_json = serde_json::to_value(target).unwrap_or(Value::Null);
362                        self.resolve_refs(target_json, visited)
363                    });
364                    visited.remove(&reference);
365                    return expanded.unwrap_or(Value::Object({
366                        let mut fallback = serde_json::Map::new();
367                        fallback.insert("$ref".to_string(), Value::String(reference));
368                        fallback
369                    }));
370                }
371                let resolved = map
372                    .into_iter()
373                    .map(|(k, v)| (k, self.resolve_refs(v, visited)))
374                    .collect();
375                Value::Object(resolved)
376            }
377            Value::Array(items) => Value::Array(
378                items
379                    .into_iter()
380                    .map(|v| self.resolve_refs(v, visited))
381                    .collect(),
382            ),
383            other => other,
384        }
385    }
386
387    /// Look up a `#/components/schemas/Name` reference in the parsed spec.
388    fn lookup_ref(&self, reference: &str) -> Option<&Schema> {
389        const PREFIX: &str = "#/components/schemas/";
390        let name = reference.strip_prefix(PREFIX)?;
391        let components = self.spec.components.as_ref()?;
392        let entry = components.schemas.get(name)?;
393        match entry {
394            ReferenceOr::Item(schema) => Some(schema),
395            ReferenceOr::Reference { reference } => {
396                // Single level of indirection; avoid unbounded recursion.
397                let nested_name = reference.strip_prefix(PREFIX)?;
398                match components.schemas.get(nested_name)? {
399                    ReferenceOr::Item(schema) => Some(schema),
400                    ReferenceOr::Reference { .. } => None,
401                }
402            }
403        }
404    }
405
406    /// Build the full URL for an operation.
407    pub(crate) fn build_url(
408        &self,
409        operation: &ExtractedOperation,
410        args: &HashMap<String, Value>,
411    ) -> Result<Url> {
412        let base = self.base_url.as_ref().ok_or(OpenApiError::NoBaseUrl)?;
413
414        // Replace path parameters
415        let mut path = operation.path.clone();
416        for param in &operation.parameters {
417            if param.location == "path" {
418                if let Some(value) = args.get(&param.name) {
419                    let value_str = match value {
420                        Value::String(s) => s.clone(),
421                        _ => value.to_string(),
422                    };
423                    path = path.replace(&format!("{{{}}}", param.name), &value_str);
424                } else if param.required {
425                    return Err(OpenApiError::MissingParameter(param.name.clone()));
426                }
427            }
428        }
429
430        let mut url = base.join(&path)?;
431
432        // Collect query parameters first
433        let mut query_params: Vec<(String, String)> = Vec::new();
434        for param in &operation.parameters {
435            if param.location == "query" {
436                if let Some(value) = args.get(&param.name) {
437                    let value_str = match value {
438                        Value::String(s) => s.clone(),
439                        Value::Bool(b) => b.to_string(),
440                        Value::Number(n) => n.to_string(),
441                        _ => value.to_string(),
442                    };
443                    query_params.push((param.name.clone(), value_str));
444                } else if param.required {
445                    return Err(OpenApiError::MissingParameter(param.name.clone()));
446                }
447            }
448        }
449
450        // Only add query string if there are parameters
451        if !query_params.is_empty() {
452            let mut query_pairs = url.query_pairs_mut();
453            for (key, value) in query_params {
454                query_pairs.append_pair(&key, &value);
455            }
456        }
457
458        Ok(url)
459    }
460
461    /// Get the HTTP client.
462    pub(crate) fn client(&self) -> &reqwest::Client {
463        &self.client
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    const TEST_SPEC: &str = r#"{
472        "openapi": "3.0.0",
473        "info": {
474            "title": "Test API",
475            "version": "1.0.0"
476        },
477        "paths": {
478            "/users": {
479                "get": {
480                    "operationId": "listUsers",
481                    "summary": "List all users",
482                    "responses": { "200": { "description": "Success" } }
483                },
484                "post": {
485                    "operationId": "createUser",
486                    "summary": "Create a user",
487                    "responses": { "201": { "description": "Created" } }
488                }
489            },
490            "/users/{id}": {
491                "get": {
492                    "operationId": "getUser",
493                    "summary": "Get a user by ID",
494                    "parameters": [
495                        {
496                            "name": "id",
497                            "in": "path",
498                            "required": true,
499                            "schema": { "type": "string" }
500                        }
501                    ],
502                    "responses": { "200": { "description": "Success" } }
503                },
504                "delete": {
505                    "operationId": "deleteUser",
506                    "summary": "Delete a user",
507                    "parameters": [
508                        {
509                            "name": "id",
510                            "in": "path",
511                            "required": true,
512                            "schema": { "type": "string" }
513                        }
514                    ],
515                    "responses": { "204": { "description": "Deleted" } }
516                }
517            }
518        }
519    }"#;
520
521    #[test]
522    fn test_provider_from_string() {
523        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
524
525        assert_eq!(provider.title(), "Test API");
526        assert_eq!(provider.version(), "1.0.0");
527    }
528
529    #[test]
530    fn test_operation_extraction() {
531        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
532
533        assert_eq!(provider.operations().len(), 4);
534
535        // Check GET /users is a resource
536        let list_users = provider
537            .operations()
538            .iter()
539            .find(|op| op.operation_id.as_deref() == Some("listUsers"))
540            .unwrap();
541        assert_eq!(list_users.mcp_type, McpType::Resource);
542        assert_eq!(list_users.method, "GET");
543
544        // Check POST /users is a tool
545        let create_user = provider
546            .operations()
547            .iter()
548            .find(|op| op.operation_id.as_deref() == Some("createUser"))
549            .unwrap();
550        assert_eq!(create_user.mcp_type, McpType::Tool);
551        assert_eq!(create_user.method, "POST");
552    }
553
554    #[test]
555    fn test_tools_and_resources() {
556        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
557
558        let tools: Vec<_> = provider.tools().collect();
559        let resources: Vec<_> = provider.resources().collect();
560
561        // GET operations -> resources
562        assert_eq!(resources.len(), 2);
563        // POST, DELETE operations -> tools
564        assert_eq!(tools.len(), 2);
565    }
566
567    #[test]
568    fn test_build_url_with_path_params() {
569        let provider = OpenApiProvider::from_string(TEST_SPEC)
570            .unwrap()
571            .with_base_url("https://api.example.com")
572            .unwrap();
573
574        let get_user = provider
575            .operations()
576            .iter()
577            .find(|op| op.operation_id.as_deref() == Some("getUser"))
578            .unwrap();
579
580        let mut args = HashMap::new();
581        args.insert("id".to_string(), json!("123"));
582
583        let url = provider.build_url(get_user, &args).unwrap();
584        assert_eq!(url.as_str(), "https://api.example.com/users/123");
585    }
586
587    #[test]
588    fn test_ref_resolution_inlines_components() {
589        const REF_SPEC: &str = r##"{
590            "openapi": "3.0.0",
591            "info": { "title": "T", "version": "1.0.0" },
592            "paths": {
593                "/pets": {
594                    "post": {
595                        "operationId": "createPet",
596                        "requestBody": {
597                            "content": {
598                                "application/json": {
599                                    "schema": { "$ref": "#/components/schemas/Pet" }
600                                }
601                            }
602                        },
603                        "responses": { "201": { "description": "ok" } }
604                    }
605                }
606            },
607            "components": {
608                "schemas": {
609                    "Pet": {
610                        "type": "object",
611                        "properties": {
612                            "name": { "type": "string" },
613                            "owner": { "$ref": "#/components/schemas/Owner" }
614                        }
615                    },
616                    "Owner": {
617                        "type": "object",
618                        "properties": {
619                            "email": { "type": "string" }
620                        }
621                    }
622                }
623            }
624        }"##;
625
626        let provider = OpenApiProvider::from_string(REF_SPEC).unwrap();
627        let op = provider
628            .operations()
629            .iter()
630            .find(|o| o.operation_id.as_deref() == Some("createPet"))
631            .expect("createPet operation");
632        let body = op.request_body_schema.as_ref().expect("body schema");
633        let props = body.get("properties").expect("properties");
634        let owner = props.get("owner").expect("owner property");
635        // The owner $ref must have been replaced by the inlined Owner schema.
636        assert!(
637            owner.get("$ref").is_none(),
638            "owner $ref was not inlined: {owner}"
639        );
640        let owner_props = owner.get("properties").expect("owner inlined properties");
641        assert!(owner_props.get("email").is_some());
642    }
643
644    #[test]
645    fn test_ref_resolution_handles_cycles() {
646        const CYCLE_SPEC: &str = r##"{
647            "openapi": "3.0.0",
648            "info": { "title": "T", "version": "1.0.0" },
649            "paths": {
650                "/n": {
651                    "post": {
652                        "operationId": "makeNode",
653                        "requestBody": {
654                            "content": {
655                                "application/json": {
656                                    "schema": { "$ref": "#/components/schemas/Node" }
657                                }
658                            }
659                        },
660                        "responses": { "201": { "description": "ok" } }
661                    }
662                }
663            },
664            "components": {
665                "schemas": {
666                    "Node": {
667                        "type": "object",
668                        "properties": {
669                            "next": { "$ref": "#/components/schemas/Node" }
670                        }
671                    }
672                }
673            }
674        }"##;
675
676        let provider = OpenApiProvider::from_string(CYCLE_SPEC).unwrap();
677        let op = provider
678            .operations()
679            .iter()
680            .find(|o| o.operation_id.as_deref() == Some("makeNode"))
681            .unwrap();
682        // Must not infinite-loop or panic — resolver should have returned a finite value
683        // with the inner cycle preserved as a $ref.
684        let body = op.request_body_schema.as_ref().unwrap();
685        let next = body.pointer("/properties/next").expect("next property");
686        assert_eq!(
687            next.get("$ref").and_then(|v| v.as_str()),
688            Some("#/components/schemas/Node")
689        );
690    }
691
692    #[test]
693    fn test_missing_required_param() {
694        let provider = OpenApiProvider::from_string(TEST_SPEC)
695            .unwrap()
696            .with_base_url("https://api.example.com")
697            .unwrap();
698
699        let get_user = provider
700            .operations()
701            .iter()
702            .find(|op| op.operation_id.as_deref() == Some("getUser"))
703            .unwrap();
704
705        let args = HashMap::new(); // Missing 'id'
706
707        let result = provider.build_url(get_user, &args);
708        assert!(matches!(result, Err(OpenApiError::MissingParameter(_))));
709    }
710}