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