codeprism_mcp_server/
response.rs

1//! Response helpers for dual-format MCP responses
2//!
3//! This module provides utilities for creating MCP responses that support both
4//! unstructured content (JSON as text) and structured content (direct JSON access)
5//! according to the MCP 2025-06-18 specification.
6
7use rmcp::model::{CallToolResult, Content};
8use serde_json::Value;
9use tracing::warn;
10
11/// Create a dual-format response containing both unstructured and structured content
12///
13/// This function creates responses that are compatible with:
14/// - Existing clients (accessing content[0].text for JSON string)
15/// - New clients expecting structured content (direct JSON field access)
16/// - Comprehensive test specifications requiring structured responses
17///
18/// # Arguments
19/// * `data` - The response data as a JSON Value
20///
21/// # Returns
22/// A CallToolResult with both text content and structured content
23///
24/// # Examples
25/// ```rust
26/// use serde_json::json;
27/// use codeprism_mcp_server::response::create_dual_response;
28///
29/// let data = json!({
30///     "status": "success",
31///     "result": "analysis complete"
32/// });
33///
34/// let response = create_dual_response(&data);
35/// // Response contains both formats for maximum compatibility
36/// ```
37pub fn create_dual_response(data: &Value) -> CallToolResult {
38    // Create unstructured content (current format for backward compatibility)
39    let text_content = Content::text(
40        serde_json::to_string_pretty(data)
41            .unwrap_or_else(|_| "Error formatting response".to_string()),
42    );
43
44    // Add structured content as a JSON content type using SDK capabilities
45
46    let mut content_list = vec![text_content];
47
48    // Attempt to add structured content using rmcp SDK capabilities
49    match add_structured_content(data) {
50        Ok(structured_content) => {
51            content_list.push(structured_content);
52        }
53        Err(e) => {
54            warn!("Failed to add structured content: {}", e);
55            // Continue with unstructured only for backward compatibility
56        }
57    }
58
59    CallToolResult::success(content_list)
60}
61
62/// Create an error response with dual format
63///
64/// # Arguments
65/// * `error_message` - Human-readable error message
66/// * `error_code` - Optional error code for categorization
67///
68/// # Returns
69/// A CallToolResult error with both text and structured formats
70pub fn create_error_response(error_message: &str, error_code: Option<&str>) -> CallToolResult {
71    let error_data = serde_json::json!({
72        "status": "error",
73        "message": error_message,
74        "code": error_code
75    });
76
77    // Create error content (using the same dual format approach)
78    let error_text = Content::text(
79        serde_json::to_string_pretty(&error_data)
80            .unwrap_or_else(|_| format!(r#"{{"status":"error","message":"{error_message}"}}"#)),
81    );
82
83    let mut content_list = vec![error_text];
84
85    // Add structured error content if possible
86    if let Ok(structured_content) = add_structured_content(&error_data) {
87        content_list.push(structured_content);
88    }
89
90    CallToolResult::error(content_list)
91}
92
93/// Attempt to add structured content to the response
94///
95/// This function implements the strategy to add structured content based on
96/// available rmcp SDK capabilities. Currently implements Option B from the design.
97///
98/// # Arguments  
99/// * `data` - The JSON data to add as structured content
100///
101/// # Returns
102/// Result containing structured Content or error if not supported
103fn add_structured_content(data: &Value) -> Result<Content, Box<dyn std::error::Error>> {
104    // Option B: Add structured content as JSON content type
105    // This uses the existing Content::json() method if available
106    match Content::json(data) {
107        Ok(json_content) => Ok(json_content),
108        Err(_e) => {
109            // Fallback: Create text content with structured marker for compatibility
110            Ok(Content::text(format!(
111                "STRUCTURED_JSON:{}",
112                serde_json::to_string_pretty(data).unwrap_or_else(|_| "{}".to_string())
113            )))
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use serde_json::json;
122
123    #[test]
124    fn test_dual_response_format() {
125        let data = json!({
126            "status": "success",
127            "result": "test_value"
128        });
129
130        let response = create_dual_response(&data);
131
132        // Verify response is successful
133        // Note: Testing exact structure depends on rmcp SDK internals
134        // This test validates the response can be created without panicking
135        assert!(!response.content.is_empty(), "Should not be empty");
136
137        // Verify unstructured content exists (backward compatibility)
138        let first_content = &response.content[0];
139        if let Some(text_content) = first_content.as_text() {
140            assert!(text_content.text.contains("test_value"));
141            assert!(text_content.text.contains("success"));
142        }
143    }
144
145    #[test]
146    fn test_error_response_format() {
147        let response = create_error_response("Test error", Some("TEST_ERROR"));
148
149        // Verify error response structure
150        assert!(!response.content.is_empty(), "Should not be empty");
151
152        let first_content = &response.content[0];
153        if let Some(text_content) = first_content.as_text() {
154            assert!(text_content.text.contains("Test error"));
155            assert!(text_content.text.contains("TEST_ERROR"));
156        }
157    }
158
159    #[test]
160    fn test_backward_compatibility() {
161        let data = json!({"test": "value"});
162        let response = create_dual_response(&data);
163
164        // Existing clients should still work by accessing first content item
165        let text_content = &response.content[0];
166        if let Some(text) = text_content.as_text() {
167            assert!(text.text.contains("\"test\""));
168            assert!(text.text.contains("\"value\""));
169        }
170    }
171
172    #[test]
173    fn test_complex_json_data() {
174        let data = json!({
175            "status": "success",
176            "repository_overview": {
177                "total_files": 42,
178                "languages": ["rust", "python"],
179                "complexity": {
180                    "average": 3.2,
181                    "max": 15
182                }
183            }
184        });
185
186        let response = create_dual_response(&data);
187
188        // Should handle nested JSON structures
189        assert!(!response.content.is_empty(), "Should not be empty");
190
191        let first_content = &response.content[0];
192        if let Some(text_content) = first_content.as_text() {
193            assert!(text_content.text.contains("repository_overview"));
194            assert!(text_content.text.contains("total_files"));
195            assert!(text_content.text.contains("42"));
196        }
197    }
198
199    #[test]
200    fn test_dual_response_contains_multiple_content_items() {
201        let data = json!({
202            "status": "success",
203            "message": "ping response"
204        });
205
206        let response = create_dual_response(&data);
207
208        // Should contain both unstructured and structured content
209        // The exact number depends on SDK capabilities, but should be at least 1
210        assert!(!response.content.is_empty(), "Should not be empty");
211
212        // First item should always be unstructured text for backward compatibility
213        let first_content = &response.content[0];
214        assert!(
215            first_content.as_text().is_some(),
216            "First content should be text for backward compatibility"
217        );
218        let text_content = first_content.as_text().unwrap();
219        assert!(
220            !text_content.text.is_empty(),
221            "Text content should not be empty"
222        );
223        assert!(
224            text_content.text.contains("ping response"),
225            "Text should contain the actual test data"
226        );
227    }
228
229    #[test]
230    fn test_structured_response_for_comprehensive_specs() {
231        // Test data that mimics what comprehensive specs expect
232        let data = json!({
233            "status": "success",
234            "quality_metrics": {
235                "overall_score": 8.5,
236                "maintainability": 9.0,
237                "complexity": 7.0
238            },
239            "code_smells": [],
240            "recommendations": ["Excellent code quality"]
241        });
242
243        let response = create_dual_response(&data);
244
245        // Verify response can be created for complex analysis data
246        assert!(!response.content.is_empty(), "Should not be empty");
247
248        // Verify unstructured format contains all data
249        let first_content = &response.content[0];
250        if let Some(text_content) = first_content.as_text() {
251            assert!(text_content.text.contains("quality_metrics"));
252            assert!(text_content.text.contains("overall_score"));
253            assert!(text_content.text.contains("8.5"));
254        }
255
256        // If multiple content items exist, verify structured content is available
257        if response.content.len() > 1 {
258            // The second item should be structured content
259            // This test will help us verify when structured content is working
260            let second_content = &response.content[1];
261            // Verify structured content exists when SDK supports multiple content items
262            let has_text = second_content.as_text().is_some();
263            let has_resource = second_content.as_resource().is_some();
264            assert!(
265                has_text || has_resource,
266                "Second content should be either text or resource"
267            );
268
269            // Validate the actual content based on type
270            if has_text {
271                let text = second_content.as_text().unwrap();
272                assert!(
273                    !text.text.is_empty(),
274                    "Structured text content should not be empty"
275                );
276            }
277            if has_resource {
278                let resource = second_content.as_resource().unwrap();
279                // Check URI based on resource type
280                let uri = match &resource.resource {
281                    rmcp::model::ResourceContents::TextResourceContents { uri, .. } => uri,
282                    rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => uri,
283                };
284                assert!(!uri.is_empty(), "Resource URI should not be empty");
285            }
286        }
287    }
288}