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,
12 ToolOutputSchema, ToolResult,
13};
14
15use crate::provider::{ExtractedOperation, OpenApiProvider};
16use crate::security::validate_url_for_ssrf;
17
18#[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 pub fn new(provider: Arc<OpenApiProvider>) -> Self {
37 Self { provider }
38 }
39
40 pub fn provider(&self) -> &OpenApiProvider {
42 &self.provider
43 }
44
45 fn tool_name(op: &ExtractedOperation) -> String {
47 op.operation_id.clone().unwrap_or_else(|| {
48 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 fn resource_uri(op: &ExtractedOperation) -> String {
60 format!("openapi://{}{}", op.method.to_lowercase(), op.path)
61 }
62
63 fn build_input_schema(op: &ExtractedOperation) -> ToolInputSchema {
65 let mut properties = serde_json::Map::new();
66 let mut required = Vec::new();
67
68 for param in &op.parameters {
70 let mut param_schema = param.schema.clone().unwrap_or(json!({"type": "string"}));
71
72 if let Some(desc) = ¶m.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 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 fn find_tool_operation(&self, name: &str) -> Option<&ExtractedOperation> {
107 self.provider.tools().find(|op| Self::tool_name(op) == name)
108 }
109
110 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 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 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 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 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 if let Some(body) = args.get("body") {
190 request = request.json(body);
191 }
192
193 for param in &op.parameters {
195 if param.location == "header"
196 && let Some(value) = args.get(¶m.name)
197 {
198 let value_str = match value {
199 Value::String(s) => s.clone(),
200 _ => value.to_string(),
201 };
202 request = request.header(¶m.name, value_str);
203 }
204 }
205
206 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 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 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 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 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}