Skip to main content

turbomcp_openapi/
handler.rs

1//! MCP handler implementation for OpenAPI operations.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use serde_json::{Value, json};
7use turbomcp_core::context::RequestContext;
8use turbomcp_core::error::{McpError, McpResult};
9use turbomcp_core::handler::McpHandler;
10use turbomcp_types::{
11    Prompt, PromptResult, Resource, ResourceResult, ServerInfo, Tool, ToolInputSchema,
12    ToolOutputSchema, ToolResult,
13};
14
15use crate::provider::{ExtractedOperation, OpenApiProvider};
16use crate::security::validate_url_for_ssrf;
17
18/// MCP handler that exposes OpenAPI operations as tools and resources.
19#[derive(Clone)]
20pub struct OpenApiHandler {
21    provider: Arc<OpenApiProvider>,
22}
23
24impl std::fmt::Debug for OpenApiHandler {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("OpenApiHandler")
27            .field("title", &self.provider.title())
28            .field("version", &self.provider.version())
29            .field("operations", &self.provider.operations().len())
30            .finish()
31    }
32}
33
34impl OpenApiHandler {
35    /// Create a new handler from a provider.
36    pub fn new(provider: Arc<OpenApiProvider>) -> Self {
37        Self { provider }
38    }
39
40    /// Get the underlying provider.
41    pub fn provider(&self) -> &OpenApiProvider {
42        &self.provider
43    }
44
45    /// Generate tool name from operation.
46    fn tool_name(op: &ExtractedOperation) -> String {
47        op.operation_id.clone().unwrap_or_else(|| {
48            // Generate name from method and path
49            let path_part = op
50                .path
51                .trim_start_matches('/')
52                .replace('/', "_")
53                .replace(['{', '}'], "");
54            format!("{}_{}", op.method.to_lowercase(), path_part)
55        })
56    }
57
58    /// Generate resource URI from operation.
59    fn resource_uri(op: &ExtractedOperation) -> String {
60        format!("openapi://{}{}", op.method.to_lowercase(), op.path)
61    }
62
63    /// Build JSON Schema for tool input.
64    fn build_input_schema(op: &ExtractedOperation) -> ToolInputSchema {
65        let mut properties = serde_json::Map::new();
66        let mut required = Vec::new();
67
68        // Add parameters
69        for param in &op.parameters {
70            let mut param_schema = param.schema.clone().unwrap_or(json!({"type": "string"}));
71
72            // Add description if available
73            if let Some(desc) = &param.description
74                && let Value::Object(ref mut map) = param_schema
75            {
76                map.insert("description".to_string(), json!(desc));
77            }
78
79            properties.insert(param.name.clone(), param_schema);
80
81            if param.required {
82                required.push(param.name.clone());
83            }
84        }
85
86        // Add request body if present
87        if let Some(body_schema) = &op.request_body_schema {
88            properties.insert("body".to_string(), body_schema.clone());
89            required.push("body".to_string());
90        }
91
92        // Carry the SEP-1613 default dialect by deferring to `Default::default`
93        // for `extra_keywords`, which now contains `$schema = 2020-12`.
94        ToolInputSchema {
95            schema_type: Some("object".into()),
96            properties: Some(Value::Object(properties)),
97            required: if required.is_empty() {
98                None
99            } else {
100                Some(required)
101            },
102            additional_properties: None,
103            ..ToolInputSchema::default()
104        }
105    }
106
107    /// Find operation by tool name.
108    fn find_tool_operation(&self, name: &str) -> Option<&ExtractedOperation> {
109        self.provider.tools().find(|op| Self::tool_name(op) == name)
110    }
111
112    /// Build the `meta` map shared by both tool and resource exposure paths.
113    /// Surfaces the operation's effective `security` requirements so MCP
114    /// clients can detect that auth is needed even when no `auth_provider` is
115    /// installed yet.
116    fn build_operation_meta(&self, op: &ExtractedOperation) -> HashMap<String, Value> {
117        let mut meta = HashMap::new();
118        meta.insert("method".to_string(), json!(op.method));
119        meta.insert("path".to_string(), json!(op.path));
120        if let Some(ref id) = op.operation_id {
121            meta.insert("operationId".to_string(), json!(id));
122        }
123        if !op.security.is_empty() {
124            meta.insert("security".to_string(), json!(&op.security));
125            // Surface the matching scheme definitions so a downstream client
126            // can render auth requirements without re-fetching the spec.
127            let referenced: HashMap<&String, &openapiv3::SecurityScheme> = op
128                .security
129                .iter()
130                .flat_map(|req| req.keys())
131                .filter_map(|name| {
132                    self.provider
133                        .security_schemes()
134                        .get(name)
135                        .map(|scheme| (name, scheme))
136                })
137                .collect();
138            if !referenced.is_empty()
139                && let Ok(value) = serde_json::to_value(&referenced)
140            {
141                meta.insert("securitySchemes".to_string(), value);
142            }
143        }
144        meta
145    }
146
147    /// Find operation by resource URI.
148    fn find_resource_operation(&self, uri: &str) -> Option<&ExtractedOperation> {
149        self.provider
150            .resources()
151            .find(|op| Self::resource_uri(op) == uri)
152    }
153
154    /// Execute an operation via HTTP.
155    ///
156    /// # Security
157    ///
158    /// This method validates URLs against SSRF attacks before making requests.
159    /// Requests to private IP ranges, localhost, and cloud metadata endpoints
160    /// are blocked.
161    async fn execute_operation(
162        &self,
163        op: &ExtractedOperation,
164        args: HashMap<String, Value>,
165    ) -> McpResult<Value> {
166        let url = self
167            .provider
168            .build_url(op, &args)
169            .map_err(|e| McpError::internal(e.to_string()))?;
170
171        // SSRF protection: validate URL before making request
172        validate_url_for_ssrf(&url).map_err(|e| McpError::internal(e.to_string()))?;
173
174        let client = self.provider.client();
175
176        let mut request = match op.method.as_str() {
177            "GET" => client.get(url),
178            "POST" => client.post(url),
179            "PUT" => client.put(url),
180            "DELETE" => client.delete(url),
181            "PATCH" => client.patch(url),
182            _ => {
183                return Err(McpError::internal(format!(
184                    "Unsupported method: {}",
185                    op.method
186                )));
187            }
188        };
189
190        // Add request body if present
191        if let Some(body) = args.get("body") {
192            request = request.json(body);
193        }
194
195        // Add header parameters
196        for param in &op.parameters {
197            if param.location == "header"
198                && let Some(value) = args.get(&param.name)
199            {
200                let value_str = match value {
201                    Value::String(s) => s.clone(),
202                    _ => value.to_string(),
203                };
204                request = request.header(&param.name, value_str);
205            }
206        }
207
208        // Inject auth credentials before sending. If the operation has security
209        // requirements but no provider is installed, the request still goes
210        // out — the upstream will return 401 and surface the misconfiguration.
211        if !op.security.is_empty()
212            && let Some(auth) = self.provider.auth_provider()
213        {
214            request = auth.apply(request, &op.security, self.provider.security_schemes());
215        }
216
217        let response = request
218            .send()
219            .await
220            .map_err(|e| McpError::internal(format!("HTTP request failed: {}", e)))?;
221
222        let status = response.status();
223        let body = response
224            .text()
225            .await
226            .map_err(|e| McpError::internal(format!("Failed to read response: {}", e)))?;
227
228        if !status.is_success() {
229            return Err(McpError::internal(format!(
230                "API returned {}: {}",
231                status, body
232            )));
233        }
234
235        // Try to parse as JSON, fallback to string
236        match serde_json::from_str(&body) {
237            Ok(json) => Ok(json),
238            Err(_) => Ok(json!(body)),
239        }
240    }
241}
242
243#[allow(clippy::manual_async_fn)]
244impl McpHandler for OpenApiHandler {
245    fn server_info(&self) -> ServerInfo {
246        ServerInfo::new(self.provider.title(), self.provider.version())
247    }
248
249    fn list_tools(&self) -> Vec<Tool> {
250        self.provider
251            .tools()
252            .map(|op| Tool {
253                name: Self::tool_name(op),
254                description: op.summary.clone().or_else(|| op.description.clone()),
255                input_schema: Self::build_input_schema(op),
256                title: op.summary.clone(),
257                icons: None,
258                annotations: None,
259                execution: None,
260                // MCP 2025-11-25 outputSchema: pulled from the operation's
261                // first 2xx `application/json` response with `$ref`s
262                // inlined. `None` for operations with no JSON response.
263                output_schema: op
264                    .response_schema
265                    .as_ref()
266                    .map(|v| ToolOutputSchema::from_value(v.clone())),
267                meta: Some(self.build_operation_meta(op)),
268            })
269            .collect()
270    }
271
272    fn list_resources(&self) -> Vec<Resource> {
273        self.provider
274            .resources()
275            .map(|op| Resource {
276                uri: Self::resource_uri(op),
277                name: op.operation_id.clone().unwrap_or_else(|| op.path.clone()),
278                description: op.summary.clone().or_else(|| op.description.clone()),
279                title: op.summary.clone(),
280                icons: None,
281                mime_type: Some("application/json".to_string()),
282                annotations: None,
283                size: None,
284                meta: Some(self.build_operation_meta(op)),
285            })
286            .collect()
287    }
288
289    fn list_prompts(&self) -> Vec<Prompt> {
290        // OpenAPI doesn't map to prompts
291        Vec::new()
292    }
293
294    fn call_tool<'a>(
295        &'a self,
296        name: &'a str,
297        args: Value,
298        _ctx: &'a RequestContext,
299    ) -> impl std::future::Future<Output = McpResult<ToolResult>> + turbomcp_core::marker::MaybeSend + 'a
300    {
301        async move {
302            let op = self
303                .find_tool_operation(name)
304                .ok_or_else(|| McpError::tool_not_found(name))?;
305
306            let args_map: HashMap<String, Value> = match args {
307                Value::Object(map) => map.into_iter().collect(),
308                Value::Null => HashMap::new(),
309                _ => {
310                    return Err(McpError::invalid_params(
311                        "Arguments must be an object or null",
312                    ));
313                }
314            };
315
316            let result = self.execute_operation(op, args_map).await?;
317
318            Ok(ToolResult::text(
319                serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()),
320            ))
321        }
322    }
323
324    fn read_resource<'a>(
325        &'a self,
326        uri: &'a str,
327        _ctx: &'a RequestContext,
328    ) -> impl std::future::Future<Output = McpResult<ResourceResult>>
329    + turbomcp_core::marker::MaybeSend
330    + 'a {
331        async move {
332            let op = self
333                .find_resource_operation(uri)
334                .ok_or_else(|| McpError::resource_not_found(uri))?;
335
336            // Resources are GET operations with no body
337            let result = self.execute_operation(op, HashMap::new()).await?;
338
339            let content =
340                serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string());
341
342            Ok(ResourceResult::text(uri, content))
343        }
344    }
345
346    fn get_prompt<'a>(
347        &'a self,
348        name: &'a str,
349        _args: Option<Value>,
350        _ctx: &'a RequestContext,
351    ) -> impl std::future::Future<Output = McpResult<PromptResult>> + turbomcp_core::marker::MaybeSend + 'a
352    {
353        async move { Err(McpError::prompt_not_found(name)) }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::McpType;
361
362    const TEST_SPEC: &str = r#"{
363        "openapi": "3.0.0",
364        "info": { "title": "Test", "version": "1.0" },
365        "paths": {
366            "/users": {
367                "get": { "operationId": "listUsers", "summary": "List users", "responses": { "200": { "description": "Success" } } },
368                "post": { "operationId": "createUser", "summary": "Create user", "responses": { "201": { "description": "Created" } } }
369            }
370        }
371    }"#;
372
373    #[test]
374    fn test_list_tools() {
375        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
376        let handler = provider.into_handler();
377
378        let tools = handler.list_tools();
379        assert_eq!(tools.len(), 1);
380        assert_eq!(tools[0].name, "createUser");
381    }
382
383    #[test]
384    fn test_list_resources() {
385        let provider = OpenApiProvider::from_string(TEST_SPEC).unwrap();
386        let handler = provider.into_handler();
387
388        let resources = handler.list_resources();
389        assert_eq!(resources.len(), 1);
390        assert_eq!(resources[0].name, "listUsers");
391    }
392
393    #[test]
394    fn test_tool_name_generation() {
395        let op_with_id = ExtractedOperation {
396            method: "POST".to_string(),
397            path: "/users".to_string(),
398            operation_id: Some("createUser".to_string()),
399            summary: None,
400            description: None,
401            parameters: vec![],
402            request_body_schema: None,
403            mcp_type: McpType::Tool,
404            security: Vec::new(),
405            response_schema: None,
406        };
407
408        let op_without_id = ExtractedOperation {
409            method: "DELETE".to_string(),
410            path: "/users/{id}".to_string(),
411            operation_id: None,
412            summary: None,
413            description: None,
414            parameters: vec![],
415            request_body_schema: None,
416            mcp_type: McpType::Tool,
417            security: Vec::new(),
418            response_schema: None,
419        };
420
421        assert_eq!(OpenApiHandler::tool_name(&op_with_id), "createUser");
422        assert_eq!(OpenApiHandler::tool_name(&op_without_id), "delete_users_id");
423    }
424}