rmcp_openapi/
server.rs

1use rmcp::{
2    RoleServer, ServerHandler,
3    model::{
4        CallToolRequestParam, CallToolResult, Content, ErrorData, Implementation, InitializeResult,
5        ListToolsResult, PaginatedRequestParam, ProtocolVersion, ServerCapabilities, Tool,
6        ToolAnnotations, ToolsCapability,
7    },
8    service::RequestContext,
9};
10use serde_json::{Value, json};
11use std::sync::Arc;
12use url::Url;
13
14use crate::error::{OpenApiError, ToolCallError, ToolCallExecutionError, ToolCallValidationError};
15use crate::http_client::HttpClient;
16use crate::openapi::OpenApiSpecLocation;
17use crate::tool_registry::ToolRegistry;
18
19#[derive(Clone)]
20pub struct OpenApiServer {
21    pub spec_location: OpenApiSpecLocation,
22    pub registry: Arc<ToolRegistry>,
23    pub http_client: HttpClient,
24    pub base_url: Option<Url>,
25}
26
27/// Internal metadata for tools generated from OpenAPI operations.
28///
29/// This struct contains all the information needed to execute HTTP requests
30/// and is used internally by the OpenAPI server. It includes fields that are
31/// not part of the MCP specification but are necessary for HTTP execution.
32///
33/// For MCP compliance, this struct is converted to `rmcp::model::Tool` using
34/// the `From` trait implementation, which only includes MCP-compliant fields.
35#[derive(Debug, Clone, serde::Serialize)]
36pub struct ToolMetadata {
37    /// Tool name - exposed to MCP clients
38    pub name: String,
39    /// Tool title - human-readable display name exposed to MCP clients
40    pub title: Option<String>,
41    /// Tool description - exposed to MCP clients  
42    pub description: String,
43    /// Input parameters schema - exposed to MCP clients as `inputSchema`
44    pub parameters: Value,
45    /// Output schema - exposed to MCP clients as `outputSchema`
46    pub output_schema: Option<Value>,
47    /// HTTP method (GET, POST, etc.) - internal only, not exposed to MCP
48    pub method: String,
49    /// URL path for the API endpoint - internal only, not exposed to MCP
50    pub path: String,
51}
52
53/// Converts internal `ToolMetadata` to MCP-compliant `Tool`.
54///
55/// This implementation ensures that only MCP-compliant fields are exposed to clients.
56/// Internal fields like `method` and `path` are not included in the conversion.
57impl From<&ToolMetadata> for Tool {
58    fn from(metadata: &ToolMetadata) -> Self {
59        // Convert parameters to the expected Arc<Map> format
60        let input_schema = if let Value::Object(obj) = &metadata.parameters {
61            Arc::new(obj.clone())
62        } else {
63            Arc::new(serde_json::Map::new())
64        };
65
66        // Convert output_schema to the expected Arc<Map> format if present
67        let output_schema = metadata.output_schema.as_ref().and_then(|schema| {
68            if let Value::Object(obj) = schema {
69                Some(Arc::new(obj.clone()))
70            } else {
71                None
72            }
73        });
74
75        // Create annotations with title if present
76        let annotations = metadata.title.as_ref().map(|title| ToolAnnotations {
77            title: Some(title.clone()),
78            ..Default::default()
79        });
80
81        Tool {
82            name: metadata.name.clone().into(),
83            description: Some(metadata.description.clone().into()),
84            input_schema,
85            output_schema,
86            annotations,
87            // TODO: Consider migration to Tool.title when rmcp supports MCP 2025-06-18 (see issue #26)
88        }
89    }
90}
91
92impl OpenApiServer {
93    #[must_use]
94    pub fn new(spec_location: OpenApiSpecLocation) -> Self {
95        Self {
96            spec_location,
97            registry: Arc::new(ToolRegistry::new()),
98            http_client: HttpClient::new(),
99            base_url: None,
100        }
101    }
102
103    /// Create a new server with a base URL for API calls
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the base URL is invalid
108    pub fn with_base_url(
109        spec_location: OpenApiSpecLocation,
110        base_url: Url,
111    ) -> Result<Self, OpenApiError> {
112        let http_client = HttpClient::new().with_base_url(base_url.clone())?;
113        Ok(Self {
114            spec_location,
115            registry: Arc::new(ToolRegistry::new()),
116            http_client,
117            base_url: Some(base_url),
118        })
119    }
120
121    /// Load the `OpenAPI` specification from the configured location
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the spec cannot be loaded or registered
126    pub async fn load_openapi_spec(&mut self) -> Result<(), OpenApiError> {
127        // Load the OpenAPI specification using the new simplified approach
128        let spec = self.spec_location.load_spec().await?;
129        self.register_spec(spec)
130    }
131
132    /// Register a spec into the registry. This requires exclusive access to the server.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the registry is already shared or if spec registration fails
137    pub fn register_spec(&mut self, spec: crate::openapi::OpenApiSpec) -> Result<(), OpenApiError> {
138        // During initialization, we should have exclusive access to the Arc
139        let registry = Arc::get_mut(&mut self.registry)
140            .ok_or_else(|| OpenApiError::McpError("Registry is already shared".to_string()))?;
141        let registered_count = registry.register_from_spec(spec)?;
142
143        println!("Loaded {registered_count} tools from OpenAPI spec");
144        println!("Registry stats: {}", self.registry.get_stats().summary());
145
146        Ok(())
147    }
148
149    /// Get the number of registered tools
150    #[must_use]
151    pub fn tool_count(&self) -> usize {
152        self.registry.tool_count()
153    }
154
155    /// Get all tool names
156    #[must_use]
157    pub fn get_tool_names(&self) -> Vec<String> {
158        self.registry.get_tool_names()
159    }
160
161    /// Check if a specific tool exists
162    #[must_use]
163    pub fn has_tool(&self, name: &str) -> bool {
164        self.registry.has_tool(name)
165    }
166
167    /// Get registry statistics
168    #[must_use]
169    pub fn get_registry_stats(&self) -> crate::tool_registry::ToolRegistryStats {
170        self.registry.get_stats()
171    }
172
173    /// Validate the registry integrity
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the registry validation fails
178    pub fn validate_registry(&self) -> Result<(), OpenApiError> {
179        self.registry.validate_registry()
180    }
181}
182
183impl ServerHandler for OpenApiServer {
184    fn get_info(&self) -> InitializeResult {
185        InitializeResult {
186            protocol_version: ProtocolVersion::V_2024_11_05,
187            server_info: Implementation {
188                name: "OpenAPI MCP Server".to_string(),
189                version: "0.1.0".to_string(),
190            },
191            capabilities: ServerCapabilities {
192                tools: Some(ToolsCapability {
193                    list_changed: Some(false),
194                }),
195                ..Default::default()
196            },
197            instructions: Some("Exposes OpenAPI endpoints as MCP tools".to_string()),
198        }
199    }
200
201    async fn list_tools(
202        &self,
203        _request: Option<PaginatedRequestParam>,
204        _context: RequestContext<RoleServer>,
205    ) -> Result<ListToolsResult, ErrorData> {
206        let mut tools = Vec::new();
207
208        // Convert all registered tools to MCP Tool format
209        for tool_metadata in self.registry.get_all_tools() {
210            let tool = Tool::from(tool_metadata);
211            tools.push(tool);
212        }
213
214        Ok(ListToolsResult {
215            tools,
216            next_cursor: None,
217        })
218    }
219
220    async fn call_tool(
221        &self,
222        request: CallToolRequestParam,
223        _context: RequestContext<RoleServer>,
224    ) -> Result<CallToolResult, ErrorData> {
225        // Check if tool exists in registry
226        if let Some(tool_metadata) = self.registry.get_tool(&request.name) {
227            let arguments = request.arguments.unwrap_or_default();
228            let arguments_value = Value::Object(arguments.clone());
229
230            // Execute the HTTP request
231            match self
232                .http_client
233                .execute_tool_call(tool_metadata, &arguments_value)
234                .await
235            {
236                Ok(response) => {
237                    // Check if the tool has an output schema
238                    let structured_content = if tool_metadata.output_schema.is_some() {
239                        // Try to parse the response body as JSON
240                        match response.json() {
241                            Ok(json_value) => {
242                                // Wrap the response in our standard HTTP response structure
243                                Some(json!({
244                                    "status": response.status_code,
245                                    "body": json_value
246                                }))
247                            }
248                            Err(_) => None, // If parsing fails, fall back to text content
249                        }
250                    } else {
251                        None
252                    };
253
254                    // For structured content, serialize to JSON for backwards compatibility
255                    let content = if let Some(ref structured) = structured_content {
256                        // MCP Specification: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
257                        // "For backwards compatibility, a tool that returns structured content SHOULD also
258                        // return the serialized JSON in a TextContent block."
259                        match serde_json::to_string(structured) {
260                            Ok(json_string) => Some(vec![Content::text(json_string)]),
261                            Err(e) => {
262                                // Return error if we can't serialize the structured content
263                                let error = ToolCallError::Execution(
264                                    ToolCallExecutionError::ResponseParsingError {
265                                        reason: format!(
266                                            "Failed to serialize structured content: {e}"
267                                        ),
268                                        raw_response: None,
269                                    },
270                                );
271                                return Err(error.into());
272                            }
273                        }
274                    } else {
275                        Some(vec![Content::text(response.to_mcp_content())])
276                    };
277
278                    // Return successful response
279                    Ok(CallToolResult {
280                        content,
281                        structured_content,
282                        is_error: Some(!response.is_success),
283                    })
284                }
285                Err(e) => {
286                    // Convert ToolCallError to ErrorData and return as error
287                    Err(e.into())
288                }
289            }
290        } else {
291            // Generate tool name suggestions when tool not found
292            let tool_names = self.registry.get_tool_names();
293            let tool_name_refs: Vec<&str> = tool_names.iter().map(|s| s.as_str()).collect();
294            let suggestions = crate::find_similar_strings(&request.name, &tool_name_refs);
295
296            // Create ToolCallValidationError with suggestions
297            let error = ToolCallValidationError::ToolNotFound {
298                tool_name: request.name.to_string(),
299                suggestions,
300            };
301            Err(error.into())
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::error::ToolCallError;
310
311    #[test]
312    fn test_tool_not_found_error_with_suggestions() {
313        // Create a server with test tools
314        let mut server = OpenApiServer::new(OpenApiSpecLocation::Url(
315            Url::parse("test://example").unwrap(),
316        ));
317
318        // Create test tool metadata
319        let tool1 = ToolMetadata {
320            name: "getPetById".to_string(),
321            title: Some("Get Pet by ID".to_string()),
322            description: "Find pet by ID".to_string(),
323            parameters: json!({
324                "type": "object",
325                "properties": {
326                    "petId": {
327                        "type": "integer"
328                    }
329                },
330                "required": ["petId"]
331            }),
332            output_schema: None,
333            method: "GET".to_string(),
334            path: "/pet/{petId}".to_string(),
335        };
336
337        let tool2 = ToolMetadata {
338            name: "getPetsByStatus".to_string(),
339            title: Some("Find Pets by Status".to_string()),
340            description: "Find pets by status".to_string(),
341            parameters: json!({
342                "type": "object",
343                "properties": {
344                    "status": {
345                        "type": "array",
346                        "items": {
347                            "type": "string"
348                        }
349                    }
350                },
351                "required": ["status"]
352            }),
353            output_schema: None,
354            method: "GET".to_string(),
355            path: "/pet/findByStatus".to_string(),
356        };
357
358        // Get mutable access to registry and register tools
359        let registry = Arc::get_mut(&mut server.registry).unwrap();
360
361        // Create a mock operation for testing
362        let mock_operation = oas3::spec::Operation::default();
363
364        // Register tools with mock operations
365        registry
366            .register_tool(
367                tool1,
368                (
369                    mock_operation.clone(),
370                    "GET".to_string(),
371                    "/pet/{petId}".to_string(),
372                ),
373            )
374            .unwrap();
375        registry
376            .register_tool(
377                tool2,
378                (
379                    mock_operation,
380                    "GET".to_string(),
381                    "/pet/findByStatus".to_string(),
382                ),
383            )
384            .unwrap();
385
386        // Test: Create ToolNotFound error with a typo
387        let tool_names = server.registry.get_tool_names();
388        let tool_name_refs: Vec<&str> = tool_names.iter().map(|s| s.as_str()).collect();
389        let suggestions = crate::find_similar_strings("getPetByID", &tool_name_refs);
390
391        let error = ToolCallError::Validation(ToolCallValidationError::ToolNotFound {
392            tool_name: "getPetByID".to_string(),
393            suggestions,
394        });
395        let error_data: ErrorData = error.into();
396        let error_json = serde_json::to_value(&error_data).unwrap();
397
398        // Snapshot the error to verify suggestions
399        insta::assert_json_snapshot!(error_json);
400    }
401
402    #[test]
403    fn test_tool_not_found_error_no_suggestions() {
404        // Create a server with test tools
405        let mut server = OpenApiServer::new(OpenApiSpecLocation::Url(
406            Url::parse("test://example").unwrap(),
407        ));
408
409        // Create test tool metadata
410        let tool = ToolMetadata {
411            name: "getPetById".to_string(),
412            title: Some("Get Pet by ID".to_string()),
413            description: "Find pet by ID".to_string(),
414            parameters: json!({
415                "type": "object",
416                "properties": {
417                    "petId": {
418                        "type": "integer"
419                    }
420                },
421                "required": ["petId"]
422            }),
423            output_schema: None,
424            method: "GET".to_string(),
425            path: "/pet/{petId}".to_string(),
426        };
427
428        // Get mutable access to registry and register tool
429        let registry = Arc::get_mut(&mut server.registry).unwrap();
430
431        // Create a mock operation for testing
432        let mock_operation = oas3::spec::Operation::default();
433
434        // Register tool with mock operation
435        registry
436            .register_tool(
437                tool,
438                (
439                    mock_operation,
440                    "GET".to_string(),
441                    "/pet/{petId}".to_string(),
442                ),
443            )
444            .unwrap();
445
446        // Test: Create ToolNotFound error with unrelated name
447        let tool_names = server.registry.get_tool_names();
448        let tool_name_refs: Vec<&str> = tool_names.iter().map(|s| s.as_str()).collect();
449        let suggestions =
450            crate::find_similar_strings("completelyUnrelatedToolName", &tool_name_refs);
451
452        let error = ToolCallError::Validation(ToolCallValidationError::ToolNotFound {
453            tool_name: "completelyUnrelatedToolName".to_string(),
454            suggestions,
455        });
456        let error_data: ErrorData = error.into();
457        let error_json = serde_json::to_value(&error_data).unwrap();
458
459        // Snapshot the error to verify no suggestions
460        insta::assert_json_snapshot!(error_json);
461    }
462
463    #[test]
464    fn test_validation_error_converted_to_error_data() {
465        // Test that validation errors are properly converted to ErrorData
466        let error = ToolCallError::Validation(ToolCallValidationError::InvalidParameters {
467            violations: vec![crate::error::ValidationError::InvalidParameter {
468                parameter: "page".to_string(),
469                suggestions: vec!["page_number".to_string()],
470                valid_parameters: vec!["page_number".to_string(), "page_size".to_string()],
471            }],
472        });
473
474        let error_data: ErrorData = error.into();
475        let error_json = serde_json::to_value(&error_data).unwrap();
476
477        // Verify the basic structure
478        assert_eq!(error_json["code"], -32602); // Invalid params error code
479
480        // Snapshot the full error to verify the new error message format
481        insta::assert_json_snapshot!(error_json);
482    }
483}