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..0550ee26fef4 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -169,13 +169,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()); @@ -221,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) } } 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;