1use 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#[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 pub fn new(provider: Arc<OpenApiProvider>) -> Self {
36 Self { provider }
37 }
38
39 pub fn provider(&self) -> &OpenApiProvider {
41 &self.provider
42 }
43
44 fn tool_name(op: &ExtractedOperation) -> String {
46 op.operation_id.clone().unwrap_or_else(|| {
47 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 fn resource_uri(op: &ExtractedOperation) -> String {
59 format!("openapi://{}{}", op.method.to_lowercase(), op.path)
60 }
61
62 fn build_input_schema(op: &ExtractedOperation) -> ToolInputSchema {
64 let mut properties = serde_json::Map::new();
65 let mut required = Vec::new();
66
67 for param in &op.parameters {
69 let mut param_schema = param.schema.clone().unwrap_or(json!({"type": "string"}));
70
71 if let Some(desc) = ¶m.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 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 fn find_tool_operation(&self, name: &str) -> Option<&ExtractedOperation> {
106 self.provider.tools().find(|op| Self::tool_name(op) == name)
107 }
108
109 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 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 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 if let Some(body) = args.get("body") {
154 request = request.json(body);
155 }
156
157 for param in &op.parameters {
159 if param.location == "header"
160 && let Some(value) = args.get(¶m.name)
161 {
162 let value_str = match value {
163 Value::String(s) => s.clone(),
164 _ => value.to_string(),
165 };
166 request = request.header(¶m.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 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 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 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}