Skip to content

Commit 6719404

Browse files
authored
Merge pull request #103 from nikomatsakis/main
feat(sacp): add tool enable/disable filtering for MCP servers
2 parents 63cd94c + 9b17d5b commit 6719404

File tree

11 files changed

+985
-80
lines changed

11 files changed

+985
-80
lines changed

src/sacp-conductor/src/conductor/mcp_bridge/actor.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use futures::{SinkExt as _, StreamExt as _, channel::mpsc};
2-
use sacp::mcp::{McpClientToServer, McpServerEnd, McpServerToClient};
2+
use sacp::mcp::{McpClientToServer, McpServerPeer, McpServerToClient};
33
use sacp::schema::McpDisconnectNotification;
44
use sacp::{Component, DynComponent, MessageCx};
55
use tracing::info;
@@ -67,7 +67,7 @@ impl McpBridgeConnectionActor {
6767
.run_until(async move |mcp_client_cx| {
6868
let mut to_mcp_client_rx = to_mcp_client_rx;
6969
while let Some(message) = to_mcp_client_rx.next().await {
70-
mcp_client_cx.send_proxied_message_to(McpServerEnd, message)?;
70+
mcp_client_cx.send_proxied_message_to(McpServerPeer, message)?;
7171
}
7272
Ok(())
7373
})
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
//! Tests for running McpServer as a standalone MCP server (not part of ACP).
2+
//!
3+
//! These tests verify that `McpServer` can be used directly with MCP clients
4+
//! via the `Component<McpServerToClient>` implementation.
5+
6+
use rmcp::{ClientHandler, ServiceExt, model::ClientInfo};
7+
use sacp::{
8+
ByteStreams, Component, mcp::McpServerToClient, mcp_server::McpServer, util::run_until,
9+
};
10+
use schemars::JsonSchema;
11+
use serde::{Deserialize, Serialize};
12+
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
13+
14+
/// Input for the echo tool
15+
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
16+
struct EchoInput {
17+
message: String,
18+
}
19+
20+
/// Input for the add tool
21+
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
22+
struct AddInput {
23+
a: i32,
24+
b: i32,
25+
}
26+
27+
/// Output for the add tool (structured output)
28+
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
29+
struct AddOutput {
30+
result: i32,
31+
}
32+
33+
/// Create a test MCP server with echo and add tools
34+
fn create_test_server() -> McpServer<McpServerToClient, impl sacp::JrResponder<McpServerToClient>> {
35+
McpServer::builder("test-server")
36+
.instructions("A test MCP server")
37+
.tool_fn(
38+
"echo",
39+
"Echo a message back",
40+
async |input: EchoInput, _cx| Ok(format!("Echo: {}", input.message)),
41+
sacp::tool_fn!(),
42+
)
43+
.tool_fn(
44+
"add",
45+
"Add two numbers",
46+
async |input: AddInput, _cx| {
47+
Ok(AddOutput {
48+
result: input.a + input.b,
49+
})
50+
},
51+
sacp::tool_fn!(),
52+
)
53+
.build()
54+
}
55+
56+
/// Minimal client handler for rmcp
57+
#[derive(Debug, Clone, Default)]
58+
struct MinimalClientHandler;
59+
60+
impl ClientHandler for MinimalClientHandler {
61+
fn get_info(&self) -> ClientInfo {
62+
ClientInfo::default()
63+
}
64+
}
65+
66+
#[tokio::test]
67+
async fn test_standalone_server_list_tools() -> Result<(), sacp::Error> {
68+
// Create duplex streams for communication
69+
let (server_stream, client_stream) = tokio::io::duplex(8192);
70+
let (server_read, server_write) = tokio::io::split(server_stream);
71+
let (client_read, client_write) = tokio::io::split(client_stream);
72+
73+
// Create the MCP server
74+
let server = create_test_server();
75+
76+
// Wrap client side as ByteStreams (this is what the MCP server will talk to)
77+
let client_as_component = ByteStreams::new(client_write.compat_write(), client_read.compat());
78+
79+
run_until(
80+
Component::<McpServerToClient>::serve(server, client_as_component),
81+
async move {
82+
// Create rmcp client on the server side of the duplex (the "other end")
83+
let client = MinimalClientHandler
84+
.serve((server_read, server_write))
85+
.await
86+
.map_err(sacp::util::internal_error)?;
87+
88+
// List tools
89+
let tools_result = client
90+
.list_tools(None)
91+
.await
92+
.map_err(sacp::util::internal_error)?;
93+
94+
// Verify we got both tools
95+
assert_eq!(tools_result.tools.len(), 2);
96+
97+
let tool_names: Vec<&str> =
98+
tools_result.tools.iter().map(|t| t.name.as_ref()).collect();
99+
assert!(tool_names.contains(&"echo"));
100+
assert!(tool_names.contains(&"add"));
101+
102+
// Clean up
103+
client.cancel().await.map_err(sacp::util::internal_error)?;
104+
Ok(())
105+
},
106+
)
107+
.await
108+
}
109+
110+
#[tokio::test]
111+
async fn test_standalone_server_call_echo_tool() -> Result<(), sacp::Error> {
112+
let (server_stream, client_stream) = tokio::io::duplex(8192);
113+
let (server_read, server_write) = tokio::io::split(server_stream);
114+
let (client_read, client_write) = tokio::io::split(client_stream);
115+
116+
let server = create_test_server();
117+
let client_as_component = ByteStreams::new(client_write.compat_write(), client_read.compat());
118+
119+
run_until(
120+
Component::<McpServerToClient>::serve(server, client_as_component),
121+
async move {
122+
let client = MinimalClientHandler
123+
.serve((server_read, server_write))
124+
.await
125+
.map_err(sacp::util::internal_error)?;
126+
127+
// Call the echo tool
128+
let result = client
129+
.call_tool(rmcp::model::CallToolRequestParam {
130+
name: "echo".into(),
131+
arguments: Some(
132+
serde_json::json!({ "message": "hello world" })
133+
.as_object()
134+
.unwrap()
135+
.clone(),
136+
),
137+
})
138+
.await
139+
.map_err(sacp::util::internal_error)?;
140+
141+
// Verify the result
142+
let text = result
143+
.content
144+
.first()
145+
.and_then(|c| c.raw.as_text())
146+
.map(|t| t.text.as_str())
147+
.expect("Expected text content");
148+
149+
assert_eq!(text, r#""Echo: hello world""#, "Unexpected echo response");
150+
151+
client.cancel().await.map_err(sacp::util::internal_error)?;
152+
Ok(())
153+
},
154+
)
155+
.await
156+
}
157+
158+
#[tokio::test]
159+
async fn test_standalone_server_call_add_tool() -> Result<(), sacp::Error> {
160+
let (server_stream, client_stream) = tokio::io::duplex(8192);
161+
let (server_read, server_write) = tokio::io::split(server_stream);
162+
let (client_read, client_write) = tokio::io::split(client_stream);
163+
164+
let server = create_test_server();
165+
let client_as_component = ByteStreams::new(client_write.compat_write(), client_read.compat());
166+
167+
run_until(
168+
Component::<McpServerToClient>::serve(server, client_as_component),
169+
async move {
170+
let client = MinimalClientHandler
171+
.serve((server_read, server_write))
172+
.await
173+
.map_err(sacp::util::internal_error)?;
174+
175+
// Call the add tool
176+
let result = client
177+
.call_tool(rmcp::model::CallToolRequestParam {
178+
name: "add".into(),
179+
arguments: Some(
180+
serde_json::json!({ "a": 5, "b": 3 })
181+
.as_object()
182+
.unwrap()
183+
.clone(),
184+
),
185+
})
186+
.await
187+
.map_err(sacp::util::internal_error)?;
188+
189+
// The add tool returns structured output (AddOutput)
190+
// Check that we get the expected result
191+
assert!(!result.is_error.unwrap_or(false));
192+
193+
// Structured output should have the result
194+
let content = result.content.first().expect("Expected content");
195+
let text = content.raw.as_text().expect("Expected text content");
196+
assert!(
197+
text.text.contains("8") || text.text.contains("result"),
198+
"Expected result to contain 8, got: {}",
199+
text.text
200+
);
201+
202+
client.cancel().await.map_err(sacp::util::internal_error)?;
203+
Ok(())
204+
},
205+
)
206+
.await
207+
}
208+
209+
#[tokio::test]
210+
async fn test_standalone_server_tool_not_found() -> Result<(), sacp::Error> {
211+
let (server_stream, client_stream) = tokio::io::duplex(8192);
212+
let (server_read, server_write) = tokio::io::split(server_stream);
213+
let (client_read, client_write) = tokio::io::split(client_stream);
214+
215+
let server = create_test_server();
216+
let client_as_component = ByteStreams::new(client_write.compat_write(), client_read.compat());
217+
218+
run_until(
219+
Component::<McpServerToClient>::serve(server, client_as_component),
220+
async move {
221+
let client = MinimalClientHandler
222+
.serve((server_read, server_write))
223+
.await
224+
.map_err(sacp::util::internal_error)?;
225+
226+
// Call a non-existent tool
227+
let result = client
228+
.call_tool(rmcp::model::CallToolRequestParam {
229+
name: "nonexistent".into(),
230+
arguments: None,
231+
})
232+
.await;
233+
234+
// Should get an error
235+
assert!(result.is_err(), "Expected error for non-existent tool");
236+
237+
client.cancel().await.map_err(sacp::util::internal_error)?;
238+
Ok(())
239+
},
240+
)
241+
.await
242+
}
243+
244+
#[tokio::test]
245+
async fn test_standalone_server_with_disabled_tools() -> Result<(), sacp::Error> {
246+
let (server_stream, client_stream) = tokio::io::duplex(8192);
247+
let (server_read, server_write) = tokio::io::split(server_stream);
248+
let (client_read, client_write) = tokio::io::split(client_stream);
249+
250+
// Create server with echo tool disabled
251+
let server = McpServer::builder("test-server")
252+
.tool_fn(
253+
"echo",
254+
"Echo a message",
255+
async |input: EchoInput, _cx| Ok(format!("Echo: {}", input.message)),
256+
sacp::tool_fn!(),
257+
)
258+
.tool_fn(
259+
"add",
260+
"Add two numbers",
261+
async |input: AddInput, _cx| {
262+
Ok(AddOutput {
263+
result: input.a + input.b,
264+
})
265+
},
266+
sacp::tool_fn!(),
267+
)
268+
.disable_tool("echo")?
269+
.build();
270+
271+
let client_as_component = ByteStreams::new(client_write.compat_write(), client_read.compat());
272+
273+
run_until(
274+
Component::<McpServerToClient>::serve(server, client_as_component),
275+
async move {
276+
let client = MinimalClientHandler
277+
.serve((server_read, server_write))
278+
.await
279+
.map_err(sacp::util::internal_error)?;
280+
281+
// List tools - should only show "add"
282+
let tools_result = client
283+
.list_tools(None)
284+
.await
285+
.map_err(sacp::util::internal_error)?;
286+
assert_eq!(tools_result.tools.len(), 1);
287+
assert_eq!(tools_result.tools[0].name.as_ref(), "add");
288+
289+
// Calling disabled tool should fail
290+
let result = client
291+
.call_tool(rmcp::model::CallToolRequestParam {
292+
name: "echo".into(),
293+
arguments: Some(
294+
serde_json::json!({ "message": "test" })
295+
.as_object()
296+
.unwrap()
297+
.clone(),
298+
),
299+
})
300+
.await;
301+
302+
assert!(result.is_err(), "Expected error for disabled tool");
303+
304+
client.cancel().await.map_err(sacp::util::internal_error)?;
305+
Ok(())
306+
},
307+
)
308+
.await
309+
}

0 commit comments

Comments
 (0)