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