Skip to content

Commit d2de72d

Browse files
committed
feat(sacp): add tool enable/disable filtering for MCP servers
Add ability to conditionally enable or disable MCP tools at build time: - EnabledTools enum with DenyList and AllowList variants - disable_tool(name) / enable_tool(name) methods that error on unknown tools - disable_all_tools() / enable_all_tools() to switch filtering modes - list_tools and call_tool filter based on enabled state Includes end-to-end tests with elizacp and cookbook documentation.
1 parent 63cd94c commit d2de72d

4 files changed

Lines changed: 487 additions & 7 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
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+
}

src/sacp-cookbook/src/lib.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
//!
3030
//! - [`global_mcp_server`] - Add tools that work across all sessions
3131
//! - [`per_session_mcp_server`] - Add tools with session-specific state
32+
//! - [`filtering_tools`] - Enable or disable tools dynamically
3233
//! - [`reusable_components`] - Package your proxy as a [`Component`] for composition
3334
//! - [`running_proxies_with_conductor`] - Run your proxy with an agent
3435
//!
@@ -712,6 +713,109 @@ pub mod per_session_mcp_server {
712713
//! [`start_session_proxy`]: sacp::SessionBuilder::start_session_proxy
713714
}
714715

716+
pub mod filtering_tools {
717+
//! Pattern: Filtering which tools are available.
718+
//!
719+
//! Use [`disable_tool`] and [`enable_tool`] to control which tools are
720+
//! visible to clients. This is useful when:
721+
//!
722+
//! - Some tools should only be available in certain configurations
723+
//! - You want to conditionally expose tools based on runtime settings
724+
//! - You need to restrict access to sensitive tools
725+
//!
726+
//! # Disabling specific tools (deny-list)
727+
//!
728+
//! By default, all registered tools are enabled. Use [`disable_tool`] to
729+
//! hide specific tools:
730+
//!
731+
//! ```
732+
//! use sacp::mcp_server::McpServer;
733+
//! use sacp::ProxyToConductor;
734+
//! use schemars::JsonSchema;
735+
//! use serde::Deserialize;
736+
//!
737+
//! #[derive(Debug, Deserialize, JsonSchema)]
738+
//! struct Params {}
739+
//!
740+
//! fn build_server(enable_admin: bool) -> Result<McpServer<ProxyToConductor, impl sacp::JrResponder<ProxyToConductor>>, sacp::Error> {
741+
//! let mut builder = McpServer::builder("my-server")
742+
//! .tool_fn("echo", "Echo a message",
743+
//! async |_p: Params, _cx| Ok("echoed"),
744+
//! sacp::tool_fn!())
745+
//! .tool_fn("admin", "Admin-only tool",
746+
//! async |_p: Params, _cx| Ok("admin action"),
747+
//! sacp::tool_fn!());
748+
//!
749+
//! // Conditionally disable the admin tool
750+
//! if !enable_admin {
751+
//! builder = builder.disable_tool("admin")?;
752+
//! }
753+
//!
754+
//! Ok(builder.build())
755+
//! }
756+
//! ```
757+
//!
758+
//! Disabled tools:
759+
//! - Don't appear in `list_tools` responses
760+
//! - Return "tool not found" errors if called directly
761+
//!
762+
//! # Enabling only specific tools (allow-list)
763+
//!
764+
//! Use [`disable_all_tools`] followed by [`enable_tool`] to create an
765+
//! allow-list where only explicitly enabled tools are available:
766+
//!
767+
//! ```
768+
//! use sacp::mcp_server::McpServer;
769+
//! use sacp::ProxyToConductor;
770+
//! use schemars::JsonSchema;
771+
//! use serde::Deserialize;
772+
//!
773+
//! #[derive(Debug, Deserialize, JsonSchema)]
774+
//! struct Params {}
775+
//!
776+
//! fn build_restricted_server() -> Result<McpServer<ProxyToConductor, impl sacp::JrResponder<ProxyToConductor>>, sacp::Error> {
777+
//! McpServer::builder("restricted-server")
778+
//! .tool_fn("safe", "Safe operation",
779+
//! async |_p: Params, _cx| Ok("safe"),
780+
//! sacp::tool_fn!())
781+
//! .tool_fn("dangerous", "Dangerous operation",
782+
//! async |_p: Params, _cx| Ok("danger!"),
783+
//! sacp::tool_fn!())
784+
//! .tool_fn("experimental", "Experimental feature",
785+
//! async |_p: Params, _cx| Ok("experimental"),
786+
//! sacp::tool_fn!())
787+
//! // Start with all tools disabled
788+
//! .disable_all_tools()
789+
//! // Only enable the safe tool
790+
//! .enable_tool("safe")
791+
//! .map(|b| b.build())
792+
//! }
793+
//! ```
794+
//!
795+
//! # Error handling
796+
//!
797+
//! Both [`enable_tool`] and [`disable_tool`] return `Result` and will error
798+
//! if the tool name doesn't match any registered tool. This helps catch typos:
799+
//!
800+
//! ```
801+
//! use sacp::mcp_server::McpServer;
802+
//! use sacp::ProxyToConductor;
803+
//!
804+
//! // This will error because "ech" is not a registered tool
805+
//! let result = McpServer::<ProxyToConductor, _>::builder("server")
806+
//! .disable_tool("ech"); // Typo! Should be "echo"
807+
//!
808+
//! assert!(result.is_err());
809+
//! ```
810+
//!
811+
//! Calling enable/disable on an already enabled/disabled tool is not an error -
812+
//! the operations are idempotent.
813+
//!
814+
//! [`disable_tool`]: sacp::mcp_server::McpServerBuilder::disable_tool
815+
//! [`enable_tool`]: sacp::mcp_server::McpServerBuilder::enable_tool
816+
//! [`disable_all_tools`]: sacp::mcp_server::McpServerBuilder::disable_all_tools
817+
}
818+
715819
pub mod running_proxies_with_conductor {
716820
//! Pattern: Running proxies with the conductor.
717821
//!

0 commit comments

Comments
 (0)