feat: relax outputSchema to accept non-object JSON Schema types (SEP-2106) (#895)
* fix: address PR review - schema_for_output no longer validates or returns Result - Add strip_output() that strips title/description without validating type (Dale #1) - Change schema_for_output to return Arc<JsonObject> instead of Result (Dale #2) - Cache only Arc<JsonObject> success values, not Result (Dale #3) - Remove dead unwrap_or_else panic paths in with_output_schema, ToolBase, and macros - Tighten test assertions from contains to assert_eq on type field (Dale #4) - Update test_schema_for_output_rejects_primitive to accept_primitive (SEP-2106) Co-authored-by: Orca <help@stably.ai> * test(rmcp): add non-object output schema tests for SEP-2106 Add tests verifying schema_for_output accepts non-object types: - test_tool_builder_methods: primitive (i32), array (Vec<String>), option - test_structured_output: tool returning Json<Vec<T>> and Json<i32> - test_json_schema_detection: Json<Vec<T>>, Result<Json<Vec<T>>,E>, Json<String> - tool_traits: ToolBase::output_schema with Vec<AddOutput> output type * test(rmcp): add missing edge case tests from code review Add tests identified during code review: - description stripping for primitive types - composition types (Option<String> with anyOf/oneOf/null) - cache correctness (Arc::ptr_eq for repeated calls) - schema_for_input rejecting array types (not just primitives) - schema_for_output accepting unit type () * feat!: mark schema_for_output return-type change as breaking This introduces SEP-2106: schema_for_output no longer validates or returns Result. The public signature changed, so bump major. * fix: address Dale's PR review - direct schema.get assertions, remove ArrayTool - Replace loose schema_str.contains(...) assertions with direct schema.get("type") equality checks in test_tool_builder_methods.rs and test_structured_output.rs - Remove redundant ArrayTool fixture and its round-trip serde_json::from_str test from tool_traits.rs since schema is already Arc<JsonObject> - Drop dead schema_str variable in test_structured_output.rs --------- Co-authored-by: Brandon Bennett <brandonbennett@macbookair.myfiosgateway.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: Brandon Bennett <brandonbennett@Pursuits-Air.lan>
This commit is contained in:
parent
ba00b15097
commit
9e3de344f4
3 changed files with 262 additions and 53 deletions
|
|
@ -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]
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,27 @@ impl TestServer {
|
|||
Err("User not found".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool that returns a list of calculation results
|
||||
#[tool(
|
||||
name = "calculate-list",
|
||||
description = "Return a list of calculation results"
|
||||
)]
|
||||
pub async fn calculate_list(
|
||||
&self,
|
||||
params: Parameters<CalculationRequest>,
|
||||
) -> Result<Json<Vec<CalculationResult>>, String> {
|
||||
Ok(Json(vec![CalculationResult {
|
||||
sum: params.0.a + params.0.b,
|
||||
product: params.0.a * params.0.b,
|
||||
}]))
|
||||
}
|
||||
|
||||
/// Tool that returns a count
|
||||
#[tool(name = "get-count", description = "Return a count")]
|
||||
pub async fn get_count(&self) -> Result<Json<i32>, String> {
|
||||
Ok(Json(42))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -360,3 +381,38 @@ fn test_call_tool_result_deserialize_without_content() {
|
|||
assert!(result.content.is_empty());
|
||||
assert!(result.structured_content.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_with_array_output_schema() {
|
||||
let server = TestServer::new();
|
||||
let tools = server.tool_router.list_all();
|
||||
|
||||
// Find the calculate-list tool
|
||||
let calculate_list_tool = tools.iter().find(|t| t.name == "calculate-list").unwrap();
|
||||
|
||||
// Verify it has an output schema
|
||||
assert!(calculate_list_tool.output_schema.is_some());
|
||||
|
||||
let schema = calculate_list_tool.output_schema.as_ref().unwrap();
|
||||
|
||||
// Check that the schema contains array type
|
||||
let schema_str = serde_json::to_string(schema).unwrap();
|
||||
assert!(schema_str.contains("array"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_with_primitive_output_schema() {
|
||||
let server = TestServer::new();
|
||||
let tools = server.tool_router.list_all();
|
||||
|
||||
// Find the get-count tool
|
||||
let get_count_tool = tools.iter().find(|t| t.name == "get-count").unwrap();
|
||||
|
||||
// Verify it has an output schema
|
||||
assert!(get_count_tool.output_schema.is_some());
|
||||
|
||||
let schema = get_count_tool.output_schema.as_ref().unwrap();
|
||||
|
||||
// Check that the schema contains integer type
|
||||
assert_eq!(schema.get("type"), Some(&serde_json::json!("integer")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,8 @@ fn test_with_output_schema() {
|
|||
|
||||
assert!(tool.output_schema.is_some());
|
||||
|
||||
// Verify the schema contains expected fields
|
||||
let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap();
|
||||
assert!(schema_str.contains("greeting"));
|
||||
assert!(schema_str.contains("is_adult"));
|
||||
let schema = tool.output_schema.as_ref().unwrap();
|
||||
assert_eq!(schema.get("type"), Some(&serde_json::json!("object")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -57,7 +55,51 @@ fn test_chained_builder_methods() {
|
|||
assert!(input_schema_str.contains("name"));
|
||||
assert!(input_schema_str.contains("age"));
|
||||
|
||||
let output_schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap();
|
||||
assert!(output_schema_str.contains("greeting"));
|
||||
assert!(output_schema_str.contains("is_adult"));
|
||||
let output_schema = tool.output_schema.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
output_schema.get("type"),
|
||||
Some(&serde_json::json!("object"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_output_schema_primitive() {
|
||||
let tool = Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::<i32>();
|
||||
|
||||
assert!(tool.output_schema.is_some());
|
||||
|
||||
let schema = tool.output_schema.as_ref().unwrap();
|
||||
assert_eq!(schema.get("type"), Some(&serde_json::json!("integer")));
|
||||
// title should be stripped from output schema
|
||||
assert!(schema.get("title").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_output_schema_array() {
|
||||
let tool =
|
||||
Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::<Vec<String>>();
|
||||
|
||||
assert!(tool.output_schema.is_some());
|
||||
|
||||
let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap();
|
||||
assert!(schema_str.contains("\"type\":\"array\""));
|
||||
assert!(schema_str.contains("items"));
|
||||
// title should be stripped from output schema
|
||||
assert!(!schema_str.contains("title"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_with_output_schema_option() {
|
||||
let tool =
|
||||
Tool::new("test", "Test tool", JsonObject::new()).with_output_schema::<Option<String>>();
|
||||
|
||||
assert!(tool.output_schema.is_some());
|
||||
|
||||
let schema_str = serde_json::to_string(tool.output_schema.as_ref().unwrap()).unwrap();
|
||||
// Option<String> generates a composition schema (anyOf/oneOf/type array with null)
|
||||
assert!(
|
||||
schema_str.contains("anyOf") || schema_str.contains("oneOf") || schema_str.contains("null"),
|
||||
"Expected composition schema for Option<String>, got: {schema_str}"
|
||||
);
|
||||
assert!(!schema_str.contains("title"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue