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