use crate::agents::ExtensionConfig; use anyhow::Result; use serde::Deserialize; use std::path::Path; #[derive(Debug, Deserialize)] struct BundledExtensionEntry { id: String, name: String, #[serde(rename = "type")] extension_type: String, #[allow(dead_code)] #[serde(default)] enabled: bool, } pub fn validate_bundled_extensions(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; let raw_entries: Vec = serde_json::from_str(&content)?; let total = raw_entries.len(); let mut errors: Vec = Vec::new(); for (index, entry) in raw_entries.iter().enumerate() { let meta: BundledExtensionEntry = match serde_json::from_value(entry.clone()) { Ok(m) => m, Err(e) => { let id = entry .get("id") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let name = entry .get("name") .and_then(|v| v.as_str()) .unwrap_or("unknown"); errors.push(format!( "[{index}] {name} (id={id}): missing required metadata fields: {e}" )); continue; } }; // Check for common field name mistakes before full deserialization if meta.extension_type == "streamable_http" && entry.get("url").is_some() && entry.get("uri").is_none() { errors.push(format!( "[{index}] {} (id={}): has \"url\" field but streamable_http expects \"uri\" — did you mean \"uri\"?", meta.name, meta.id )); continue; } if meta.extension_type == "stdio" && entry.get("cmd").is_none() { errors.push(format!( "[{index}] {} (id={}): stdio extension is missing required \"cmd\" field", meta.name, meta.id )); continue; } if let Err(e) = serde_json::from_value::(entry.clone()) { errors.push(format!("[{index}] {} (id={}): {e}", meta.name, meta.id)); } } if errors.is_empty() { Ok(format!("✓ All {total} extensions validated successfully.")) } else { let mut output = format!("✗ Found {} error(s) in {total} extensions:\n", errors.len()); for error in &errors { output.push_str(&format!("\n {error}")); } anyhow::bail!("{output}"); } } #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::NamedTempFile; fn write_json(content: &str) -> NamedTempFile { let mut f = NamedTempFile::new().unwrap(); f.write_all(content.as_bytes()).unwrap(); f } #[test] fn test_valid_builtin() { let f = write_json( r#"[{ "id": "developer", "name": "developer", "display_name": "Developer", "description": "Dev tools", "enabled": true, "type": "builtin", "timeout": 300, "bundled": true }]"#, ); let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); assert!(result.unwrap().contains("1 extensions validated")); } #[test] fn test_valid_stdio() { let f = write_json( r#"[{ "id": "googledrive", "name": "Google Drive", "description": "Google Drive integration", "enabled": false, "type": "stdio", "cmd": "uvx", "args": ["mcp_gdrive@latest"], "env_keys": [], "timeout": 300, "bundled": true }]"#, ); let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); } #[test] fn test_valid_streamable_http() { let f = write_json( r#"[{ "id": "asana", "name": "Asana", "display_name": "Asana", "description": "Manage Asana tasks", "enabled": false, "type": "streamable_http", "uri": "https://mcp.asana.com/mcp", "env_keys": [], "timeout": 300, "bundled": true }]"#, ); let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); } #[test] fn test_invalid_type_http() { let f = write_json( r#"[{ "id": "asana", "name": "Asana", "description": "Manage Asana tasks", "enabled": false, "type": "http", "uri": "https://mcp.asana.com/mcp", "timeout": 300, "bundled": true }]"#, ); let result = validate_bundled_extensions(f.path()); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("Asana")); assert!(err.contains("unknown variant `http`")); } #[test] fn test_url_instead_of_uri() { let f = write_json( r#"[{ "id": "neighborhood", "name": "Neighborhood", "description": "Neighborhood tools", "enabled": false, "type": "streamable_http", "url": "https://example.com/mcp", "timeout": 300, "bundled": true }]"#, ); let result = validate_bundled_extensions(f.path()); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("uri")); } #[test] fn test_missing_cmd_for_stdio() { let f = write_json( r#"[{ "id": "test", "name": "Test", "description": "Test extension", "enabled": false, "type": "stdio", "args": [], "timeout": 300, "bundled": true }]"#, ); let result = validate_bundled_extensions(f.path()); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("cmd")); } #[test] fn test_valid_entries_before_invalid_still_pass() { let f = write_json( r#"[ { "id": "developer", "name": "developer", "description": "Dev tools", "enabled": true, "type": "builtin", "timeout": 300, "bundled": true }, { "id": "bad", "name": "Bad Extension", "description": "This one is broken", "enabled": false, "type": "http", "uri": "https://example.com", "timeout": 300, "bundled": true } ]"#, ); let result = validate_bundled_extensions(f.path()); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("1 error(s)")); assert!(err.contains("Bad Extension")); } #[test] fn test_empty_array_is_valid() { let f = write_json("[]"); let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); assert!(result.unwrap().contains("0 extensions validated")); } }