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