|
1 | 1 | #![allow(clippy::exhaustive_structs)] |
2 | 2 | //cargo test --test test_structured_output --features "client server macros" |
3 | 3 | use rmcp::{ |
4 | | - Json, ServerHandler, |
| 4 | + Json, ServerHandler, StructuredOnly, |
5 | 5 | handler::server::{router::tool::ToolRouter, tool::IntoCallToolResult, wrapper::Parameters}, |
6 | 6 | model::{CallToolResponse, CallToolResult, ContentBlock, ServerResult, Tool}, |
7 | 7 | tool, tool_handler, tool_router, |
@@ -114,6 +114,21 @@ impl TestServer { |
114 | 114 | pub async fn get_count(&self) -> Result<Json<i32>, String> { |
115 | 115 | Ok(Json(42)) |
116 | 116 | } |
| 117 | + |
| 118 | + /// Tool that returns structured output without the text mirror |
| 119 | + #[tool( |
| 120 | + name = "calculate-structured-only", |
| 121 | + description = "Perform calculations, returning structured content only" |
| 122 | + )] |
| 123 | + pub async fn calculate_structured_only( |
| 124 | + &self, |
| 125 | + params: Parameters<CalculationRequest>, |
| 126 | + ) -> Result<StructuredOnly<CalculationResult>, String> { |
| 127 | + Ok(StructuredOnly(CalculationResult { |
| 128 | + sum: params.0.a + params.0.b, |
| 129 | + product: params.0.a * params.0.b, |
| 130 | + })) |
| 131 | + } |
117 | 132 | } |
118 | 133 |
|
119 | 134 | #[tokio::test] |
@@ -203,6 +218,40 @@ async fn test_structured_error_in_call_result() { |
203 | 218 | assert_eq!(result.is_error, Some(true)); |
204 | 219 | } |
205 | 220 |
|
| 221 | +#[tokio::test] |
| 222 | +async fn test_structured_only_in_call_result() { |
| 223 | + // structured_only skips the text mirror: structured content, empty content |
| 224 | + let structured_data = json!({ |
| 225 | + "sum": 7, |
| 226 | + "product": 12 |
| 227 | + }); |
| 228 | + |
| 229 | + let result = CallToolResult::structured_only(structured_data.clone()); |
| 230 | + |
| 231 | + assert!(result.content.is_empty()); |
| 232 | + assert_eq!(result.structured_content, Some(structured_data)); |
| 233 | + assert_eq!(result.is_error, Some(false)); |
| 234 | + |
| 235 | + // The empty content array still serializes explicitly on the wire |
| 236 | + let serialized = serde_json::to_value(&result).unwrap(); |
| 237 | + assert_eq!(serialized["content"], json!([])); |
| 238 | + assert_eq!(serialized["structuredContent"]["sum"], 7); |
| 239 | +} |
| 240 | + |
| 241 | +#[tokio::test] |
| 242 | +async fn test_structured_error_only_in_call_result() { |
| 243 | + let error_data = json!({ |
| 244 | + "error_code": "NOT_FOUND", |
| 245 | + "message": "User not found" |
| 246 | + }); |
| 247 | + |
| 248 | + let result = CallToolResult::structured_error_only(error_data.clone()); |
| 249 | + |
| 250 | + assert!(result.content.is_empty()); |
| 251 | + assert_eq!(result.structured_content, Some(error_data)); |
| 252 | + assert_eq!(result.is_error, Some(true)); |
| 253 | +} |
| 254 | + |
206 | 255 | #[tokio::test] |
207 | 256 | async fn test_mutual_exclusivity_validation() { |
208 | 257 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] |
@@ -275,6 +324,49 @@ async fn test_structured_return_conversion() { |
275 | 324 | assert_eq!(structured_value["product"], 12); |
276 | 325 | } |
277 | 326 |
|
| 327 | +#[tokio::test] |
| 328 | +async fn test_structured_only_return_conversion() { |
| 329 | + // StructuredOnly<T> converts to CallToolResult with structured_content but |
| 330 | + // no text mirror in content |
| 331 | + let calc_result = CalculationResult { |
| 332 | + sum: 7, |
| 333 | + product: 12, |
| 334 | + }; |
| 335 | + |
| 336 | + let structured = StructuredOnly(calc_result); |
| 337 | + let result: Result<CallToolResponse, rmcp::ErrorData> = |
| 338 | + rmcp::handler::server::tool::IntoCallToolResult::into_call_tool_result(structured); |
| 339 | + |
| 340 | + assert!(result.is_ok()); |
| 341 | + let CallToolResponse::Complete(call_result) = result.unwrap() else { |
| 342 | + panic!("expected complete CallToolResult"); |
| 343 | + }; |
| 344 | + |
| 345 | + assert!(call_result.content.is_empty()); |
| 346 | + |
| 347 | + let structured_value = call_result.structured_content.unwrap(); |
| 348 | + assert_eq!(structured_value["sum"], 7); |
| 349 | + assert_eq!(structured_value["product"], 12); |
| 350 | +} |
| 351 | + |
| 352 | +#[tokio::test] |
| 353 | +async fn test_structured_only_tool_has_output_schema() { |
| 354 | + // The #[tool] macro derives outputSchema from StructuredOnly<T> just like Json<T> |
| 355 | + let server = TestServer::new(); |
| 356 | + let tools = server.tool_router.list_all(); |
| 357 | + |
| 358 | + let tool = tools |
| 359 | + .iter() |
| 360 | + .find(|t| t.name == "calculate-structured-only") |
| 361 | + .unwrap(); |
| 362 | + |
| 363 | + assert!(tool.output_schema.is_some()); |
| 364 | + |
| 365 | + let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap(); |
| 366 | + assert!(schema_str.contains("sum")); |
| 367 | + assert!(schema_str.contains("product")); |
| 368 | +} |
| 369 | + |
278 | 370 | #[tokio::test] |
279 | 371 | async fn test_tool_serialization_with_output_schema() { |
280 | 372 | let server = TestServer::new(); |
|
0 commit comments