|
| 1 | +//! Integration tests for tool enable/disable functionality |
| 2 | +//! |
| 3 | +//! These tests verify that `disable_tool`, `enable_tool`, `disable_all_tools`, |
| 4 | +//! and `enable_all_tools` correctly filter which tools are visible and callable. |
| 5 | +
|
| 6 | +use sacp::Component; |
| 7 | +use sacp::ProxyToConductor; |
| 8 | +use sacp::link::AgentToClient; |
| 9 | +use sacp::mcp_server::McpServer; |
| 10 | +use sacp_conductor::{Conductor, ProxiesAndAgent}; |
| 11 | +use schemars::JsonSchema; |
| 12 | +use serde::{Deserialize, Serialize}; |
| 13 | +use tokio::io::duplex; |
| 14 | +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; |
| 15 | + |
| 16 | +/// Input for the echo tool |
| 17 | +#[derive(Debug, Serialize, Deserialize, JsonSchema)] |
| 18 | +struct EchoInput { |
| 19 | + message: String, |
| 20 | +} |
| 21 | + |
| 22 | +/// Input for the greet tool |
| 23 | +#[derive(Debug, Serialize, Deserialize, JsonSchema)] |
| 24 | +struct GreetInput { |
| 25 | + name: String, |
| 26 | +} |
| 27 | + |
| 28 | +/// Empty input for simple tools |
| 29 | +#[derive(Debug, Serialize, Deserialize, JsonSchema)] |
| 30 | +struct EmptyInput {} |
| 31 | + |
| 32 | +/// Create a proxy with multiple tools, some disabled via deny-list |
| 33 | +fn create_proxy_with_disabled_tool() -> Result<sacp::DynComponent<ProxyToConductor>, sacp::Error> { |
| 34 | + let mcp_server = McpServer::builder("test_server".to_string()) |
| 35 | + .instructions("Test MCP server with some disabled tools") |
| 36 | + .tool_fn( |
| 37 | + "echo", |
| 38 | + "Echo a message back", |
| 39 | + async |input: EchoInput, _context| Ok(format!("Echo: {}", input.message)), |
| 40 | + sacp::tool_fn!(), |
| 41 | + ) |
| 42 | + .tool_fn( |
| 43 | + "greet", |
| 44 | + "Greet someone by name", |
| 45 | + async |input: GreetInput, _context| Ok(format!("Hello, {}!", input.name)), |
| 46 | + sacp::tool_fn!(), |
| 47 | + ) |
| 48 | + .tool_fn( |
| 49 | + "secret", |
| 50 | + "A secret tool that should be disabled", |
| 51 | + async |_input: EmptyInput, _context| Ok("This is secret!".to_string()), |
| 52 | + sacp::tool_fn!(), |
| 53 | + ) |
| 54 | + .disable_tool("secret")? |
| 55 | + .build(); |
| 56 | + |
| 57 | + Ok(sacp::DynComponent::new(TestProxy { mcp_server })) |
| 58 | +} |
| 59 | + |
| 60 | +/// Create a proxy where all tools are disabled except specific ones (allow-list) |
| 61 | +fn create_proxy_with_allowlist() -> Result<sacp::DynComponent<ProxyToConductor>, sacp::Error> { |
| 62 | + let mcp_server = McpServer::builder("allowlist_server".to_string()) |
| 63 | + .instructions("Test MCP server with allow-list") |
| 64 | + .tool_fn( |
| 65 | + "echo", |
| 66 | + "Echo a message back", |
| 67 | + async |input: EchoInput, _context| Ok(format!("Echo: {}", input.message)), |
| 68 | + sacp::tool_fn!(), |
| 69 | + ) |
| 70 | + .tool_fn( |
| 71 | + "greet", |
| 72 | + "Greet someone by name", |
| 73 | + async |input: GreetInput, _context| Ok(format!("Hello, {}!", input.name)), |
| 74 | + sacp::tool_fn!(), |
| 75 | + ) |
| 76 | + .tool_fn( |
| 77 | + "secret", |
| 78 | + "A secret tool", |
| 79 | + async |_input: EmptyInput, _context| Ok("This is secret!".to_string()), |
| 80 | + sacp::tool_fn!(), |
| 81 | + ) |
| 82 | + .disable_all_tools() |
| 83 | + .enable_tool("echo")? |
| 84 | + .build(); |
| 85 | + |
| 86 | + Ok(sacp::DynComponent::new(TestProxy { mcp_server })) |
| 87 | +} |
| 88 | + |
| 89 | +struct TestProxy<R: sacp::JrResponder<ProxyToConductor>> { |
| 90 | + mcp_server: McpServer<ProxyToConductor, R>, |
| 91 | +} |
| 92 | + |
| 93 | +impl<R: sacp::JrResponder<ProxyToConductor> + 'static + Send> Component<ProxyToConductor> |
| 94 | + for TestProxy<R> |
| 95 | +{ |
| 96 | + async fn serve( |
| 97 | + self, |
| 98 | + client: impl Component<sacp::link::ConductorToProxy>, |
| 99 | + ) -> Result<(), sacp::Error> { |
| 100 | + ProxyToConductor::builder() |
| 101 | + .name("test-proxy") |
| 102 | + .with_mcp_server(self.mcp_server) |
| 103 | + .serve(client) |
| 104 | + .await |
| 105 | + } |
| 106 | +} |
| 107 | + |
| 108 | +/// Elizacp agent component wrapper for testing |
| 109 | +struct ElizacpAgentComponent; |
| 110 | + |
| 111 | +impl Component<AgentToClient> for ElizacpAgentComponent { |
| 112 | + async fn serve( |
| 113 | + self, |
| 114 | + client: impl Component<sacp::link::ClientToAgent>, |
| 115 | + ) -> Result<(), sacp::Error> { |
| 116 | + let (elizacp_write, client_read) = duplex(8192); |
| 117 | + let (client_write, elizacp_read) = duplex(8192); |
| 118 | + |
| 119 | + let elizacp_transport = |
| 120 | + sacp::ByteStreams::new(elizacp_write.compat_write(), elizacp_read.compat()); |
| 121 | + |
| 122 | + let client_transport = |
| 123 | + sacp::ByteStreams::new(client_write.compat_write(), client_read.compat()); |
| 124 | + |
| 125 | + tokio::spawn(async move { |
| 126 | + if let Err(e) = |
| 127 | + Component::<AgentToClient>::serve(elizacp::ElizaAgent::new(), elizacp_transport) |
| 128 | + .await |
| 129 | + { |
| 130 | + tracing::error!("Elizacp error: {}", e); |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + Component::<AgentToClient>::serve(client_transport, client).await |
| 135 | + } |
| 136 | +} |
| 137 | + |
| 138 | +// ============================================================================ |
| 139 | +// Tests for deny-list (disable specific tools) |
| 140 | +// ============================================================================ |
| 141 | + |
| 142 | +#[tokio::test] |
| 143 | +async fn test_list_tools_excludes_disabled() -> Result<(), sacp::Error> { |
| 144 | + let result = yopo::prompt( |
| 145 | + Conductor::new_agent( |
| 146 | + "test-conductor".to_string(), |
| 147 | + ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_disabled_tool()?), |
| 148 | + Default::default(), |
| 149 | + ), |
| 150 | + "List tools from test_server", |
| 151 | + ) |
| 152 | + .await?; |
| 153 | + |
| 154 | + // Should contain echo and greet, but NOT secret |
| 155 | + assert!(result.contains("echo"), "Expected 'echo' tool in list"); |
| 156 | + assert!(result.contains("greet"), "Expected 'greet' tool in list"); |
| 157 | + assert!( |
| 158 | + !result.contains("secret"), |
| 159 | + "Disabled 'secret' tool should not appear in list" |
| 160 | + ); |
| 161 | + |
| 162 | + Ok(()) |
| 163 | +} |
| 164 | + |
| 165 | +#[tokio::test] |
| 166 | +async fn test_enabled_tool_can_be_called() -> Result<(), sacp::Error> { |
| 167 | + let result = yopo::prompt( |
| 168 | + Conductor::new_agent( |
| 169 | + "test-conductor".to_string(), |
| 170 | + ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_disabled_tool()?), |
| 171 | + Default::default(), |
| 172 | + ), |
| 173 | + r#"Use tool test_server::echo with {"message": "hello"}"#, |
| 174 | + ) |
| 175 | + .await?; |
| 176 | + |
| 177 | + assert!( |
| 178 | + result.contains("Echo: hello"), |
| 179 | + "Expected echo response, got: {}", |
| 180 | + result |
| 181 | + ); |
| 182 | + |
| 183 | + Ok(()) |
| 184 | +} |
| 185 | + |
| 186 | +#[tokio::test] |
| 187 | +async fn test_disabled_tool_returns_not_found() -> Result<(), sacp::Error> { |
| 188 | + let result = yopo::prompt( |
| 189 | + Conductor::new_agent( |
| 190 | + "test-conductor".to_string(), |
| 191 | + ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_disabled_tool()?), |
| 192 | + Default::default(), |
| 193 | + ), |
| 194 | + r#"Use tool test_server::secret with {}"#, |
| 195 | + ) |
| 196 | + .await?; |
| 197 | + |
| 198 | + // Should get an error about tool not found |
| 199 | + assert!( |
| 200 | + result.contains("not found") || result.contains("error"), |
| 201 | + "Expected error for disabled tool, got: {}", |
| 202 | + result |
| 203 | + ); |
| 204 | + |
| 205 | + Ok(()) |
| 206 | +} |
| 207 | + |
| 208 | +// ============================================================================ |
| 209 | +// Tests for allow-list (disable all, enable specific) |
| 210 | +// ============================================================================ |
| 211 | + |
| 212 | +#[tokio::test] |
| 213 | +async fn test_allowlist_only_shows_enabled_tools() -> Result<(), sacp::Error> { |
| 214 | + let result = yopo::prompt( |
| 215 | + Conductor::new_agent( |
| 216 | + "test-conductor".to_string(), |
| 217 | + ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_allowlist()?), |
| 218 | + Default::default(), |
| 219 | + ), |
| 220 | + "List tools from allowlist_server", |
| 221 | + ) |
| 222 | + .await?; |
| 223 | + |
| 224 | + // Should only contain echo |
| 225 | + assert!(result.contains("echo"), "Expected 'echo' tool in list"); |
| 226 | + assert!( |
| 227 | + !result.contains("greet"), |
| 228 | + "'greet' should not appear (not in allow-list)" |
| 229 | + ); |
| 230 | + assert!( |
| 231 | + !result.contains("secret"), |
| 232 | + "'secret' should not appear (not in allow-list)" |
| 233 | + ); |
| 234 | + |
| 235 | + Ok(()) |
| 236 | +} |
| 237 | + |
| 238 | +#[tokio::test] |
| 239 | +async fn test_allowlist_enabled_tool_works() -> Result<(), sacp::Error> { |
| 240 | + let result = yopo::prompt( |
| 241 | + Conductor::new_agent( |
| 242 | + "test-conductor".to_string(), |
| 243 | + ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_allowlist()?), |
| 244 | + Default::default(), |
| 245 | + ), |
| 246 | + r#"Use tool allowlist_server::echo with {"message": "allowed"}"#, |
| 247 | + ) |
| 248 | + .await?; |
| 249 | + |
| 250 | + assert!( |
| 251 | + result.contains("Echo: allowed"), |
| 252 | + "Expected echo response, got: {}", |
| 253 | + result |
| 254 | + ); |
| 255 | + |
| 256 | + Ok(()) |
| 257 | +} |
| 258 | + |
| 259 | +#[tokio::test] |
| 260 | +async fn test_allowlist_non_enabled_tool_returns_not_found() -> Result<(), sacp::Error> { |
| 261 | + let result = yopo::prompt( |
| 262 | + Conductor::new_agent( |
| 263 | + "test-conductor".to_string(), |
| 264 | + ProxiesAndAgent::new(ElizacpAgentComponent).proxy(create_proxy_with_allowlist()?), |
| 265 | + Default::default(), |
| 266 | + ), |
| 267 | + r#"Use tool allowlist_server::greet with {"name": "World"}"#, |
| 268 | + ) |
| 269 | + .await?; |
| 270 | + |
| 271 | + // greet is registered but not enabled, should error |
| 272 | + assert!( |
| 273 | + result.contains("not found") || result.contains("error"), |
| 274 | + "Expected error for non-enabled tool, got: {}", |
| 275 | + result |
| 276 | + ); |
| 277 | + |
| 278 | + Ok(()) |
| 279 | +} |
0 commit comments