From b5fd752a4f5fd39803baf513305aafd619f47733 Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Thu, 3 Jul 2025 11:53:53 +1000 Subject: [PATCH 1/2] Adds json schema validation to goose recipe validate cli --- Cargo.lock | 1 + crates/goose-cli/Cargo.toml | 1 + crates/goose-cli/src/commands/recipe.rs | 49 +++++++++++++++++-- .../goose-cli/src/recipes/extract_from_cli.rs | 16 ++++-- crates/goose-cli/src/recipes/recipe.rs | 14 ++++++ 5 files changed, 75 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 735b9a5303aa..d38777094920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3544,6 +3544,7 @@ dependencies = [ "goose-mcp", "http 1.2.0", "indicatif", + "jsonschema", "mcp-client", "mcp-core", "mcp-server", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index be6b72fccf23..0bcfc5506b0c 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -27,6 +27,7 @@ console = "0.15.8" bat = "0.24.0" anyhow = "1.0" serde_json = "1.0" +jsonschema = "0.30.0" tokio = { version = "1.43", features = ["full"] } futures = "0.3" serde = { version = "1.0", features = ["derive"] } # For serialization diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index deadf0f0a840..332e693e4c9a 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -74,10 +74,22 @@ mod tests { } const VALID_RECIPE_CONTENT: &str = r#" -title: "Test Recipe" -description: "A test recipe for deeplink generation" +title: "Test Recipe with Valid JSON Schema" +description: "A test recipe with valid JSON schema" prompt: "Test prompt content" instructions: "Test instructions" +response: + json_schema: + type: object + properties: + result: + type: string + description: "The result" + count: + type: number + description: "A count value" + required: + - result "#; const INVALID_RECIPE_CONTENT: &str = r#" @@ -87,6 +99,20 @@ prompt: "Test prompt content {{ name }}" instructions: "Test instructions" "#; + const RECIPE_WITH_INVALID_JSON_SCHEMA: &str = r#" +title: "Test Recipe with Invalid JSON Schema" +description: "A test recipe with invalid JSON schema" +prompt: "Test prompt content" +instructions: "Test instructions" +response: + json_schema: + type: invalid_type + properties: + result: + type: unknown_type + required: "should_be_array_not_string" +"#; + #[test] fn test_handle_deeplink_valid_recipe() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -95,7 +121,7 @@ instructions: "Test instructions" let result = handle_deeplink(&recipe_path); assert!(result.is_ok()); - assert!(result.unwrap().contains("goose://recipe?config=eyJ2ZXJzaW9uIjoiMS4wLjAiLCJ0aXRsZSI6IlRlc3QgUmVjaXBlIiwiZGVzY3JpcHRpb24iOiJBIHRlc3QgcmVjaXBlIGZvciBkZWVwbGluayBnZW5lcmF0aW9uIiwiaW5zdHJ1Y3Rpb25zIjoiVGVzdCBpbnN0cnVjdGlvbnMiLCJwcm9tcHQiOiJUZXN0IHByb21wdCBjb250ZW50In0%3D")); + assert!(result.unwrap().contains("goose://recipe?config=eyJ2ZXJzaW9uIjoiMS4wLjAiLCJ0aXRsZSI6IlRlc3QgUmVjaXBlIHdpdGggVmFsaWQgSlNPTiBTY2hlbWEiLCJkZXNjcmlwdGlvbiI6IkEgdGVzdCByZWNpcGUgd2l0aCB2YWxpZCBKU09OIHNjaGVtYSIsImluc3RydWN0aW9ucyI6IlRlc3QgaW5zdHJ1Y3Rpb25zIiwicHJvbXB0IjoiVGVzdCBwcm9tcHQgY29udGVudCIsInJlc3BvbnNlIjp7Impzb25fc2NoZW1hIjp7InByb3BlcnRpZXMiOnsiY291bnQiOnsiZGVzY3JpcHRpb24iOiJBIGNvdW50IHZhbHVlIiwidHlwZSI6Im51bWJlciJ9LCJyZXN1bHQiOnsiZGVzY3JpcHRpb24iOiJUaGUgcmVzdWx0IiwidHlwZSI6InN0cmluZyJ9fSwicmVxdWlyZWQiOlsicmVzdWx0Il0sInR5cGUiOiJvYmplY3QifX19")); } #[test] @@ -125,4 +151,21 @@ instructions: "Test instructions" let result = handle_validate(&recipe_path); assert!(result.is_err()); } + + #[test] + fn test_handle_validation_recipe_with_invalid_json_schema() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = create_test_recipe_file( + &temp_dir, + "test_recipe.yaml", + RECIPE_WITH_INVALID_JSON_SCHEMA, + ); + + let result = handle_validate(&recipe_path); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("JSON schema validation failed")); + } } diff --git a/crates/goose-cli/src/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs index 0b886a158ee3..d5615e7b708c 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -28,8 +28,10 @@ pub fn extract_recipe_info_from_cli( Ok(recipe_file) => { let name = extract_recipe_name(&sub_recipe_name); let recipe_file_path = recipe_file.file_path; + let canonical_path = + recipe_file_path.canonicalize().unwrap_or(recipe_file_path); let additional_sub_recipe = SubRecipe { - path: recipe_file_path.to_string_lossy().to_string(), + path: canonical_path.to_string_lossy().to_string(), name, values: None, }; @@ -169,13 +171,21 @@ mod tests { assert!(sub_recipes[0].values.is_none()); assert_eq!( sub_recipes[1].path, - sub_recipe1_path.to_string_lossy().to_string() + sub_recipe1_path + .canonicalize() + .unwrap() + .to_string_lossy() + .to_string() ); assert_eq!(sub_recipes[1].name, "sub_recipe1".to_string()); assert!(sub_recipes[1].values.is_none()); assert_eq!( sub_recipes[2].path, - sub_recipe2_path.to_string_lossy().to_string() + sub_recipe2_path + .canonicalize() + .unwrap() + .to_string_lossy() + .to_string() ); assert_eq!(sub_recipes[2].name, "sub_recipe2".to_string()); assert!(sub_recipes[2].values.is_none()); diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index 5593130bcb92..a7c99e041419 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -88,6 +88,13 @@ pub fn load_recipe(recipe_name: &str) -> Result { recipe_dir_str.to_string(), &HashMap::new(), )?; + + if let Some(response) = &recipe.response { + if let Some(json_schema) = &response.json_schema { + validate_json_schema(json_schema)?; + } + } + Ok(recipe) } @@ -222,5 +229,12 @@ fn apply_values_to_parameters( Ok((param_map, missing_params)) } +fn validate_json_schema(schema: &serde_json::Value) -> Result<()> { + match jsonschema::validator_for(schema) { + Ok(_) => Ok(()), + Err(err) => Err(anyhow::anyhow!("JSON schema validation failed: {}", err)), + } +} + #[cfg(test)] mod tests; From 5e2081043f52ea1604f56d48eef7a253cf64e94e Mon Sep 17 00:00:00 2001 From: Jarrod Sibbison Date: Thu, 3 Jul 2025 17:36:12 +1000 Subject: [PATCH 2/2] Fix symlink canonicalize issue in test --- crates/goose-cli/src/recipes/extract_from_cli.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/goose-cli/src/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs index d5615e7b708c..0550ee26fef4 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -28,10 +28,8 @@ pub fn extract_recipe_info_from_cli( Ok(recipe_file) => { let name = extract_recipe_name(&sub_recipe_name); let recipe_file_path = recipe_file.file_path; - let canonical_path = - recipe_file_path.canonicalize().unwrap_or(recipe_file_path); let additional_sub_recipe = SubRecipe { - path: canonical_path.to_string_lossy().to_string(), + path: recipe_file_path.to_string_lossy().to_string(), name, values: None, }; @@ -231,6 +229,7 @@ response: let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.yaml"); std::fs::write(&recipe_path, test_recipe_content).unwrap(); - (temp_dir, recipe_path) + let canonical_recipe_path = recipe_path.canonicalize().unwrap(); + (temp_dir, canonical_recipe_path) } }