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