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