rust-sdk/crates/rmcp/tests/test_tool_macros.rs
Jean-Marc Le Roux fbc7ab70ca
feat: Add support for Tool.outputSchema and CallToolResult.structuredContent (#316)
* feat: add output_schema field to Tool struct

- Add optional output_schema field to Tool struct for defining tool
output structure
- Update Tool::new() to initialize output_schema as None

* feat: add structured_content field to CallToolResult

- Add optional structured_content field for JSON object results
- Make content field optional to support either structured or
unstructured results
- Add CallToolResult::structured() and structured_error() constructor
methods

* feat: implement validation for mutually exclusive content/structuredContent

- Add validate() method to ensure content and structured_content are
mutually exclusive
- Implement custom Deserialize to enforce validation during
deserialization
- Update documentation to clarify the mutual exclusivity requirement

* feat: add output_schema support to #[tool] macro

- Add output_schema field to ToolAttribute and ResolvedToolAttribute
structs
- Implement automatic output schema generation from return types
- Support explicit output_schema attribute for manual specification
- Generate schemas for Result<T, E> where T is not CallToolResult
- Update tool generation to include output_schema in Tool struct

* feat: implement IntoCallToolResult for structured content

- Add Structured<T> wrapper type for explicit structured content
- Implement IntoCallToolResult for Structured<T> with JSON serialization
- Add support for Result<Structured<T>, E> conversions
- Enable tools to return structured content through the trait system

* fix: update simple-chat-client example for optional content field

- Handle Option<Vec<Content>> in CallToolResult.content
- Add proper unwrapping for the optional content field
- Fix compilation error in chat.rs

* fix: update examples and tests for optional content field

- Add output_schema field to Tool initialization in sampling_stdio
example
- Update test_tool_macros tests to handle Option<Vec<Content>>
- Use as_ref() before calling first() on optional content field

* feat: implement basic schema validation in conversion logic

- Add validate_against_schema function for basic type validation
- Add note that full JSON Schema validation requires dedicated library
- Document that actual validation should happen in tool handler

* feat: add structured output support for tools

- Add output_schema field to Tool struct for defining output JSON schemas
- Add structured_content field to CallToolResult (mutually exclusive with content)
- Implement Structured<T> wrapper for type-safe structured outputs
- Update #[tool] macro to automatically generate output schemas from return types
- Add validation of structured outputs against their schemas
- Update all examples and tests for breaking change (CallToolResult.content now Option)
- Add comprehensive documentation and rustdoc
- Add structured_output example demonstrating the feature

BREAKING CHANGE: CallToolResult.content is now Option<Vec<Content>> instead of Vec<Content>

Closes #312

* fix: correct structured output doctest to use Parameters wrapper

The #[tool] macro requires Parameters<T> wrapper for tool inputs.
This fixes the pre-existing broken doctest in the structured output
documentation example.

* feat: replace Structured<T> with Json<T> for structured output

- Remove Structured<T> type definition and implementations
- Reuse existing Json<T> wrapper for structured content
- Update IntoCallToolResult implementations to use Json<T>
- Add JsonSchema implementation for Json<T> delegating to T
- Update all examples and tests to use Json<T> instead of Structured<T>
- Update documentation and exports

BREAKING CHANGE: Structured<T> has been replaced with Json<T>. Users must update their code to use Json<T> for structured tool outputs.

* feat: add output_schema() method to IntoCallToolResult trait

- Add output_schema() method with default None implementation
- Implement output_schema() for Json<T> to return cached schema
- Implement output_schema() for Result<Json<T>, E> delegating to Json<T>
- Enable trait-based schema generation for structured outputs

* feat: update macro to detect Json<T> wrapper for output schemas

- Add extract_json_inner_type() helper to detect Json<T> types
- Update schema generation to only occur for Json<T> wrapped types
- Remove generic Result<T, E> detection in favor of specific Json<T> detection
- Add comprehensive tests to verify schema generation behavior

* feat: add builder methods to Tool struct for setting schemas

- Add with_output_schema<T>() method to set output schema from type
- Add with_input_schema<T>() method to set input schema from type
- Both methods use cached_schema_for_type internally
- Add comprehensive tests for builder methods

* fix: address clippy warnings

- Add Default implementation for StructuredOutputServer
- Fix collapsible else-if in simple-chat-client
- No functional changes

* style: apply cargo fmt

Apply automatic formatting changes to:
- examples/simple-chat-client/src/chat.rs - fix line wrapping
- crates/rmcp-macros/src/tool.rs - format method chaining
- examples/servers/src/structured_output.rs - reorder imports and format function signatures

* chore: fix formatting

* chore: fix rustdoc redundant link warning

* refactor: validate_against_schema

* feat: enforce structured_content usage when output_schema is defined

This commit implements strict validation to ensure tools with output_schema
consistently use structured_content for both success and error responses.

Changes:
- Enhanced ToolRouter::call() validation to require structured_content when output_schema is present
- Added validation that tools with output_schema cannot use regular content field
- Added comprehensive tests covering the new strict validation behavior
- Created example demonstrating proper structured output usage
- Updated TODO.md to track validation improvements

This ensures consistent response format and better type safety for MCP clients.

* chore: remove TODO.md

* refactor: simplify output schema extraction logic in tool macro

- Extract complex nested logic into dedicated helper function
- Replace deeply nested if-else chains with functional approach
- Use early returns and ? operator for cleaner code flow
- Reduce 46 lines to 7 lines in main logic while improving readability

* chore: run cargo fmt

* fix: enforce structured_content usage when output_schema is defined

Structured content is returned as a JSON object in the structuredContent
field of a result.For backwards compatibility, a tool that returns
structured content SHOULD also return the serialized JSON in a
TextContent block.

https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content

Tools may also provide an output schema for validation of structured
results. If an output schema is provided:

- Servers MUST provide structured results that conform to this schema.
- Clients SHOULD validate structured results against this schema.

https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema

* chore: cargo fmt
2025-07-31 16:32:24 +08:00

347 lines
9.8 KiB
Rust

//cargo test --test test_tool_macros --features "client server"
#![allow(dead_code)]
use std::sync::Arc;
use rmcp::{
ClientHandler, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, tool::Parameters},
model::{CallToolRequestParam, ClientInfo},
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct GetWeatherRequest {
pub city: String,
pub date: String,
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for Server {}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Server {
tool_router: ToolRouter<Self>,
}
impl Default for Server {
fn default() -> Self {
Self::new()
}
}
#[tool_router(router = tool_router)]
impl Server {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
/// This tool is used to get the weather of a city.
#[tool(name = "get-weather", description = "Get the weather of a city.")]
pub async fn get_weather(&self, city: Parameters<GetWeatherRequest>) -> String {
drop(city);
"rain".to_string()
}
#[tool]
async fn empty_param(&self) {}
}
// define generic service trait
pub trait DataService: Send + Sync + 'static {
fn get_data(&self) -> String;
}
// mock service for test
#[derive(Clone)]
struct MockDataService;
impl DataService for MockDataService {
fn get_data(&self) -> String {
"mock data".to_string()
}
}
// define generic server
#[derive(Debug, Clone)]
pub struct GenericServer<DS: DataService> {
data_service: Arc<DS>,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl<DS: DataService> GenericServer<DS> {
pub fn new(data_service: DS) -> Self {
Self {
data_service: Arc::new(data_service),
tool_router: Self::tool_router(),
}
}
#[tool(description = "Get data from the service")]
async fn get_data(&self) -> String {
self.data_service.get_data()
}
}
#[tool_handler]
impl<DS: DataService> ServerHandler for GenericServer<DS> {}
#[tokio::test]
async fn test_tool_macros() {
let server = Server::new();
let _attr = Server::get_weather_tool_attr();
let _get_weather_tool_attr_fn = Server::get_weather_tool_attr;
let _get_weather_fn = Server::get_weather;
server
.get_weather(Parameters(GetWeatherRequest {
city: "Harbin".into(),
date: "Yesterday".into(),
}))
.await;
}
#[tokio::test]
async fn test_tool_macros_with_empty_param() {
let _attr = Server::empty_param_tool_attr();
println!("{_attr:?}");
assert_eq!(_attr.input_schema.get("type").unwrap(), "object");
assert!(_attr.input_schema.get("properties").is_none());
}
#[tokio::test]
async fn test_tool_macros_with_generics() {
let mock_service = MockDataService;
let server = GenericServer::new(mock_service);
let _attr = GenericServer::<MockDataService>::get_data_tool_attr();
let _get_data_call_fn = GenericServer::<MockDataService>::get_data;
let _get_data_fn = GenericServer::<MockDataService>::get_data;
assert_eq!(server.get_data().await, "mock data");
}
#[tokio::test]
async fn test_tool_macros_with_optional_param() {
let _attr = Server::get_weather_tool_attr();
// println!("{_attr:?}");
let attr_type = _attr
.input_schema
.get("properties")
.unwrap()
.get("city")
.unwrap()
.get("type")
.unwrap();
println!("_attr.input_schema: {:?}", attr_type);
assert_eq!(attr_type.as_str().unwrap(), "string");
}
impl GetWeatherRequest {}
// Struct defined for testing optional field schema generation
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct OptionalFieldTestSchema {
#[schemars(description = "An optional description field")]
pub description: Option<String>,
}
// Struct defined for testing optional i64 field schema generation and null handling
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct OptionalI64TestSchema {
#[schemars(description = "An optional i64 field")]
pub count: Option<i64>,
pub mandatory_field: String, // Added to ensure non-empty object schema
}
// Dummy struct to host the test tool method
#[derive(Debug, Clone)]
pub struct OptionalSchemaTester {
tool_router: ToolRouter<Self>,
}
impl Default for OptionalSchemaTester {
fn default() -> Self {
Self::new()
}
}
impl OptionalSchemaTester {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
}
#[tool_router]
impl OptionalSchemaTester {
// Dummy tool function using the test schema as an aggregated parameter
#[tool(description = "A tool to test optional schema generation")]
async fn test_optional(&self, _req: Parameters<OptionalFieldTestSchema>) {
// Implementation doesn't matter for schema testing
// Return type changed to () to satisfy IntoCallToolResult
}
// Tool function to test optional i64 handling
#[tool(description = "A tool to test optional i64 schema generation")]
async fn test_optional_i64(
&self,
Parameters(req): Parameters<OptionalI64TestSchema>,
) -> String {
match req.count {
Some(c) => format!("Received count: {}", c),
None => "Received null count".to_string(),
}
}
}
#[tool_handler]
// Implement ServerHandler to route tool calls for OptionalSchemaTester
impl ServerHandler for OptionalSchemaTester {}
#[test]
fn test_optional_field_schema_generation_via_macro() {
// tests https://github.com/modelcontextprotocol/rust-sdk/issues/135
// Get the attributes generated by the #[tool] macro helper
let tool_attr = OptionalSchemaTester::test_optional_tool_attr();
// Print the actual generated schema for debugging
println!(
"Actual input schema generated by macro: {:#?}",
tool_attr.input_schema
);
// Verify the schema generated for the aggregated OptionalFieldTestSchema
// by the macro infrastructure (which should now use OpenAPI 3 settings)
let input_schema_map = &*tool_attr.input_schema; // Dereference Arc<JsonObject>
// Check the schema for the 'description' property within the input schema
let properties = input_schema_map
.get("properties")
.expect("Schema should have properties")
.as_object()
.unwrap();
let description_schema = properties
.get("description")
.expect("Properties should include description")
.as_object()
.unwrap();
// Assert that the format is now `type: "string", nullable: true`
assert_eq!(
description_schema.get("type").map(|v| v.as_str().unwrap()),
Some("string"),
"Schema for Option<String> generated by macro should be type: \"string\""
);
assert_eq!(
description_schema
.get("nullable")
.map(|v| v.as_bool().unwrap()),
Some(true),
"Schema for Option<String> generated by macro should have nullable: true"
);
// We still check the description is correct
assert_eq!(
description_schema
.get("description")
.map(|v| v.as_str().unwrap()),
Some("An optional description field")
);
// Ensure the old 'type: [T, null]' format is NOT used
let type_value = description_schema.get("type").unwrap();
assert!(
!type_value.is_array(),
"Schema type should not be an array [T, null]"
);
}
// Define a dummy client handler
#[derive(Debug, Clone, Default)]
struct DummyClientHandler {}
impl ClientHandler for DummyClientHandler {
fn get_info(&self) -> ClientInfo {
ClientInfo::default()
}
}
#[tokio::test]
async fn test_optional_i64_field_with_null_input() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(4096);
// Server setup
let server = OptionalSchemaTester::new();
let server_handle = tokio::spawn(async move {
server.serve(server_transport).await?.waiting().await?;
anyhow::Ok(())
});
// Create a simple client handler that just forwards tool calls
let client_handler = DummyClientHandler::default();
let client = client_handler.serve(client_transport).await?;
// Test null case
let result = client
.call_tool(CallToolRequestParam {
name: "test_optional_i64".into(),
arguments: Some(
serde_json::json!({
"count": null,
"mandatory_field": "test_null"
})
.as_object()
.unwrap()
.clone(),
),
})
.await?;
let result_text = result
.content
.as_ref()
.and_then(|contents| contents.first())
.and_then(|content| content.raw.as_text())
.map(|text| text.text.as_str())
.expect("Expected text content");
assert_eq!(
result_text, "Received null count",
"Null case should return expected message"
);
// Test Some case
let some_result = client
.call_tool(CallToolRequestParam {
name: "test_optional_i64".into(),
arguments: Some(
serde_json::json!({
"count": 42,
"mandatory_field": "test_some"
})
.as_object()
.unwrap()
.clone(),
),
})
.await?;
let some_result_text = some_result
.content
.as_ref()
.and_then(|contents| contents.first())
.and_then(|content| content.raw.as_text())
.map(|text| text.text.as_str())
.expect("Expected text content");
assert_eq!(
some_result_text, "Received count: 42",
"Some case should return expected message"
);
client.cancel().await?;
server_handle.await??;
Ok(())
}