diff --git a/src/commands.rs b/src/commands.rs index 65240889..0cf945ed 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -69,13 +69,10 @@ pub enum Commands { #[command(subcommand)] command: SecretCommands, }, - /// Inspect a WebAssembly component and display its JSON schema (for debugging). + /// Inspect a WebAssembly component (display schema or call component). Inspect { - /// Component ID to inspect - component_id: String, - /// Directory where components are stored. Defaults to $XDG_DATA_HOME/wassette/components - #[arg(long)] - component_dir: Option, + #[command(subcommand)] + command: InspectCommands, }, /// Manage tools (list, read, invoke). Tool { @@ -435,6 +432,35 @@ pub enum ToolCommands { }, } +#[derive(Subcommand, Debug)] +pub enum InspectCommands { + /// Display JSON schema of a component. + Schema { + /// Component ID to inspect + component_id: String, + /// Directory where components are stored. Defaults to $XDG_DATA_HOME/wassette/components + #[arg(long)] + component_dir: Option, + }, + /// Call a component with arguments. + Call { + /// Component ID to call + component_id: String, + /// Arguments in KEY=VALUE format. Can be specified multiple times. + #[arg(long = "arg", value_parser = crate::parse_env_var)] + args: Vec<(String, String)>, + /// Path to policy file for this component + #[arg(long)] + policy_file: Option, + /// Directory where components are stored. Defaults to $XDG_DATA_HOME/wassette/components + #[arg(long)] + component_dir: Option, + /// Output format + #[arg(short = 'o', long = "output-format", default_value = "json")] + output_format: OutputFormat, + }, +} + #[derive(Subcommand, Debug)] pub enum RegistryCommands { /// Search for components in the registry. diff --git a/src/main.rs b/src/main.rs index 5e4c144d..07509d9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use rmcp::service::serve_server; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::StreamableHttpService; use rmcp::transport::{stdio as stdio_transport, SseServer}; -use serde_json::{json, Map}; +use serde_json::{json, Map, Value}; use tracing_subscriber::layer::SubscriberExt as _; use tracing_subscriber::util::SubscriberInitExt as _; @@ -31,8 +31,9 @@ mod utils; use cli_handlers::{create_lifecycle_manager, handle_tool_cli_command}; use commands::{ - Cli, Commands, ComponentCommands, GrantPermissionCommands, PermissionCommands, PolicyCommands, - RegistryCommands, RevokePermissionCommands, SecretCommands, Shell, ToolCommands, Transport, + Cli, Commands, ComponentCommands, GrantPermissionCommands, InspectCommands, PermissionCommands, + PolicyCommands, RegistryCommands, RevokePermissionCommands, SecretCommands, Shell, + ToolCommands, Transport, }; use format::{print_result, OutputFormat}; use server::McpServer; @@ -716,50 +717,273 @@ async fn main() -> Result<()> { } } }, - Commands::Inspect { - component_id, - component_dir, - } => { - let component_dir = component_dir.clone().or_else(|| cli.component_dir.clone()); - let lifecycle_manager = create_lifecycle_manager(component_dir).await?; - - // Get the component schema from the lifecycle manager - let schema = lifecycle_manager - .get_component_schema(component_id) - .await - .context(format!( - "Component '{}' not found. Use 'component load' to load the component first.", - component_id - ))?; - - // Display tools information - if let Some(arr) = schema["tools"].as_array() { - for t in arr { - // The tool info is nested in properties.result - let tool_info = &t["properties"]["result"]; - let name = tool_info["name"] + Commands::Inspect { command } => match command { + InspectCommands::Schema { + component_id, + component_dir, + } => { + let component_dir = component_dir.clone().or_else(|| cli.component_dir.clone()); + let lifecycle_manager = create_lifecycle_manager(component_dir).await?; + + // Get the component schema from the lifecycle manager + let schema = lifecycle_manager + .get_component_schema(component_id) + .await + .context(format!( + "Component '{}' not found. Use 'component load' to load the component first.", + component_id + ))?; + + // Display tools information + if let Some(arr) = schema["tools"].as_array() { + for t in arr { + // The tool info is nested in properties.result + let tool_info = &t["properties"]["result"]; + let name = tool_info["name"] + .as_str() + .unwrap_or("") + .to_string(); + let description: Option = + tool_info["description"].as_str().map(|s| s.to_string()); + let input_schema = tool_info["inputSchema"].clone(); + let output_schema = tool_info["outputSchema"].clone(); + + println!("{name}, {description:?}"); + println!( + "input schema: {}", + serde_json::to_string_pretty(&input_schema)? + ); + println!( + "output schema: {}", + serde_json::to_string_pretty(&output_schema)? + ); + } + } else { + println!("No tools found in component"); + } + } + InspectCommands::Call { + component_id, + args, + policy_file, + component_dir, + output_format, + } => { + let component_dir = component_dir.clone().or_else(|| cli.component_dir.clone()); + let lifecycle_manager = create_lifecycle_manager(component_dir).await?; + + // Ensure the component is loaded in memory first + lifecycle_manager + .ensure_component_loaded(component_id) + .await + .context("Failed to load component into memory")?; + + // Apply policy file if provided + if let Some(policy_path) = policy_file { + tracing::info!("Applying policy from file to component {}", component_id); + + // Use PolicyParser from the policy crate to parse the policy file + let policy_doc = policy::PolicyParser::parse_file(policy_path) + .context("Failed to parse policy file")?; + + // Apply storage permissions from the allow list + if let Some(storage) = &policy_doc.permissions.storage { + if let Some(allow_list) = &storage.allow { + for perm in allow_list { + lifecycle_manager + .grant_permission( + component_id, + "storage", + &json!({ + "uri": perm.uri, + "access": perm.access.iter().map(|a| match a { + policy::AccessType::Read => "read", + policy::AccessType::Write => "write", + }).collect::>() + }), + ) + .await?; + } + } + } + + // Apply network permissions from the allow list + if let Some(network) = &policy_doc.permissions.network { + if let Some(allow_list) = &network.allow { + for perm in allow_list { + match perm { + policy::NetworkPermission::Host(host) => { + lifecycle_manager + .grant_permission( + component_id, + "network", + &json!({ + "host": host.host + }), + ) + .await?; + } + policy::NetworkPermission::Cidr(cidr) => { + lifecycle_manager + .grant_permission( + component_id, + "network", + &json!({ + "cidr": cidr.cidr + }), + ) + .await?; + } + } + } + } + } + + // Apply environment variable permissions from the allow list + if let Some(environment) = &policy_doc.permissions.environment { + if let Some(allow_list) = &environment.allow { + for perm in allow_list { + lifecycle_manager + .grant_permission( + component_id, + "environment-variable", + &json!({ + "key": perm.key + }), + ) + .await?; + } + } + } + + // Apply resource limits if specified + if let Some(resources) = &policy_doc.permissions.resources { + if let Some(limits) = &resources.limits { + let mut limits_json = json!({}); + + if let Some(cpu) = &limits.cpu { + limits_json["cpu"] = match cpu { + policy::CpuLimit::String(s) => json!(s), + policy::CpuLimit::Number(n) => json!(n), + }; + } + + if let Some(memory) = &limits.memory { + limits_json["memory"] = match memory { + policy::MemoryLimit::String(s) => json!(s), + policy::MemoryLimit::Number(n) => json!(n), + }; + } + + if !limits_json.as_object().unwrap().is_empty() { + lifecycle_manager + .grant_permission( + component_id, + "resources", + &json!({ + "limits": limits_json + }), + ) + .await?; + } + } + } + } + + // Get the component schema to find the tool name + let schema = lifecycle_manager + .get_component_schema(component_id) + .await + .context(format!( + "Component '{}' not found. Use 'component load' to load the component first.", + component_id + ))?; + + // Find the tool to call in the component + let tool_name = if let Some(arr) = schema["tools"].as_array() { + if arr.is_empty() { + bail!("No tools found in component"); + } + + // Check if component has multiple tools + if arr.len() > 1 { + // Collect tool names for error message + let tool_names: Vec = arr + .iter() + .map(|tool| { + let tool_info = if tool.get("properties").is_some() { + &tool["properties"]["result"] + } else { + tool + }; + tool_info["name"] + .as_str() + .unwrap_or("") + .to_string() + }) + .collect(); + bail!( + "Component '{}' has multiple tools: [{}]. This command currently only supports components with a single tool.", + component_id, + tool_names.join(", ") + ); + } + + // Only one tool present, proceed + let first_tool = &arr[0]; + let tool_info = if first_tool.get("properties").is_some() { + &first_tool["properties"]["result"] + } else { + // Fallback to flat structure (metadata schema) + first_tool + }; + + tool_info["name"] .as_str() - .unwrap_or("") - .to_string(); - let description: Option = - tool_info["description"].as_str().map(|s| s.to_string()); - let input_schema = tool_info["inputSchema"].clone(); - let output_schema = tool_info["outputSchema"].clone(); - - println!("{name}, {description:?}"); - println!( - "input schema: {}", - serde_json::to_string_pretty(&input_schema)? - ); - println!( - "output schema: {}", - serde_json::to_string_pretty(&output_schema)? - ); + .ok_or_else(|| anyhow::anyhow!("Tool name not found in schema"))? + .to_string() + } else { + bail!("No tools found in component"); + }; + + // Convert args to JSON + let mut arguments = Map::new(); + for (key, value) in args { + // Try to parse value as JSON, otherwise treat as string + let json_value: Value = serde_json::from_str(value) + .unwrap_or_else(|_| Value::String(value.clone())); + arguments.insert(key.clone(), json_value); + } + + // Call the component + let parameters_json = serde_json::to_string(&arguments)?; + let result = lifecycle_manager + .execute_component_call(component_id, &tool_name, ¶meters_json) + .await; + + match result { + Ok(result_str) => { + let result_value: Value = serde_json::from_str(&result_str) + .unwrap_or_else(|_| Value::String(result_str.clone())); + + print_result( + &rmcp::model::CallToolResult { + content: Some(vec![rmcp::model::Content::text( + serde_json::to_string_pretty(&result_value)?, + )]), + structured_content: None, + is_error: Some(false), + }, + *output_format, + )?; + } + Err(e) => { + eprintln!("Error calling component '{}': {}", component_id, e); + std::process::exit(1); + } } - } else { - println!("No tools found in component"); } - } + }, Commands::Registry { command } => match command { RegistryCommands::Search { query, diff --git a/tests/cli_integration_test.rs b/tests/cli_integration_test.rs index ea8e8ed9..f5edc6cf 100644 --- a/tests/cli_integration_test.rs +++ b/tests/cli_integration_test.rs @@ -726,7 +726,9 @@ async fn test_cli_inspect_component() -> Result<()> { .expect("Component ID should be in load output"); // Now inspect the loaded component by ID - let (stdout, stderr, exit_code) = ctx.run_command(&["inspect", component_id]).await?; + let (stdout, stderr, exit_code) = ctx + .run_command(&["inspect", "schema", component_id]) + .await?; assert_eq!(exit_code, 0, "Inspect command failed with stderr: {stderr}"); @@ -753,7 +755,7 @@ async fn test_cli_inspect_invalid_component_id() -> Result<()> { // Try to inspect a non-existent component let (_, stderr, exit_code) = ctx - .run_command(&["inspect", "nonexistent-component"]) + .run_command(&["inspect", "schema", "nonexistent-component"]) .await?; assert_ne!(exit_code, 0, "Command should fail for invalid component ID"); @@ -900,3 +902,204 @@ async fn test_cli_autocomplete_includes_all_commands() -> Result<()> { Ok(()) } + +#[test(tokio::test)] +async fn test_cli_inspect_call_component() -> Result<()> { + let ctx = CliTestContext::new().await?; + let component_path = build_fetch_component().await?; + + // First, load the component + let file_uri = format!("file://{}", component_path.display()); + let (load_stdout, load_stderr, load_exit_code) = + ctx.run_command(&["component", "load", &file_uri]).await?; + + assert_eq!( + load_exit_code, 0, + "Load command failed with stderr: {load_stderr}" + ); + + let load_output: Value = ctx.parse_json_output(&load_stdout)?; + let component_id = load_output["id"] + .as_str() + .expect("Component ID should be in load output"); + + // Grant network permission for the component to call example.com + let (_grant_stdout, grant_stderr, grant_exit_code) = ctx + .run_command(&[ + "permission", + "grant", + "network", + component_id, + "example.com", + ]) + .await?; + + assert_eq!( + grant_exit_code, 0, + "Grant network permission failed with stderr: {grant_stderr}" + ); + + // Now call the component with arguments + let (stdout, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + component_id, + "--arg", + "url=https://example.com", + ]) + .await?; + + assert_eq!( + exit_code, 0, + "Inspect call command failed with stderr: {stderr}" + ); + + // Verify the output contains expected result + let result: Value = ctx.parse_json_output(&stdout)?; + assert!( + result.is_object() || result.is_string(), + "Result should be a valid JSON object or string" + ); + + Ok(()) +} + +#[test(tokio::test)] +async fn test_cli_inspect_call_with_multiple_args() -> Result<()> { + let ctx = CliTestContext::new().await?; + let component_path = build_fetch_component().await?; + + // First, load the component + let file_uri = format!("file://{}", component_path.display()); + let (load_stdout, load_stderr, load_exit_code) = + ctx.run_command(&["component", "load", &file_uri]).await?; + + assert_eq!( + load_exit_code, 0, + "Load command failed with stderr: {load_stderr}" + ); + + let load_output: Value = ctx.parse_json_output(&load_stdout)?; + let component_id = load_output["id"] + .as_str() + .expect("Component ID should be in load output"); + + // Grant network permission + let (_grant_stdout, grant_stderr, grant_exit_code) = ctx + .run_command(&[ + "permission", + "grant", + "network", + component_id, + "httpbin.org", + ]) + .await?; + + assert_eq!( + grant_exit_code, 0, + "Grant network permission failed with stderr: {grant_stderr}" + ); + + // Now call the component with multiple arguments + let (stdout, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + component_id, + "--arg", + "url=https://httpbin.org/get", + "--arg", + "method=GET", + ]) + .await?; + + assert_eq!( + exit_code, 0, + "Inspect call with multiple args failed with stderr: {stderr}" + ); + + // Verify the output is valid JSON + let _result: Value = ctx.parse_json_output(&stdout)?; + + Ok(()) +} + +#[test(tokio::test)] +async fn test_cli_inspect_call_with_policy_file() -> Result<()> { + let ctx = CliTestContext::new().await?; + let component_path = build_fetch_component().await?; + + // First, load the component + let file_uri = format!("file://{}", component_path.display()); + let (load_stdout, load_stderr, load_exit_code) = + ctx.run_command(&["component", "load", &file_uri]).await?; + + assert_eq!( + load_exit_code, 0, + "Load command failed with stderr: {load_stderr}" + ); + + let load_output: Value = ctx.parse_json_output(&load_stdout)?; + let component_id = load_output["id"] + .as_str() + .expect("Component ID should be in load output"); + + // Create a temporary policy file with correct format + let policy_content = r#" +version: "1.0" +permissions: + network: + allow: + - host: "example.com" +"#; + let policy_file = ctx.temp_dir.path().join("test-policy.yaml"); + std::fs::write(&policy_file, policy_content)?; + + // Now call the component with the policy file + let (stdout, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + component_id, + "--arg", + "url=https://example.com", + "--policy-file", + policy_file.to_str().unwrap(), + ]) + .await?; + + assert_eq!( + exit_code, 0, + "Inspect call with policy file failed with stderr: {stderr}" + ); + + // Verify the output is valid JSON + let _result: Value = ctx.parse_json_output(&stdout)?; + + Ok(()) +} + +#[test(tokio::test)] +async fn test_cli_inspect_call_invalid_component() -> Result<()> { + let ctx = CliTestContext::new().await?; + + // Try to call a non-existent component + let (_, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + "nonexistent-component", + "--arg", + "test=value", + ]) + .await?; + + assert_ne!(exit_code, 0, "Command should fail for invalid component ID"); + assert!( + stderr.contains("not found") || stderr.contains("Error"), + "Error message should indicate component not found" + ); + + Ok(()) +}