Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions crates/rmcp-macros/src/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,6 @@ fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {
if let Some(inner_type) = extract_json_inner_type(ret_type) {
return syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::schema_for_output::<#inner_type>()
.unwrap_or_else(|e| {
panic!(
"Invalid output schema for Json<{}>: {}",
std::any::type_name::<#inner_type>(),
e
)
})
})
.ok();
}
Expand Down Expand Up @@ -65,13 +58,6 @@ fn extract_schema_from_return_type(ret_type: &syn::Type) -> Option<Expr> {

syn::parse2::<Expr>(quote! {
rmcp::handler::server::tool::schema_for_output::<#inner_type>()
.unwrap_or_else(|e| {
panic!(
"Invalid output schema for Result<Json<{}>, E>: {}",
std::any::type_name::<#inner_type>(),
e
)
})
})
.ok()
}
Expand Down
100 changes: 82 additions & 18 deletions crates/rmcp/src/handler/server/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,32 +106,40 @@ pub fn schema_for_empty_input() -> Arc<JsonObject> {
EMPTY.clone()
}

/// Generate a JSON schema for outputSchema (must have root type "object"; top-level "title" and "description" are removed)
pub fn schema_for_output<T: JsonSchema + std::any::Any>() -> Result<Arc<JsonObject>, String> {
/// Strip top-level `title` and `description` from a JSON schema for outputSchema.
/// Unlike `validate_and_strip`, this performs no validation — output schemas are not
/// restricted to `type: "object"` (per SEP-2106).
fn strip_output(raw: &Arc<JsonObject>) -> Arc<JsonObject> {
let mut object = raw.as_ref().clone();
object.remove("title");
object.remove("description");
Arc::new(object)
}

/// Generate and strip a JSON schema for outputSchema (top-level "title" and
/// "description" are removed; output schemas are not restricted to root type "object").
pub fn schema_for_output<T: JsonSchema + std::any::Any>() -> Arc<JsonObject> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public API Check is red in CI because the schema_for_output return-type change is a breaking change:

-pub fn ...::schema_for_output<...>() -> Result<Arc<JsonObject>, String>
+pub fn ...::schema_for_output<...>() -> Arc<JsonObject>
Error: The API diff is not allowed as per --deny: Changed items not allowed

That's expected and fine, and the plan is to batch all 2026-07-28 spec breaking changes into v3.0. So this SEP-2106 implementation belongs in that major.

We need to mark the introducing commit breaking though replacing feat: with feat!: so the CI gate computes major and Public API Check passes.

thread_local! {
static CACHE_FOR_OUTPUT: std::sync::RwLock<HashMap<TypeId, Result<Arc<JsonObject>, String>>> = Default::default();
static CACHE_FOR_OUTPUT: std::sync::RwLock<HashMap<TypeId, Arc<JsonObject>>> = Default::default();
};

CACHE_FOR_OUTPUT.with(|cache| {
// Try to get from cache first
if let Some(result) = cache
if let Some(schema) = cache
.read()
.expect("output schema cache lock poisoned")
.get(&TypeId::of::<T>())
{
return result.clone();
return schema.clone();
}

// Generate, validate, and strip unnecessary top-level fields
let result = validate_and_strip(&schema_for_type::<T>(), "outputSchema");
let schema = strip_output(&schema_for_type::<T>());

// Cache the result (both success and error cases)
cache
.write()
.expect("output schema cache lock poisoned")
.insert(TypeId::of::<T>(), result.clone());
.insert(TypeId::of::<T>(), schema.clone());

result
schema
})
}

Expand Down Expand Up @@ -305,32 +313,88 @@ mod tests {
assert!(Arc::ptr_eq(&schema, &cloned));
}

#[test]
fn test_schema_for_output_accepts_primitive() {
let schema = schema_for_output::<i32>();
assert_eq!(schema.get("type"), Some(&serde_json::json!("integer")));
}

#[test]
fn test_schema_for_output_strips_description_for_primitive() {
let schema = schema_for_output::<i32>();
assert!(!schema.contains_key("description"));
}

#[test]
fn test_schema_for_output_accepts_composition() {
let schema = schema_for_output::<Option<String>>();
let schema_str = serde_json::to_string(&schema).unwrap();
assert!(
schema_str.contains("anyOf")
|| schema_str.contains("oneOf")
|| schema_str.contains("null"),
"Expected composition schema for Option<String>, got: {schema_str}"
);
}

#[test]
fn test_schema_for_output_caches_result() {
let schema1 = schema_for_output::<i32>();
let schema2 = schema_for_output::<i32>();
assert!(Arc::ptr_eq(&schema1, &schema2));
}

#[test]
fn test_schema_for_input_rejects_array() {
let result = schema_for_input::<Vec<i32>>();
assert!(result.is_err());
}

#[test]
fn test_schema_for_output_accepts_unit() {
let _schema = schema_for_output::<()>();
}

#[test]
fn test_schema_for_output_accepts_object() {
let schema = schema_for_output::<TestObject>();
assert_eq!(schema.get("type"), Some(&serde_json::json!("object")));
}

#[test]
fn test_schema_for_output_strips_top_level_title() {
let schema = schema_for_output::<TestObject>();
assert!(!schema.contains_key("title"));
}

#[test]
fn test_schema_for_output_strips_top_level_description() {
let schema = schema_for_output::<TestObject>();
assert!(!schema.contains_key("description"));
}

#[rstest]
#[case::output(schema_for_output::<i32>)]
#[case::input(schema_for_input::<i32>)]
fn test_schema_for_object_wrappers_reject_primitives(
fn test_schema_for_input_rejects_primitives(
#[case] schema_fn: fn() -> Result<Arc<JsonObject>, String>,
) {
let result = schema_fn();
assert!(result.is_err());
}

#[rstest]
#[case::output(schema_for_output::<TestObject>)]
#[case::input(schema_for_input::<TestObject>)]
fn test_schema_for_object_wrappers_accept_objects(
fn test_schema_for_input_accepts_objects(
#[case] schema_fn: fn() -> Result<Arc<JsonObject>, String>,
) {
let result = schema_fn();
assert!(result.is_ok());
}

#[rstest]
#[case::output_title(schema_for_output::<TestObject>, "title")]
#[case::output_description(schema_for_output::<TestObject>, "description")]
#[case::input_title(schema_for_input::<TestObject>, "title")]
#[case::input_description(schema_for_input::<TestObject>, "description")]
fn test_schema_for_object_wrappers_strip_top_level_metadata(
fn test_schema_for_input_strips_top_level_metadata(
#[case] schema_fn: fn() -> Result<Arc<JsonObject>, String>,
#[case] field: &str,
) {
Expand Down
47 changes: 40 additions & 7 deletions crates/rmcp/src/handler/server/router/tool/tool_traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,7 @@ pub trait ToolBase {
///
/// If the tool does not have any output, you should override this methods to return [`None`].
fn output_schema() -> Option<Arc<JsonObject>> {
Some(schema_for_output::<Self::Output>().unwrap_or_else(|e| {
panic!(
"Invalid output schema for ToolBase::Output type `{0}`: {1}",
std::any::type_name::<Self::Output>(),
e,
);
}))
Some(schema_for_output::<Self::Output>())
}

fn annotations() -> Option<ToolAnnotations> {
Expand Down Expand Up @@ -346,4 +340,43 @@ mod tests {
assert_eq!(result, ErrorData::invalid_params("invalid params", None));
}
}

struct ArrayTool;
impl ToolBase for ArrayTool {
type Parameter = AddParameter;
type Output = Vec<AddOutput>;
type Error = ErrorData;

fn name() -> Cow<'static, str> {
"array-tool".into()
}
}
impl SyncTool<TraitBasedToolServer> for ArrayTool {
fn invoke(
_service: &TraitBasedToolServer,
_param: Self::Parameter,
) -> Result<Self::Output, Self::Error> {
Ok(vec![])
}
}
impl AsyncTool<TraitBasedToolServer> for ArrayTool {
async fn invoke(
_service: &TraitBasedToolServer,
_param: Self::Parameter,
) -> Result<Self::Output, Self::Error> {
Ok(vec![])
}
}
Comment on lines +354 to +369

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these for?


#[test]
fn test_toolbase_output_schema_with_array_output() {
let schema = ArrayTool::output_schema();
assert!(schema.is_some());
let schema = schema.unwrap();
let schema_value: serde_json::Value = serde_json::from_str(
&serde_json::to_string(&*schema).expect("failed to serialize schema"),
)
.expect("failed to parse schema JSON");
assert_eq!(schema_value["type"], "array");
Comment on lines +376 to +380

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schema is already an Arc<JsonObject> so we can read the key directly.

Suggested change
let schema_value: serde_json::Value = serde_json::from_str(
&serde_json::to_string(&*schema).expect("failed to serialize schema"),
)
.expect("failed to parse schema JSON");
assert_eq!(schema_value["type"], "array");
assert_eq!(schema.get("type"), Some(&serde_json::json!("array")));

}
}
8 changes: 1 addition & 7 deletions crates/rmcp/src/model/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,15 +315,9 @@ impl Tool {
}

/// Set the output schema using a type that implements JsonSchema
///
/// # Panics
///
/// Panics if the generated schema does not have root type "object" as required by MCP specification.
#[cfg(feature = "server")]
pub fn with_output_schema<T: JsonSchema + 'static>(mut self) -> Self {
let schema = crate::handler::server::tool::schema_for_output::<T>()
.unwrap_or_else(|e| panic!("Invalid output schema for tool '{}': {}", self.name, e));
self.output_schema = Some(schema);
self.output_schema = Some(crate::handler::server::tool::schema_for_output::<T>());
self
}

Expand Down
73 changes: 73 additions & 0 deletions crates/rmcp/tests/test_json_schema_detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ impl TestServer {
pub async fn explicit_schema(&self) -> Result<String, String> {
Ok("test".to_string())
}

/// Tool that returns Json<Vec<T>> - array output schema
#[tool(name = "with-json-array")]
pub async fn with_json_array(&self) -> Result<Json<Vec<TestData>>, String> {
Ok(Json(vec![TestData {
value: "test".to_string(),
}]))
}

/// Tool that returns Result<Json<Vec<T>>, ErrorData> - array output schema
#[tool(name = "result-with-json-array")]
pub async fn result_with_json_array(&self) -> Result<Json<Vec<TestData>>, rmcp::ErrorData> {
Ok(Json(vec![TestData {
value: "test".to_string(),
}]))
}

/// Tool that returns Json<String> - string output schema
#[tool(name = "with-json-string")]
pub async fn with_json_string(&self) -> Result<Json<String>, String> {
Ok(Json("test".to_string()))
}
}

#[tokio::test]
Expand Down Expand Up @@ -113,3 +135,54 @@ async fn test_explicit_schema_override() {
"Explicit output_schema attribute should work"
);
}

#[tokio::test]
async fn test_json_array_type_generates_schema() {
let server = TestServer::new();
let tools = server.tool_router.list_all();

let array_tool = tools.iter().find(|t| t.name == "with-json-array").unwrap();
assert!(
array_tool.output_schema.is_some(),
"Json<Vec<T>> return type should generate output schema"
);
let schema = array_tool.output_schema.as_ref().unwrap();
assert_eq!(
schema.get("type").and_then(|v| v.as_str()),
Some("array"),
"Json<Vec<T>> should produce an array schema"
);
}

#[tokio::test]
async fn test_result_with_json_array_generates_schema() {
let server = TestServer::new();
let tools = server.tool_router.list_all();

let result_array_tool = tools
.iter()
.find(|t| t.name == "result-with-json-array")
.unwrap();
assert!(
result_array_tool.output_schema.is_some(),
"Result<Json<Vec<T>>, ErrorData> return type should generate output schema"
);
}

#[tokio::test]
async fn test_json_string_type_generates_schema() {
let server = TestServer::new();
let tools = server.tool_router.list_all();

let string_tool = tools.iter().find(|t| t.name == "with-json-string").unwrap();
assert!(
string_tool.output_schema.is_some(),
"Json<String> return type should generate output schema"
);
let schema = string_tool.output_schema.as_ref().unwrap();
assert_eq!(
schema.get("type").and_then(|v| v.as_str()),
Some("string"),
"Json<String> should produce a string schema"
);
}
Loading
Loading